[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
2026-09-03 00:50:48 -04:00
|
|
|
#!/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"])
|
|
|
|
|
|
|
|
|
|
|
2026-09-03 03:30:54 -04:00
|
|
|
class LAbiFigee(unittest.TestCase):
|
|
|
|
|
"""Une 3D demandée que l'ABI figée annule doit se voir.
|
|
|
|
|
|
|
|
|
|
Depuis libvirt 12.5.0, le <model> vidéo porte un attribut « device »
|
|
|
|
|
qui grave le device QEMU retenu, pour tenir l'ABI de l'invité stable
|
|
|
|
|
d'un démarrage à l'autre. Il l'emporte sur « accel3d » : une VM
|
|
|
|
|
démarrée une première fois sans 3D garde le device sans GL, et cocher
|
|
|
|
|
la 3D ensuite ne change rien à ce que QEMU reçoit. La définition et
|
|
|
|
|
la ligne de commande se contredisent alors en silence.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
XML = (
|
|
|
|
|
"<domain><name>vm</name><vcpu>8</vcpu>"
|
|
|
|
|
"<memory unit='KiB'>33554432</memory><devices>"
|
|
|
|
|
"<video><model type='virtio' heads='1' {attr}>"
|
|
|
|
|
"<acceleration accel3d='{accel}'/></model></video>"
|
|
|
|
|
"<graphics type='egl-headless'>"
|
|
|
|
|
"<gl rendernode='/dev/dri/renderD128'/></graphics>"
|
|
|
|
|
"</devices></domain>"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _etat(self, device="", accel="yes"):
|
|
|
|
|
from script.todo.qemu_hardware import hw_state
|
|
|
|
|
|
|
|
|
|
attr = f"device='{device}'" if device else ""
|
|
|
|
|
return hw_state(self.XML.format(attr=attr, accel=accel), False)
|
|
|
|
|
|
|
|
|
|
def test_the_pinned_device_is_read(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self._etat("virtio-vga")["video_device"], "virtio-vga"
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(self._etat()["video_device"], "")
|
|
|
|
|
|
|
|
|
|
def test_a_non_gl_pin_defeats_the_requested_3d(self):
|
|
|
|
|
from script.todo.qemu_hardware import pin_defeats_3d
|
|
|
|
|
|
|
|
|
|
self.assertTrue(pin_defeats_3d(self._etat("virtio-vga")))
|
|
|
|
|
|
|
|
|
|
def test_a_gl_pin_does_not(self):
|
|
|
|
|
"""Le suffixe « -gl » est ce qui distingue les deux devices.
|
|
|
|
|
|
|
|
|
|
Sans cette lecture, tout épinglage passerait pour une panne et la
|
|
|
|
|
VM correctement accélérée porterait un avertissement à tort.
|
|
|
|
|
"""
|
|
|
|
|
from script.todo.qemu_hardware import pin_defeats_3d
|
|
|
|
|
|
|
|
|
|
self.assertFalse(pin_defeats_3d(self._etat("virtio-vga-gl")))
|
|
|
|
|
self.assertFalse(pin_defeats_3d(self._etat("virtio-gpu-gl")))
|
|
|
|
|
|
|
|
|
|
def test_no_pin_and_no_3d_are_not_flagged(self):
|
|
|
|
|
"""Deux cas voisins qu'un test trop large confondrait : libvirt
|
|
|
|
|
antérieur à 12.5.0 n'écrit pas l'attribut, et une VM sans 3D
|
|
|
|
|
demandée n'a rien à signaler même si son device est figé."""
|
|
|
|
|
from script.todo.qemu_hardware import pin_defeats_3d
|
|
|
|
|
|
|
|
|
|
self.assertFalse(pin_defeats_3d(self._etat()))
|
|
|
|
|
self.assertFalse(pin_defeats_3d(self._etat("virtio-vga", accel="no")))
|
|
|
|
|
|
|
|
|
|
def test_the_summary_says_so(self):
|
|
|
|
|
"""La ligne de résumé est celle sur laquelle on décide : elle ne
|
|
|
|
|
doit pas afficher « 3D » tout court quand la 3D ne tourne pas."""
|
|
|
|
|
from script.todo.qemu_hardware import hw_summary
|
|
|
|
|
|
|
|
|
|
figee = hw_summary(self._etat("virtio-vga"))
|
|
|
|
|
vivante = hw_summary(self._etat("virtio-vga-gl"))
|
|
|
|
|
self.assertIn("virtio-vga", figee)
|
|
|
|
|
self.assertIn("⚠", figee)
|
|
|
|
|
self.assertNotIn("⚠", vivante)
|
|
|
|
|
|
|
|
|
|
|
[FIX] qemu 3D : donner à l'invité l'accès au nœud de rendu
Le matériel virtuel accéléré ne suffisait pas. Dans l'invité, le nœud de
rendu appartient à « root:render » en 0660 et le compte créé n'y était
pas : toute application GL retombait sur le rendu logiciel alors que la
négociation VIRGL avait réussi, et rien ne le signalait. En session
graphique locale, logind pose une ACL pour l'utilisateur du siège ; en
SSH ou en tty, le cas d'une VM de ce parc, personne ne la pose.
Les groupes sont DÉCLARÉS avant d'être utilisés : « useradd -G » échoue
sur un nom inconnu, et cloud-init ne crée alors pas le compte du tout —
la VM démarre inaccessible. « render » manque des images anciennes. Même
parité côté preseed, par « groupadd -f ». « --gpu off » n'ajoute rien.
--- EN ---
Accelerated virtual hardware was not enough. In the guest the render
node is « root:render » at 0660 and the created account was not in it:
every GL application fell back to software rendering although VIRGL
negotiation had succeeded, with nothing to say so. On a local graphical
session logind sets an ACL for the seat's user; over SSH or on a tty,
which is what these VMs get, nobody sets one.
Groups are DECLARED before use: « useradd -G » fails on an unknown name
and cloud-init then creates no account at all — the VM boots
unreachable. « render » is missing from older images. The preseed keeps
parity via « groupadd -f ». « --gpu off » adds nothing.
Assisted-by: Claude Opus 5
2026-09-03 03:54:01 -04:00
|
|
|
try:
|
|
|
|
|
import yaml
|
|
|
|
|
except ImportError:
|
|
|
|
|
yaml = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class LesGroupesGpuDeLInvite(unittest.TestCase):
|
|
|
|
|
"""Le compte de l'invité doit pouvoir OUVRIR le nœud de rendu.
|
|
|
|
|
|
|
|
|
|
Le matériel virtuel accéléré ne suffit pas : dans l'invité, le nœud
|
|
|
|
|
appartient à « root:render » en 0660, et un compte hors de ce groupe
|
|
|
|
|
retombe sur le rendu logiciel alors que la négociation VIRGL a réussi.
|
|
|
|
|
Rien ne le signale. En session graphique locale logind pose une ACL
|
|
|
|
|
pour l'utilisateur du siège ; en SSH ou en tty, personne ne la pose.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _args(self, distro="ubuntu", gpu="auto"):
|
|
|
|
|
return DQ.build_parser().parse_args(
|
|
|
|
|
["--distro", distro, "--gpu", gpu, "--hostname", "vm"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _cc(self, **kw):
|
|
|
|
|
return DQ.build_cloud_config(self._args(**kw), None, [])
|
|
|
|
|
|
|
|
|
|
def test_the_account_joins_the_gpu_groups(self):
|
|
|
|
|
"""Sur les groupes DU COMPTE, et non sur le texte du document : le
|
|
|
|
|
bloc qui déclare les groupes y porte déjà les deux mots, si bien
|
|
|
|
|
qu'y chercher « render » passerait sans que personne n'y entre."""
|
|
|
|
|
noms = DQ.user_groups("ubuntu", gpu=True).split(", ")
|
|
|
|
|
self.assertIn("render", noms)
|
|
|
|
|
self.assertIn("video", noms)
|
|
|
|
|
self.assertNotIn("render", DQ.user_groups("ubuntu").split(", "))
|
|
|
|
|
|
|
|
|
|
def test_gpu_off_leaves_the_account_alone(self):
|
|
|
|
|
"""« off » est un refus explicite : ne rien ajouter alors."""
|
|
|
|
|
cc = self._cc(gpu="off")
|
|
|
|
|
self.assertNotIn("render", cc)
|
|
|
|
|
self.assertNotIn("video", cc)
|
|
|
|
|
|
|
|
|
|
def test_the_admin_group_survives_every_distro(self):
|
|
|
|
|
"""Le groupe d'administration ne doit pas être perdu en chemin :
|
|
|
|
|
sans lui la commodité disparaît, et un nom inconnu ferait bien
|
|
|
|
|
pire — cloud-init ne créerait pas le compte du tout."""
|
|
|
|
|
for distro, attendu in (
|
|
|
|
|
("ubuntu", "sudo"),
|
|
|
|
|
("debian", "sudo"),
|
|
|
|
|
("arch", "wheel"),
|
|
|
|
|
("almalinux", "wheel"),
|
|
|
|
|
):
|
|
|
|
|
with self.subTest(distro=distro):
|
|
|
|
|
self.assertIn(
|
|
|
|
|
attendu, DQ.user_groups(distro, gpu=True).split(", ")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_opensuse_still_gets_no_admin_group(self):
|
|
|
|
|
"""Son cloud-init n'en met pas et « wheel » n'y est pas garanti :
|
|
|
|
|
en ajouter un risquerait un compte jamais créé."""
|
|
|
|
|
noms = DQ.user_groups("opensuse", gpu=True).split(", ")
|
|
|
|
|
self.assertNotIn("wheel", noms)
|
|
|
|
|
self.assertNotIn("sudo", noms)
|
|
|
|
|
self.assertIn("render", noms)
|
|
|
|
|
|
|
|
|
|
@unittest.skipIf(yaml is None, "PyYAML absent")
|
|
|
|
|
def test_the_groups_are_declared_before_being_used(self):
|
|
|
|
|
"""« useradd -G » échoue sur un nom de groupe INCONNU, et cloud-init
|
|
|
|
|
ne crée alors pas le compte : ni mot de passe ni clé SSH, la VM
|
|
|
|
|
démarre inaccessible. « render » manque des images anciennes, donc
|
|
|
|
|
le déclarer n'est pas une précaution de style."""
|
|
|
|
|
doc = yaml.safe_load(self._cc())
|
|
|
|
|
self.assertEqual(["render", "video"], doc["groups"])
|
|
|
|
|
for nom in ("render", "video"):
|
|
|
|
|
self.assertIn(nom, doc["users"][0]["groups"].split(", "))
|
|
|
|
|
|
|
|
|
|
@unittest.skipIf(yaml is None, "PyYAML absent")
|
|
|
|
|
def test_the_document_stays_parsable_everywhere(self):
|
|
|
|
|
for distro in ("ubuntu", "debian", "arch", "almalinux", "opensuse"):
|
|
|
|
|
for gpu in ("auto", "on", "off"):
|
|
|
|
|
with self.subTest(distro=distro, gpu=gpu):
|
|
|
|
|
doc = yaml.safe_load(self._cc(distro=distro, gpu=gpu))
|
|
|
|
|
self.assertEqual("vm", doc["hostname"])
|
|
|
|
|
|
|
|
|
|
def test_the_preseed_creates_the_groups_before_using_them(self):
|
|
|
|
|
"""Debian passe par le preseed et non par cloud-init : la parité
|
|
|
|
|
promise par build_preseed s'y perdrait sans cela. L'ORDRE compte —
|
|
|
|
|
« usermod -aG » sur un groupe absent échoue."""
|
|
|
|
|
pre = DQ.build_preseed(self._args(distro="debian"), "$6$x$y", [])
|
|
|
|
|
i_add = pre.index("groupadd -f render")
|
|
|
|
|
i_use = pre.index("usermod -aG render,video")
|
|
|
|
|
self.assertLess(i_add, i_use)
|
|
|
|
|
|
|
|
|
|
def test_the_preseed_says_nothing_when_gpu_is_off(self):
|
|
|
|
|
pre = DQ.build_preseed(
|
|
|
|
|
self._args(distro="debian", gpu="off"), "$6$x$y", []
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn("groupadd", pre)
|
[ADD] qemu deploy : poser rtk, starship et un agent dans la VM
Une case de plus au catalogue des outils, donc une case dans les deux
écrans sans y toucher. Cochée, elle découvre le choix de l'agent — Claude
Code ou opencode — et l'identité git, pré-remplie avec celle de l'hôte :
c'est ce que la VM reçoit déjà, et un champ vide la ferait croire absente.
Ce qui est saisi prime, champ par champ.
Phase « before », où chaque outil se garde lui-même. Chaque pose est aussi
privée d'entrée standard et bornée dans le temps : « || true » couvre
l'échec, pas l'ATTENTE, et un installateur amont qui pose une question
resterait pendu sur un SSH sans terminal — d'où « -y » pour starship.
--- EN ---
One more entry in the tool catalogue, hence one more box on both screens
for free. Ticked, it reveals the agent choice — Claude Code or opencode —
and the git identity, prefilled from the host: that is what the VM
already gets, and an empty field would suggest none. What is typed wins,
field by field.
Phase « before », where each tool guards itself. Every install is also
denied stdin and time-bounded: « || true » covers failure, not WAITING,
and an upstream installer asking a question would hang on a terminal-less
SSH — hence « -y » for starship.
Assisted-by: Claude Opus 5
2026-09-03 04:12:14 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|