[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""GPU de l'hôte : détection, déploiement, et réglage d'une VM éteinte.
|
|
|
|
|
|
|
|
|
|
Une VM graphique sans accélération rend tout par le processeur — le bureau
|
|
|
|
|
comme l'émulateur Android qui tourne dedans. L'hôte a un GPU ou non ; s'il en
|
|
|
|
|
a un, la VM doit le prendre, et c'est le défaut.
|
|
|
|
|
|
|
|
|
|
Ce que ces tests gardent, appris en le cassant :
|
|
|
|
|
|
|
|
|
|
- UN SEUL « --video » : la 3D remplace le « --video virtio », elle ne s'y
|
|
|
|
|
ajoute pas — deux écrans, et l'invité n'en peuple qu'un.
|
|
|
|
|
- « --add-device --graphics egl-headless » n'est PAS idempotent : deux appels,
|
|
|
|
|
deux affichages. D'où l'état lu avant tout plan.
|
|
|
|
|
- « virt-xml --memory N » ne touche que <currentMemory> : la RAM plafonnait en
|
|
|
|
|
silence à l'ancien maximum.
|
|
|
|
|
- Le retrait de la 3D cible le TYPE egl-headless : sans ce ciblage, c'est la
|
|
|
|
|
console VNC de la VM qui disparaît.
|
|
|
|
|
- « --define » sur chaque commande : sans lui, virt-xml POSE UNE QUESTION et
|
|
|
|
|
le menu se bloque sans rien dire.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import contextlib
|
|
|
|
|
import importlib.util
|
|
|
|
|
import io
|
|
|
|
|
import os
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
sys.argv = ["todo.py"]
|
|
|
|
|
from script.todo import qemu_hardware as hw # noqa: E402
|
|
|
|
|
from script.todo.todo import TODO # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _deploy_qemu():
|
|
|
|
|
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
|
|
|
|
|
path = Path(__file__).resolve().parents[1] / "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()
|
|
|
|
|
|
|
|
|
|
# XML réel d'une VM du parc, réduit à ce qui décide du matériel.
|
|
|
|
|
XML_SANS_3D = """<domain type='kvm'>
|
|
|
|
|
<name>erplibre-ubuntu-2604-gnome</name>
|
|
|
|
|
<memory unit='KiB'>33554432</memory>
|
|
|
|
|
<currentMemory unit='KiB'>33554432</currentMemory>
|
|
|
|
|
<vcpu placement='static'>8</vcpu>
|
|
|
|
|
<devices>
|
|
|
|
|
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
|
|
|
|
|
<listen type='address' address='127.0.0.1'/>
|
|
|
|
|
</graphics>
|
|
|
|
|
<video>
|
|
|
|
|
<model type='virtio' heads='1' primary='yes'/>
|
|
|
|
|
</video>
|
|
|
|
|
</devices>
|
|
|
|
|
</domain>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
XML_AVEC_3D = XML_SANS_3D.replace(
|
|
|
|
|
"<model type='virtio' heads='1' primary='yes'/>",
|
|
|
|
|
"<model type='virtio' heads='1' primary='yes'>"
|
|
|
|
|
"<acceleration accel3d='yes'/></model>",
|
|
|
|
|
).replace(
|
|
|
|
|
"</video>",
|
|
|
|
|
"</video>\n <graphics type='egl-headless'>"
|
|
|
|
|
"<gl rendernode='/dev/dri/renderD128'/></graphics>",
|
|
|
|
|
)
|
|
|
|
|
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
# XML persistant complet : mode CPU, écrans, interface réseau. C'est cette
|
|
|
|
|
# forme-là que « virsh dumpxml --inactive » rend, sans les décorations que
|
|
|
|
|
# libvirt ajoute au démarrage (portid, vnetN, alias).
|
|
|
|
|
XML_COMPLET = """<domain type='kvm'>
|
|
|
|
|
<name>erplibre-ubuntu-2604-gnome</name>
|
|
|
|
|
<memory unit='KiB'>33554432</memory>
|
|
|
|
|
<currentMemory unit='KiB'>33554432</currentMemory>
|
|
|
|
|
<vcpu placement='static'>8</vcpu>
|
|
|
|
|
<cpu mode='host-passthrough' check='none' migratable='on'/>
|
|
|
|
|
<devices>
|
|
|
|
|
<interface type='network'>
|
|
|
|
|
<mac address='52:54:00:fc:a1:34'/>
|
|
|
|
|
<source network='default'/>
|
|
|
|
|
<model type='virtio'/>
|
|
|
|
|
</interface>
|
|
|
|
|
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>
|
|
|
|
|
<video>
|
|
|
|
|
<model type='virtio' heads='1' primary='yes'/>
|
|
|
|
|
</video>
|
|
|
|
|
</devices>
|
|
|
|
|
</domain>
|
|
|
|
|
"""
|
|
|
|
|
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
XML_SERVEUR = """<domain type='kvm'>
|
|
|
|
|
<name>erplibre-serveur</name>
|
|
|
|
|
<memory unit='KiB'>2097152</memory>
|
|
|
|
|
<vcpu placement='static'>2</vcpu>
|
|
|
|
|
<devices/>
|
|
|
|
|
</domain>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
NODE = "/dev/dri/renderD128"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDetection(unittest.TestCase):
|
|
|
|
|
"""Le GPU se lit dans /dev/dri, pas dans une liste de cartes connues."""
|
|
|
|
|
|
|
|
|
|
def _dri(self, *names):
|
|
|
|
|
tmp = tempfile.TemporaryDirectory()
|
|
|
|
|
self.addCleanup(tmp.cleanup)
|
|
|
|
|
for n in names:
|
|
|
|
|
Path(tmp.name, n).touch()
|
|
|
|
|
return tmp.name
|
|
|
|
|
|
|
|
|
|
def test_it_keeps_render_nodes_and_ignores_the_rest(self):
|
|
|
|
|
"""« card0 » est le nœud d'affichage, pas de rendu : QEMU ouvre
|
|
|
|
|
renderD*, et lui donner card0 échouerait au démarrage."""
|
|
|
|
|
found = DQ.host_render_nodes(
|
|
|
|
|
self._dri("card0", "renderD128", "by-path")
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(1, len(found))
|
|
|
|
|
self.assertTrue(found[0].endswith("renderD128"))
|
|
|
|
|
|
|
|
|
|
def test_several_cards_come_out_sorted(self):
|
|
|
|
|
found = DQ.host_render_nodes(self._dri("renderD129", "renderD128"))
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
["renderD128", "renderD129"], [Path(f).name for f in found]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_host_without_dri_answers_empty(self):
|
|
|
|
|
"""Cet hyperviseur-ci est lui-même une VM sans GPU : /dev/dri n'existe
|
|
|
|
|
pas du tout. Une exception ici ferait échouer TOUT déploiement."""
|
|
|
|
|
self.assertEqual([], DQ.host_render_nodes("/nexistepas/dri"))
|
|
|
|
|
self.assertEqual("", DQ.host_gpu_node("/nexistepas/dri"))
|
|
|
|
|
|
|
|
|
|
def test_presence_is_the_test_not_our_own_access(self):
|
|
|
|
|
"""Le nœud appartient au groupe « render » ; libvirt donne l'accès au
|
|
|
|
|
démarrage du domaine. Tester nos droits rejetterait un hôte valable."""
|
|
|
|
|
d = self._dri("renderD128")
|
|
|
|
|
os.chmod(Path(d, "renderD128"), 0o000)
|
|
|
|
|
self.assertTrue(DQ.host_gpu_node(d).endswith("renderD128"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDecision(unittest.TestCase):
|
|
|
|
|
"""« Par défaut avec GPU s'il existe » — et le silence n'est pas permis."""
|
|
|
|
|
|
|
|
|
|
def test_auto_takes_the_gpu_when_there_is_one(self):
|
|
|
|
|
on, msg = DQ.gpu_decision("auto", NODE, True)
|
|
|
|
|
self.assertTrue(on)
|
|
|
|
|
self.assertIn(NODE, msg)
|
|
|
|
|
|
|
|
|
|
def test_auto_without_gpu_says_why_it_falls_back(self):
|
|
|
|
|
"""Sans ce message, une VM en rendu logiciel ne s'explique pas — et on
|
|
|
|
|
cherche la lenteur ailleurs pendant des heures."""
|
|
|
|
|
on, msg = DQ.gpu_decision("auto", "", True)
|
|
|
|
|
self.assertFalse(on)
|
|
|
|
|
self.assertTrue(msg.strip())
|
|
|
|
|
|
|
|
|
|
def test_forcing_it_without_a_node_refuses_and_warns(self):
|
|
|
|
|
"""libvirt refuse de démarrer un domaine dont le rendernode manque :
|
|
|
|
|
obéir aveuglément à « --gpu on » livrerait une VM qui ne démarre pas.
|
|
|
|
|
"""
|
|
|
|
|
on, msg = DQ.gpu_decision("on", "", True)
|
|
|
|
|
self.assertFalse(on)
|
|
|
|
|
self.assertIn("⚠", msg)
|
|
|
|
|
|
|
|
|
|
def test_off_stays_off_and_silent(self):
|
|
|
|
|
self.assertEqual((False, ""), DQ.gpu_decision("off", NODE, True))
|
|
|
|
|
|
|
|
|
|
def test_no_screen_no_3d(self):
|
|
|
|
|
"""Une VM serveur n'a pas d'écran : la 3D n'y accélérerait rien, et le
|
|
|
|
|
« --edit --video » échouerait faute de périphérique vidéo."""
|
|
|
|
|
on, _ = DQ.gpu_decision("auto", NODE, False)
|
|
|
|
|
self.assertFalse(on)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDeployArgs(unittest.TestCase):
|
|
|
|
|
def test_the_args_carry_both_halves(self):
|
|
|
|
|
"""L'accélération sur le virtio-gpu ET un affichage capable de
|
|
|
|
|
contexte GL : l'une sans l'autre ne donne aucune 3D."""
|
|
|
|
|
args = DQ.gpu_install_args(NODE)
|
|
|
|
|
self.assertIn("model.acceleration.accel3d=on", " ".join(args))
|
|
|
|
|
self.assertIn(f"gl.rendernode={NODE}", " ".join(args))
|
|
|
|
|
|
|
|
|
|
def test_only_one_video_device_survives(self):
|
|
|
|
|
"""Le bug qui donne deux écrans : garder « --video virtio » à côté du
|
|
|
|
|
« --video » de la 3D. L'invité n'en peuple alors qu'un."""
|
|
|
|
|
video, gpu_args, _ = DQ.gpu_apply(
|
|
|
|
|
["--video", "virtio"], "auto", NODE, True
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual([], video)
|
|
|
|
|
self.assertEqual(1, (video + gpu_args).count("--video"))
|
|
|
|
|
|
|
|
|
|
def test_without_gpu_the_plain_video_stays(self):
|
|
|
|
|
video, gpu_args, _ = DQ.gpu_apply(
|
|
|
|
|
["--video", "virtio"], "auto", "", True
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(["--video", "virtio"], video)
|
|
|
|
|
self.assertEqual([], gpu_args)
|
|
|
|
|
|
|
|
|
|
def test_the_egl_display_is_added_beside_vnc_not_instead(self):
|
|
|
|
|
"""« egl-headless » n'ouvre aucun port : il ne remplace pas la console
|
|
|
|
|
VNC, il porte le contexte OpenGL. Les deux cohabitent."""
|
|
|
|
|
args = DQ.gpu_install_args(NODE)
|
|
|
|
|
self.assertIn("type=egl-headless", " ".join(args))
|
|
|
|
|
self.assertNotIn("vnc", " ".join(args))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestReadState(unittest.TestCase):
|
|
|
|
|
def test_it_reads_what_the_vm_has(self):
|
|
|
|
|
st = hw.hw_state(XML_SANS_3D)
|
|
|
|
|
self.assertEqual("erplibre-ubuntu-2604-gnome", st["name"])
|
|
|
|
|
self.assertEqual(8, st["vcpus"])
|
|
|
|
|
self.assertEqual(32768, st["mem_mib"])
|
|
|
|
|
self.assertEqual("virtio", st["video"])
|
|
|
|
|
self.assertFalse(st["accel3d"])
|
|
|
|
|
self.assertTrue(st["screen"])
|
|
|
|
|
|
|
|
|
|
def test_it_sees_an_existing_3d_setup(self):
|
|
|
|
|
st = hw.hw_state(XML_AVEC_3D)
|
|
|
|
|
self.assertTrue(st["accel3d"])
|
|
|
|
|
self.assertTrue(st["egl"])
|
|
|
|
|
self.assertEqual(NODE, st["render"])
|
|
|
|
|
|
|
|
|
|
def test_egl_headless_alone_is_not_a_screen(self):
|
|
|
|
|
"""Il n'affiche rien et n'ouvre aucun port. Le compter comme écran
|
|
|
|
|
proposerait la 3D à une VM qui n'a rien à accélérer."""
|
|
|
|
|
xml = XML_SERVEUR.replace(
|
|
|
|
|
"<devices/>",
|
|
|
|
|
"<devices><graphics type='egl-headless'/></devices>",
|
|
|
|
|
)
|
|
|
|
|
self.assertFalse(hw.hw_state(xml)["screen"])
|
|
|
|
|
|
|
|
|
|
def test_the_balloon_target_is_what_the_vm_gets(self):
|
|
|
|
|
"""<memory> est le maximum, <currentMemory> ce que la VM voit. Lire le
|
|
|
|
|
premier annoncerait 32 Go à une VM qui en a 4."""
|
|
|
|
|
xml = XML_SANS_3D.replace(
|
|
|
|
|
"<currentMemory unit='KiB'>33554432</currentMemory>",
|
|
|
|
|
"<currentMemory unit='KiB'>4194304</currentMemory>",
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(4096, hw.hw_state(xml)["mem_mib"])
|
|
|
|
|
|
|
|
|
|
def test_units_are_exact(self):
|
|
|
|
|
"""KB vaut mille octets, KiB en vaut 1024 : le schéma libvirt autorise
|
|
|
|
|
les deux, et « à peu près » se voit dans le tableau."""
|
|
|
|
|
for unit, value, mib in (
|
|
|
|
|
("KiB", 1048576, 1024),
|
|
|
|
|
("MiB", 2048, 2048),
|
|
|
|
|
("GiB", 4, 4096),
|
|
|
|
|
("bytes", 1073741824, 1024),
|
|
|
|
|
):
|
|
|
|
|
xml = XML_SERVEUR.replace(
|
|
|
|
|
"<memory unit='KiB'>2097152</memory>",
|
|
|
|
|
f"<memory unit='{unit}'>{value}</memory>",
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(mib, hw.hw_state(xml)["mem_mib"], unit)
|
|
|
|
|
|
|
|
|
|
def test_broken_xml_gives_an_empty_state(self):
|
|
|
|
|
st = hw.hw_state("<domain")
|
|
|
|
|
self.assertEqual("", st["name"])
|
|
|
|
|
self.assertEqual(0, st["vcpus"])
|
|
|
|
|
|
|
|
|
|
def test_autostart_comes_from_outside_the_xml(self):
|
|
|
|
|
"""Il vit dans un lien symbolique de libvirt, pas dans la définition."""
|
|
|
|
|
self.assertTrue(hw.hw_state(XML_SANS_3D, autostart=True)["autostart"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPlan(unittest.TestCase):
|
|
|
|
|
def _plan(self, xml, want, node=NODE, autostart=False):
|
|
|
|
|
return hw.hw_plan(hw.hw_state(xml, autostart), want, node)
|
|
|
|
|
|
|
|
|
|
def test_nothing_wanted_nothing_planned(self):
|
|
|
|
|
self.assertEqual([], self._plan(XML_SANS_3D, {}))
|
|
|
|
|
|
|
|
|
|
def test_the_same_values_change_nothing(self):
|
|
|
|
|
"""Revalider le formulaire sans rien toucher ne doit RIEN lancer."""
|
|
|
|
|
plan = self._plan(
|
|
|
|
|
XML_SANS_3D, {"vcpus": 8, "ram": 32768, "gpu": False}
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual([], plan)
|
|
|
|
|
|
|
|
|
|
def test_3d_already_on_the_same_node_is_not_added_twice(self):
|
|
|
|
|
"""« --add-device --graphics egl-headless » deux fois pose DEUX
|
|
|
|
|
affichages : c'est l'état lu qui l'empêche, pas virt-xml."""
|
|
|
|
|
self.assertEqual([], self._plan(XML_AVEC_3D, {"gpu": True}))
|
|
|
|
|
|
|
|
|
|
def test_another_node_is_edited_in_place(self):
|
|
|
|
|
plan = self._plan(
|
|
|
|
|
XML_AVEC_3D, {"gpu": True}, node="/dev/dri/renderD129"
|
|
|
|
|
)
|
|
|
|
|
joined = [" ".join(e["cmd"]) for e in plan]
|
|
|
|
|
self.assertEqual(1, len(plan))
|
|
|
|
|
self.assertIn("--edit type=egl-headless", joined[0])
|
|
|
|
|
self.assertNotIn("--add-device", joined[0])
|
|
|
|
|
|
|
|
|
|
def test_turning_3d_on_carries_both_halves(self):
|
|
|
|
|
plan = self._plan(XML_SANS_3D, {"gpu": True})
|
|
|
|
|
joined = " ".join(" ".join(e["cmd"]) for e in plan)
|
|
|
|
|
self.assertIn("model.acceleration.accel3d=on", joined)
|
|
|
|
|
self.assertIn(f"gl.rendernode={NODE}", joined)
|
|
|
|
|
|
|
|
|
|
def test_turning_3d_off_targets_the_type_so_vnc_survives(self):
|
|
|
|
|
"""Un « --remove-device --graphics » sans cible emporterait la console
|
|
|
|
|
VNC : plus aucun accès à l'écran de la VM."""
|
|
|
|
|
plan = self._plan(XML_AVEC_3D, {"gpu": False})
|
|
|
|
|
removals = [e for e in plan if "--remove-device" in e["cmd"]]
|
|
|
|
|
self.assertEqual(1, len(removals))
|
|
|
|
|
self.assertIn("type=egl-headless", removals[0]["cmd"])
|
|
|
|
|
|
|
|
|
|
def test_ram_sets_the_maximum_too(self):
|
|
|
|
|
"""« --memory N » seul ne touche que <currentMemory> : la VM plafonne
|
|
|
|
|
à son ancien maximum, sans un mot."""
|
|
|
|
|
plan = self._plan(XML_SANS_3D, {"ram": 65536})
|
|
|
|
|
arg = [c for c in plan[0]["cmd"] if c.startswith("memory=")][0]
|
|
|
|
|
self.assertIn("memory=65536", arg)
|
|
|
|
|
self.assertIn("currentMemory=65536", arg)
|
|
|
|
|
|
|
|
|
|
def test_3d_without_a_host_node_is_refused_with_a_reason(self):
|
|
|
|
|
plan = hw.hw_plan(hw.hw_state(XML_SANS_3D), {"gpu": True}, "")
|
|
|
|
|
self.assertEqual(1, len(plan))
|
|
|
|
|
self.assertNotIn("cmd", plan[0])
|
|
|
|
|
self.assertTrue(plan[0]["skip"])
|
|
|
|
|
|
|
|
|
|
def test_3d_on_a_screenless_vm_is_refused(self):
|
|
|
|
|
"""« --edit --video » échouerait : il n'y a pas de périphérique vidéo
|
|
|
|
|
à modifier. Mieux vaut le dire que laisser virt-xml protester."""
|
|
|
|
|
plan = self._plan(XML_SERVEUR, {"gpu": True})
|
|
|
|
|
self.assertEqual(1, len(plan))
|
|
|
|
|
self.assertIn("skip", plan[0])
|
|
|
|
|
|
|
|
|
|
def test_autostart_only_moves_when_it_differs(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[], self._plan(XML_SANS_3D, {"autostart": True}, autostart=True)
|
|
|
|
|
)
|
|
|
|
|
plan = self._plan(XML_SANS_3D, {"autostart": False}, autostart=True)
|
|
|
|
|
self.assertIn("--disable", plan[0]["cmd"])
|
|
|
|
|
|
|
|
|
|
def test_every_command_defines_and_names_its_uri(self):
|
|
|
|
|
"""Sans « --define », virt-xml INTERROGE l'utilisateur quand le domaine
|
|
|
|
|
tourne : le menu se bloque sur une question qu'on ne voit pas. Sans
|
|
|
|
|
« --connect », un appel non root viserait qemu:///session, où les VM du
|
|
|
|
|
parc n'existent pas."""
|
|
|
|
|
plan = self._plan(XML_SANS_3D, {"vcpus": 4, "ram": 8192, "gpu": True})
|
|
|
|
|
self.assertTrue(plan)
|
|
|
|
|
for entry in plan:
|
|
|
|
|
self.assertIn("--define", entry["cmd"])
|
|
|
|
|
self.assertIn("qemu:///system", entry["cmd"])
|
|
|
|
|
|
|
|
|
|
def test_a_state_without_a_name_plans_nothing(self):
|
|
|
|
|
"""Un dumpxml illisible ne doit pas produire une commande sans cible."""
|
|
|
|
|
self.assertEqual([], hw.hw_plan({}, {"vcpus": 4}, NODE))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWant(unittest.TestCase):
|
|
|
|
|
def test_empty_fields_keep_the_current_values(self):
|
|
|
|
|
"""Valider sans rien saisir ne doit pas rétrécir la VM à néant."""
|
|
|
|
|
st = hw.hw_state(XML_SANS_3D)
|
|
|
|
|
want = hw.build_want(st, "", "", False, False)
|
|
|
|
|
self.assertEqual(8, want["vcpus"])
|
|
|
|
|
self.assertEqual(32768, want["ram"])
|
|
|
|
|
|
|
|
|
|
def test_gigabytes_are_understood(self):
|
|
|
|
|
st = hw.hw_state(XML_SANS_3D)
|
|
|
|
|
self.assertEqual(8192, hw.build_want(st, "", "8G", 0, 0)["ram"])
|
|
|
|
|
|
|
|
|
|
def test_nonsense_does_not_shrink_the_vm(self):
|
|
|
|
|
st = hw.hw_state(XML_SANS_3D)
|
|
|
|
|
want = hw.build_want(st, "beaucoup", "gros", 0, 0)
|
|
|
|
|
self.assertEqual(8, want["vcpus"])
|
|
|
|
|
self.assertEqual(32768, want["ram"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDisplay(unittest.TestCase):
|
|
|
|
|
def test_the_ram_field_stays_short_enough_to_read(self):
|
|
|
|
|
"""« 32768 » ne tient pas dans le champ et s'affichait « 3276 » : un
|
|
|
|
|
nombre tronqué qu'on valide sans regarder rétrécit la machine."""
|
|
|
|
|
self.assertEqual("32G", hw.ram_field(32768))
|
|
|
|
|
self.assertEqual("1536", hw.ram_field(1536))
|
|
|
|
|
for mib in (1024, 12288, 32768, 65536):
|
|
|
|
|
self.assertLessEqual(len(hw.ram_field(mib)), 4)
|
|
|
|
|
|
|
|
|
|
def test_sizes_read_like_sizes(self):
|
|
|
|
|
self.assertEqual("1 Go", hw.fmt_mib(1024))
|
|
|
|
|
self.assertEqual("1,5 Go", hw.fmt_mib(1536))
|
|
|
|
|
self.assertEqual("512 Mo", hw.fmt_mib(512))
|
|
|
|
|
|
|
|
|
|
def test_the_summary_names_the_render_node(self):
|
|
|
|
|
self.assertIn("renderD128", hw.hw_summary(hw.hw_state(XML_AVEC_3D)))
|
|
|
|
|
|
|
|
|
|
def test_a_screen_without_3d_says_software_rendering(self):
|
|
|
|
|
summary = hw.hw_summary(hw.hw_state(XML_SANS_3D))
|
|
|
|
|
self.assertIn("8 vCPU", summary)
|
|
|
|
|
self.assertIn(hw.t("software rendering"), summary)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMenuGlue(unittest.TestCase):
|
|
|
|
|
"""Le raccord dans todo.py : ce qui est éteint, et ce qui s'exécute."""
|
|
|
|
|
|
|
|
|
|
def _todo(self, states, xml=XML_SANS_3D, node=NODE):
|
|
|
|
|
todo = TODO.__new__(TODO)
|
|
|
|
|
todo._qemu_domstate = lambda name: states[name]
|
|
|
|
|
todo._qemu_dumpxml = lambda name: xml.replace(
|
|
|
|
|
"erplibre-ubuntu-2604-gnome", name
|
|
|
|
|
)
|
|
|
|
|
todo._qemu_autostart = lambda name: False
|
|
|
|
|
todo._qemu_host_gpu_node = lambda: node
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
# Aucun test ne doit atteindre virsh : la liste des réseaux est fournie.
|
|
|
|
|
todo._qemu_net_choices = lambda: ["network:default"]
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
todo.launched = []
|
|
|
|
|
todo.execute = mock.Mock()
|
|
|
|
|
todo.execute.exec_command_live = (
|
|
|
|
|
lambda cmd, **kw: todo.launched.append(cmd)
|
|
|
|
|
)
|
|
|
|
|
return todo
|
|
|
|
|
|
|
|
|
|
def _run(self, todo, names, answers):
|
|
|
|
|
it = iter(answers)
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with mock.patch("builtins.input", lambda *a: next(it, "")):
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
todo._qemu_adjust_hardware(names)
|
|
|
|
|
return out.getvalue()
|
|
|
|
|
|
|
|
|
|
def test_a_running_vm_is_left_alone_and_said_so(self):
|
|
|
|
|
"""virt-xml y écrirait une définition qui ne prend effet qu'au
|
|
|
|
|
prochain démarrage : un réglage qui paraît appliqué et ne l'est pas."""
|
|
|
|
|
todo = self._todo({"vm-a": "running"})
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
todo._qemu_hw_form = lambda rows, node, nets=None: {}
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
out = self._run(todo, ["vm-a"], [])
|
|
|
|
|
self.assertIn("vm-a", out)
|
|
|
|
|
self.assertEqual([], todo.launched)
|
|
|
|
|
|
|
|
|
|
def test_a_shut_off_vm_is_adjusted(self):
|
|
|
|
|
todo = self._todo({"vm-a": "shut off"})
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
todo._qemu_hw_form = lambda rows, node, nets=None: {
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
"vm-a": {"vcpus": 4, "ram": 8192, "gpu": True}
|
|
|
|
|
}
|
|
|
|
|
self._run(todo, ["vm-a"], ["o"])
|
|
|
|
|
joined = " ".join(todo.launched)
|
|
|
|
|
self.assertIn("--vcpus 4", joined)
|
|
|
|
|
self.assertIn("accel3d=on", joined)
|
|
|
|
|
self.assertTrue(all(c.startswith("sudo ") for c in todo.launched))
|
|
|
|
|
|
|
|
|
|
def test_nothing_to_change_launches_nothing(self):
|
|
|
|
|
todo = self._todo({"vm-a": "shut off"})
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
todo._qemu_hw_form = lambda rows, node, nets=None: {
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
"vm-a": {"vcpus": 8, "ram": 32768, "gpu": False}
|
|
|
|
|
}
|
|
|
|
|
out = self._run(todo, ["vm-a"], [])
|
|
|
|
|
self.assertEqual([], todo.launched)
|
|
|
|
|
self.assertIn(hw.t("Nothing to change."), out)
|
|
|
|
|
|
|
|
|
|
def test_refusing_the_confirmation_launches_nothing(self):
|
|
|
|
|
todo = self._todo({"vm-a": "shut off"})
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
todo._qemu_hw_form = lambda rows, node, nets=None: {
|
|
|
|
|
"vm-a": {"vcpus": 4}
|
|
|
|
|
}
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
self._run(todo, ["vm-a"], ["n"])
|
|
|
|
|
self.assertEqual([], todo.launched)
|
|
|
|
|
|
|
|
|
|
def test_cancelling_the_form_launches_nothing(self):
|
|
|
|
|
todo = self._todo({"vm-a": "shut off"})
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
todo._qemu_hw_form = lambda rows, node, nets=None: None
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
self._run(todo, ["vm-a"], [])
|
|
|
|
|
self.assertEqual([], todo.launched)
|
|
|
|
|
|
|
|
|
|
def test_the_skipped_3d_is_explained_not_silent(self):
|
|
|
|
|
todo = self._todo({"vm-a": "shut off"}, node="")
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
todo._qemu_hw_form = lambda rows, node, nets=None: {
|
|
|
|
|
"vm-a": {"gpu": True}
|
|
|
|
|
}
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
out = self._run(todo, ["vm-a"], [])
|
|
|
|
|
self.assertIn(hw.t("no render node on the host"), out)
|
|
|
|
|
self.assertEqual([], todo.launched)
|
|
|
|
|
|
|
|
|
|
def test_the_host_gpu_is_announced_before_anything_else(self):
|
|
|
|
|
todo = self._todo({"vm-a": "shut off"})
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
todo._qemu_hw_form = lambda rows, node, nets=None: None
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
out = self._run(todo, ["vm-a"], [])
|
|
|
|
|
self.assertIn(NODE, out)
|
|
|
|
|
|
|
|
|
|
def test_the_prompts_take_over_when_textual_is_absent(self):
|
|
|
|
|
"""Le repli en ligne n'est pas décoratif : sans Textual, c'est la SEULE
|
|
|
|
|
voie, et un {} mal interprété annulerait tout."""
|
|
|
|
|
todo = self._todo({"vm-a": "shut off"})
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
todo._qemu_hw_form = lambda rows, node, nets=None: {}
|
|
|
|
|
# vCPU, RAM, 3D, démarrage auto, mode CPU, écrans, puis la validation.
|
|
|
|
|
self._run(todo, ["vm-a"], ["6", "", "o", "n", "", "", "o"])
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
self.assertIn("--vcpus 6", " ".join(todo.launched))
|
|
|
|
|
|
|
|
|
|
def test_an_empty_answer_keeps_the_current_state(self):
|
|
|
|
|
"""Le défaut d'une question fermée est l'état ACTUEL de la VM : sur un
|
|
|
|
|
formulaire de matériel, le silence ne modifie rien."""
|
|
|
|
|
todo = TODO.__new__(TODO)
|
|
|
|
|
with mock.patch("builtins.input", lambda *a: ""):
|
|
|
|
|
self.assertTrue(todo._qemu_ask_bool("? ", True))
|
|
|
|
|
self.assertFalse(todo._qemu_ask_bool("? ", False))
|
|
|
|
|
with mock.patch("builtins.input", lambda *a: "n'importe quoi"):
|
|
|
|
|
self.assertTrue(todo._qemu_ask_bool("? ", True))
|
|
|
|
|
|
|
|
|
|
def test_autostart_is_read_from_virsh(self):
|
|
|
|
|
out = "Id: -\nName: vm-a\nAutostart: enable\n"
|
|
|
|
|
with mock.patch(
|
|
|
|
|
"subprocess.run",
|
|
|
|
|
return_value=subprocess.CompletedProcess([], 0, out, ""),
|
|
|
|
|
):
|
|
|
|
|
self.assertTrue(TODO._qemu_autostart("vm-a"))
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=OSError):
|
|
|
|
|
self.assertFalse(TODO._qemu_autostart("vm-a"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestForm(unittest.IsolatedAsyncioTestCase):
|
|
|
|
|
"""Le formulaire monté pour de vrai : ce qu'il propose et ce qu'il rend."""
|
|
|
|
|
|
|
|
|
|
async def _mount(self, rows, node):
|
|
|
|
|
app = hw.run_hardware_form(rows, node, run_app=False)
|
|
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
async def test_it_returns_the_intention_on_apply(self):
|
|
|
|
|
from textual.widgets import Checkbox, Input
|
|
|
|
|
|
|
|
|
|
app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
app.query_one("#vcpus0", Input).value = "6"
|
|
|
|
|
app.query_one("#ram0", Input).value = "8G"
|
|
|
|
|
app.query_one("#gpu0", Checkbox).value = True
|
|
|
|
|
await pilot.press("ctrl+s")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
want = app.want["erplibre-ubuntu-2604-gnome"]
|
|
|
|
|
self.assertEqual(6, want["vcpus"])
|
|
|
|
|
self.assertEqual(8192, want["ram"])
|
|
|
|
|
self.assertTrue(want["gpu"])
|
|
|
|
|
|
|
|
|
|
async def test_escape_returns_nothing(self):
|
|
|
|
|
app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await pilot.press("escape")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
self.assertIsNone(app.want)
|
|
|
|
|
|
|
|
|
|
async def test_3d_is_out_of_reach_without_a_host_node(self):
|
|
|
|
|
"""Cocher une case qui ne peut rien produire ferait attendre une
|
|
|
|
|
accélération que l'hôte ne sait pas donner."""
|
|
|
|
|
from textual.widgets import Checkbox
|
|
|
|
|
|
|
|
|
|
app = await self._mount([hw.hw_state(XML_SANS_3D)], "")
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
self.assertTrue(app.query_one("#gpu0", Checkbox).disabled)
|
|
|
|
|
|
|
|
|
|
async def test_3d_is_out_of_reach_for_a_screenless_vm(self):
|
|
|
|
|
from textual.widgets import Checkbox
|
|
|
|
|
|
|
|
|
|
app = await self._mount([hw.hw_state(XML_SERVEUR)], NODE)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
self.assertTrue(app.query_one("#gpu0", Checkbox).disabled)
|
|
|
|
|
|
|
|
|
|
async def test_the_fields_start_on_the_current_values(self):
|
|
|
|
|
from textual.widgets import Input
|
|
|
|
|
|
|
|
|
|
app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
self.assertEqual("8", app.query_one("#vcpus0", Input).value)
|
|
|
|
|
self.assertEqual("32G", app.query_one("#ram0", Input).value)
|
|
|
|
|
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
async def test_the_second_row_carries_cpu_screens_and_network(self):
|
|
|
|
|
from textual.widgets import Input, Select
|
|
|
|
|
|
|
|
|
|
app = hw.run_hardware_form(
|
|
|
|
|
[hw.hw_state(XML_COMPLET)],
|
|
|
|
|
NODE,
|
|
|
|
|
nets=["network:default", "bridge:br0"],
|
|
|
|
|
run_app=False,
|
|
|
|
|
)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
app.query_one("#cpu0", Select).value = "host-model"
|
|
|
|
|
app.query_one("#heads0", Input).value = "2"
|
|
|
|
|
app.query_one("#net0", Select).value = "bridge:br0"
|
|
|
|
|
await pilot.press("ctrl+s")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
want = app.want["erplibre-ubuntu-2604-gnome"]
|
|
|
|
|
self.assertEqual("host-model", want["cpu"])
|
|
|
|
|
self.assertEqual(2, want["heads"])
|
|
|
|
|
self.assertEqual("bridge:br0", want["net"])
|
|
|
|
|
|
|
|
|
|
async def test_the_screens_value_is_actually_visible(self):
|
|
|
|
|
"""Sous six colonnes, Textual dessine le cadre du champ mais PAS son
|
|
|
|
|
contenu : la valeur devient invisible, et on valide un champ qu'on
|
|
|
|
|
croit vide. Pire qu'une troncature, donc vérifié à l'écran."""
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
app = hw.run_hardware_form(
|
|
|
|
|
[dict(hw.hw_state(XML_COMPLET), heads=3)], NODE, run_app=False
|
|
|
|
|
)
|
|
|
|
|
async with app.run_test(size=(80, 24)) as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
svg = app.export_screenshot()
|
|
|
|
|
rendu = re.findall(r">([^<>]+)</text>", svg)
|
|
|
|
|
self.assertIn("3", [txt.strip() for txt in rendu])
|
|
|
|
|
|
|
|
|
|
async def test_without_networks_to_offer_there_is_no_network_field(self):
|
|
|
|
|
"""Un hôte sans pont n'a qu'une voie : une liste à un seul choix ne
|
|
|
|
|
vaut pas la place qu'elle prend."""
|
|
|
|
|
app = hw.run_hardware_form(
|
|
|
|
|
[dict(hw.hw_state(XML_COMPLET), net="")], NODE, run_app=False
|
|
|
|
|
)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
self.assertEqual(0, len(app.query("#net0")))
|
|
|
|
|
await pilot.press("ctrl+s")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
self.assertEqual("", app.want["erplibre-ubuntu-2604-gnome"]["net"])
|
|
|
|
|
|
|
|
|
|
async def test_the_current_network_stays_selected(self):
|
|
|
|
|
"""La liste montre ce que la VM a : sans cela, valider sans y toucher
|
|
|
|
|
la basculerait sur le premier choix de la liste."""
|
|
|
|
|
from textual.widgets import Select
|
|
|
|
|
|
|
|
|
|
app = hw.run_hardware_form(
|
|
|
|
|
[hw.hw_state(XML_COMPLET)],
|
|
|
|
|
NODE,
|
|
|
|
|
nets=["bridge:br0", "network:default"],
|
|
|
|
|
run_app=False,
|
|
|
|
|
)
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
"network:default", app.query_one("#net0", Select).value
|
|
|
|
|
)
|
|
|
|
|
await pilot.press("ctrl+s")
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
want = app.want["erplibre-ubuntu-2604-gnome"]
|
|
|
|
|
self.assertEqual("network:default", want["net"])
|
|
|
|
|
self.assertEqual([], hw.hw_plan(hw.hw_state(XML_COMPLET), want, NODE))
|
|
|
|
|
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
async def test_it_fits_in_eighty_columns(self):
|
|
|
|
|
"""Un terminal de 80 colonnes est le plus petit qu'on rencontre ;
|
|
|
|
|
au-delà, les libellés se tronquent en « Démarrage automatiq… »."""
|
|
|
|
|
import re
|
|
|
|
|
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
rows = [hw.hw_state(XML_COMPLET), hw.hw_state(XML_SERVEUR)]
|
|
|
|
|
app = hw.run_hardware_form(
|
|
|
|
|
rows, NODE, nets=["network:default", "bridge:br0"], run_app=False
|
|
|
|
|
)
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
async with app.run_test(size=(80, 24)) as pilot:
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
svg = app.export_screenshot()
|
|
|
|
|
texte = " ".join(re.findall(r">([^<>]+)</text>", svg))
|
|
|
|
|
self.assertNotIn("…", texte)
|
|
|
|
|
|
|
|
|
|
|
[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM
Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.
La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.
Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.
--- EN ---
The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.
vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.
Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.
Assisted-by: Claude Opus 5
2026-08-21 23:13:42 -04:00
|
|
|
class TestCpuScreensNetwork(unittest.TestCase):
|
|
|
|
|
"""Les trois réglages ajoutés, et pourquoi chacun est celui-là.
|
|
|
|
|
|
|
|
|
|
Mesuré sur l'hôte avant de les offrir : « heads » atteint QEMU
|
|
|
|
|
(max_outputs), « vram » NON sur un virtio-gpu — il n'est donc pas proposé.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _plan(self, xml, want, node=NODE):
|
|
|
|
|
return hw.hw_plan(hw.hw_state(xml), want, node)
|
|
|
|
|
|
|
|
|
|
def test_it_reads_the_cpu_mode_screens_and_network(self):
|
|
|
|
|
st = hw.hw_state(XML_COMPLET)
|
|
|
|
|
self.assertEqual("host-passthrough", st["cpu"])
|
|
|
|
|
self.assertEqual(1, st["heads"])
|
|
|
|
|
self.assertEqual("network:default", st["net"])
|
|
|
|
|
|
|
|
|
|
def test_a_vm_without_an_interface_has_no_network(self):
|
|
|
|
|
self.assertEqual("", hw.hw_state(XML_SERVEUR)["net"])
|
|
|
|
|
|
|
|
|
|
def test_a_bridge_is_read_as_a_bridge(self):
|
|
|
|
|
"""libvirt refuse type='network' avec un pont pour source : le type et
|
|
|
|
|
le nom doivent voyager ensemble."""
|
|
|
|
|
xml = XML_COMPLET.replace(
|
|
|
|
|
"<interface type='network'>", "<interface type='bridge'>"
|
|
|
|
|
).replace("<source network='default'/>", "<source bridge='br0'/>")
|
|
|
|
|
self.assertEqual("bridge:br0", hw.hw_state(xml)["net"])
|
|
|
|
|
|
|
|
|
|
def test_the_label_marks_the_bridge_only(self):
|
|
|
|
|
"""Le réseau libvirt est le cas ordinaire ; le suffixe allongeait le
|
|
|
|
|
libellé au-delà de la liste déroulante, qui se repliait."""
|
|
|
|
|
self.assertEqual("default", hw.net_label("network:default"))
|
|
|
|
|
self.assertIn("br0", hw.net_label("bridge:br0"))
|
|
|
|
|
self.assertIn(hw.t("bridge"), hw.net_label("bridge:br0"))
|
|
|
|
|
|
|
|
|
|
def test_the_spec_names_the_right_virt_xml_key(self):
|
|
|
|
|
self.assertEqual("network=default", hw.net_spec("network:default"))
|
|
|
|
|
self.assertEqual("bridge=br0", hw.net_spec("bridge:br0"))
|
|
|
|
|
|
|
|
|
|
def test_passthrough_keeps_check_and_migratable(self):
|
|
|
|
|
"""C'est ce que virt-install écrit, et ce que veut la virtualisation
|
|
|
|
|
imbriquée : sans eux libvirt vérifie un modèle qu'il n'a pas calculé.
|
|
|
|
|
"""
|
|
|
|
|
xml = XML_COMPLET.replace(
|
|
|
|
|
"<cpu mode='host-passthrough' check='none' migratable='on'/>",
|
|
|
|
|
"<cpu mode='host-model'/>",
|
|
|
|
|
)
|
|
|
|
|
plan = self._plan(xml, {"cpu": "host-passthrough"})
|
|
|
|
|
arg = plan[0]["cmd"][-1]
|
|
|
|
|
self.assertIn("check=none", arg)
|
|
|
|
|
self.assertIn("migratable=on", arg)
|
|
|
|
|
|
|
|
|
|
def test_host_model_carries_nothing_extra(self):
|
|
|
|
|
plan = self._plan(XML_COMPLET, {"cpu": "host-model"})
|
|
|
|
|
self.assertEqual("host-model", plan[0]["cmd"][-1])
|
|
|
|
|
|
|
|
|
|
def test_the_same_cpu_mode_changes_nothing(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[], self._plan(XML_COMPLET, {"cpu": "host-passthrough"})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_screens_go_through_the_video_model(self):
|
|
|
|
|
"""« heads » devient max_outputs sur la ligne QEMU — vérifié par
|
|
|
|
|
domxml-to-native. C'est le seul réglage vidéo qui y arrive."""
|
|
|
|
|
plan = self._plan(XML_COMPLET, {"heads": 2})
|
|
|
|
|
self.assertIn("model.heads=2", plan[0]["cmd"])
|
|
|
|
|
|
|
|
|
|
def test_screens_on_a_screenless_vm_are_refused_not_attempted(self):
|
|
|
|
|
"""« --edit --video » sortirait en erreur au milieu du lot, et les
|
|
|
|
|
commandes suivantes ne partiraient pas."""
|
|
|
|
|
plan = self._plan(XML_SERVEUR, {"heads": 2})
|
|
|
|
|
self.assertEqual(1, len(plan))
|
|
|
|
|
self.assertIn("skip", plan[0])
|
|
|
|
|
|
|
|
|
|
def test_screens_and_3d_are_two_separate_edits(self):
|
|
|
|
|
"""Vérifié sur un domaine réel : le second « --edit --video » ne
|
|
|
|
|
remet pas heads à 1, et le premier ne perd pas l'accélération."""
|
|
|
|
|
plan = self._plan(XML_COMPLET, {"heads": 2, "gpu": True})
|
|
|
|
|
videos = [e for e in plan if "--video" in e.get("cmd", [])]
|
|
|
|
|
self.assertEqual(2, len(videos))
|
|
|
|
|
heads = [e for e in videos if "model.heads=2" in e["cmd"]]
|
|
|
|
|
self.assertEqual(1, len(heads))
|
|
|
|
|
self.assertNotIn("accel3d", " ".join(heads[0]["cmd"]))
|
|
|
|
|
|
|
|
|
|
def test_switching_to_a_bridge(self):
|
|
|
|
|
"""Le MAC et l'adresse PCI survivent — vérifié sur un domaine réel,
|
|
|
|
|
démarré : sans cela l'invité verrait une carte neuve, et son bail
|
|
|
|
|
DHCP comme son nom d'interface changeraient."""
|
|
|
|
|
plan = self._plan(XML_COMPLET, {"net": "bridge:br0"})
|
|
|
|
|
self.assertIn("bridge=br0", plan[0]["cmd"])
|
|
|
|
|
self.assertNotIn("mac", " ".join(plan[0]["cmd"]))
|
|
|
|
|
|
|
|
|
|
def test_switching_back_to_a_libvirt_network(self):
|
|
|
|
|
xml = XML_COMPLET.replace(
|
|
|
|
|
"<interface type='network'>", "<interface type='bridge'>"
|
|
|
|
|
).replace("<source network='default'/>", "<source bridge='br0'/>")
|
|
|
|
|
plan = hw.hw_plan(hw.hw_state(xml), {"net": "network:default"}, NODE)
|
|
|
|
|
self.assertIn("network=default", plan[0]["cmd"])
|
|
|
|
|
|
|
|
|
|
def test_the_same_network_changes_nothing(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[], self._plan(XML_COMPLET, {"net": "network:default"})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_vm_without_an_interface_is_told_not_attempted(self):
|
|
|
|
|
plan = self._plan(XML_SERVEUR, {"net": "bridge:br0"})
|
|
|
|
|
self.assertEqual(1, len(plan))
|
|
|
|
|
self.assertIn("skip", plan[0])
|
|
|
|
|
|
|
|
|
|
def test_an_unknown_current_cpu_mode_stays_offered(self):
|
|
|
|
|
"""Une VM en mode « custom » ne doit pas voir son réglage disparaître
|
|
|
|
|
d'une liste qui l'ignore : la liste afficherait un AUTRE mode que le
|
|
|
|
|
sien, et valider le formulaire le changerait sans le dire."""
|
|
|
|
|
modes = hw.cpu_choices([{"cpu": "custom"}])
|
|
|
|
|
self.assertIn("custom", modes)
|
|
|
|
|
self.assertIn("host-passthrough", modes)
|
|
|
|
|
|
|
|
|
|
def test_the_network_list_merges_the_host_and_the_current_value(self):
|
|
|
|
|
choices = hw.net_choices(
|
|
|
|
|
[{"net": "bridge:br9"}], ["network:default", "bridge:br9"]
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
["network:default", "bridge:br9"], [tok for tok, _ in choices]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_empty_answers_keep_the_current_hardware(self):
|
|
|
|
|
st = hw.hw_state(XML_COMPLET)
|
|
|
|
|
want = hw.build_want(
|
|
|
|
|
st, "", "", False, False, cpu="", heads="", net=""
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual("host-passthrough", want["cpu"])
|
|
|
|
|
self.assertEqual(1, want["heads"])
|
|
|
|
|
self.assertEqual("network:default", want["net"])
|
|
|
|
|
self.assertEqual([], hw.hw_plan(st, want, ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHostNetworks(unittest.TestCase):
|
|
|
|
|
"""Ce que l'hôte propose : ses réseaux libvirt, et ses ponts à lui."""
|
|
|
|
|
|
|
|
|
|
def _choices(self, nets, infos, bridges):
|
|
|
|
|
todo = TODO.__new__(TODO)
|
|
|
|
|
sorties = {}
|
|
|
|
|
sorties["net-list"] = nets
|
|
|
|
|
sorties["bridge"] = bridges
|
|
|
|
|
sorties.update(infos)
|
|
|
|
|
|
|
|
|
|
def fake(cmd):
|
|
|
|
|
if "net-list" in cmd:
|
|
|
|
|
return sorties["net-list"]
|
|
|
|
|
if "net-info" in cmd:
|
|
|
|
|
return sorties.get(cmd[-1], [])
|
|
|
|
|
return sorties["bridge"]
|
|
|
|
|
|
|
|
|
|
todo._qemu_cmd_lines = fake
|
|
|
|
|
return todo._qemu_net_choices()
|
|
|
|
|
|
|
|
|
|
def test_a_libvirt_owned_bridge_is_not_offered_twice(self):
|
|
|
|
|
"""virbr0 EST le réseau « default » : l'offrir aussi comme pont
|
|
|
|
|
proposerait deux fois le même chemin, dont un qui contourne la
|
|
|
|
|
gestion du réseau par libvirt."""
|
|
|
|
|
got = self._choices(
|
|
|
|
|
["default"],
|
|
|
|
|
{"default": ["Name: default", "Bridge: virbr0"]},
|
|
|
|
|
["3: virbr0: <BROADCAST,MULTICAST,UP,LOWER_UP>"],
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(["network:default"], got)
|
|
|
|
|
|
|
|
|
|
def test_a_real_bridge_is_offered(self):
|
|
|
|
|
got = self._choices(
|
|
|
|
|
["default"],
|
|
|
|
|
{"default": ["Bridge: virbr0"]},
|
|
|
|
|
[
|
|
|
|
|
"3: virbr0: <BROADCAST>",
|
|
|
|
|
"4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP>",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(["network:default", "bridge:br0"], got)
|
|
|
|
|
|
|
|
|
|
def test_a_host_without_libvirt_answers_nothing(self):
|
|
|
|
|
todo = TODO.__new__(TODO)
|
|
|
|
|
todo._qemu_cmd_lines = lambda cmd: []
|
|
|
|
|
self.assertEqual([], todo._qemu_net_choices())
|
|
|
|
|
|
|
|
|
|
def test_the_persistent_definition_is_what_gets_read(self):
|
|
|
|
|
"""Sur une VM allumée, « dumpxml » sans --inactive rend la vue VIVANTE
|
|
|
|
|
(portid, vnetN, alias) — pas la définition que virt-xml modifie."""
|
|
|
|
|
vu = {}
|
|
|
|
|
|
|
|
|
|
def fake_run(cmd, **kw):
|
|
|
|
|
vu["cmd"] = cmd
|
|
|
|
|
return subprocess.CompletedProcess([], 0, "<domain/>", "")
|
|
|
|
|
|
|
|
|
|
with mock.patch("subprocess.run", side_effect=fake_run):
|
|
|
|
|
TODO._qemu_dumpxml("vm-a")
|
|
|
|
|
self.assertIn("--inactive", vu["cmd"])
|
|
|
|
|
|
|
|
|
|
def test_a_numbered_pick_defaults_to_the_current_value(self):
|
|
|
|
|
todo = TODO.__new__(TODO)
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
for reponse, attendu in (
|
|
|
|
|
("", "b"),
|
|
|
|
|
("mille", "b"),
|
|
|
|
|
("9", "b"),
|
|
|
|
|
("1", "a"),
|
|
|
|
|
("2", "b"),
|
|
|
|
|
):
|
|
|
|
|
with mock.patch("builtins.input", lambda *a, r=reponse: r):
|
|
|
|
|
with contextlib.redirect_stdout(out):
|
|
|
|
|
got = todo._qemu_pick("t", ["a", "b"], "b")
|
|
|
|
|
self.assertEqual(attendu, got, reponse)
|
|
|
|
|
|
|
|
|
|
|
[ADD] qemu : prendre le GPU de l'hôte, et régler le matériel des VM
Une VM graphique sans accélération rend tout par le processeur : le bureau,
et l'émulateur Android qui tourne dedans — 32 % d'images en retard, mesuré.
Le déploiement prend donc le GPU de l'hôte dès qu'un nœud de rendu existe.
Une VM déjà installée n'avait aucune voie : ni vCPU, ni RAM, ni 3D. Le menu
d'état les règle maintenant, pendant qu'elle est éteinte — le seul moment où
libvirt les lit.
Trois pièges du terrain : « --memory N » ne touche que le ballon, l'ajout
d'egl-headless n'est pas idempotent, et son retrait sans cible emporte la
console VNC. Vérifié sur un domaine jetable, 459 tests verts.
--- EN ---
A graphical VM without acceleration renders everything on the CPU: the
desktop, and the Android emulator inside it — 32 % janky frames, measured.
The deployment now takes the host GPU as soon as a render node exists.
An installed VM had no path at all: no vCPU, no RAM, no 3D. The state menu
now sets them while the VM is shut off — the only moment libvirt reads them.
Three field traps: "--memory N" only moves the balloon, adding egl-headless
is not idempotent, and removing it untargeted takes the VNC console with it.
Verified on a throwaway domain, 459 tests green.
Assisted-by: Claude Opus 5
2026-08-21 22:32:56 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main(verbosity=1)
|