[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
This commit is contained in:
Mathieu Benoit 2026-08-21 22:32:56 -04:00
parent a98d6ec9cd
commit a55b03e199
9 changed files with 1566 additions and 2 deletions

View file

@ -390,6 +390,9 @@ family — and its disk cost is added to the plan before anything is created.
- `--no-install-deps` — never auto-install dependencies.
- `--dry-run` — show the commands without executing anything.
- `--force` — overwrite the existing working qcow2 disk.
- `--gpu` — 3D acceleration by the host GPU: `auto` (default, on when the
host has a render node), `on` (force), `off` (software rendering).
- `--gpu-node` — which render node to use, on a multi-GPU host.
- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The
TODO menu passes its own language.
- `--erplibre-dir` — where ERPLibre will live in the VM
@ -575,6 +578,9 @@ de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé
- `--no-install-deps` — n'installe jamais les dépendances automatiquement.
- `--dry-run` — affiche les commandes sans rien exécuter.
- `--force` — écrase le disque de travail qcow2 existant.
- `--gpu` — accélération 3D par le GPU de l'hôte : `auto` (défaut, activée si
l'hôte a un nœud de rendu), `on` (forcer), `off` (rendu logiciel).
- `--gpu-node` — quel nœud de rendu utiliser, sur un hôte à plusieurs cartes.
- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou
`en`. Le menu TODO passe la sienne.
- `--erplibre-dir` — où ERPLibre sera installé dans la VM
@ -681,6 +687,37 @@ This works over Wi-Fi and needs no VM shutdown — the simplest option for
personal access. Prefer a bridge (below) if the VM must be a full server
exposed on the LAN.
## 3D acceleration (host GPU)
A graphical VM without acceleration renders everything on the CPU — the
desktop, and the Android emulator running inside it. The deployment therefore
takes the host GPU **by default** (`--gpu auto`): when the host exposes a
render node, the VM gets a virtio-GPU with `accel3d` plus an `egl-headless`
display that carries the OpenGL context **beside** the VNC console — it opens
no port and replaces nothing. No render node, no 3D, and the deployment says
why instead of quietly falling back.
```bash
ls /dev/dri/renderD* # the GPU QEMU can use — empty means no 3D
sudo virsh dumpxml <vm-name> | grep -A2 -E "accel3d|egl-headless"
```
An existing VM is adjusted from the TODO menu **while it is shut off**:
libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs ›
[2] Change the state`, then either accept *Adjust hardware before starting*,
or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set
there — in a form when Textual is available, in prompts otherwise.
Two things worth knowing:
- A host that is **itself a VM** has no render node unless a GPU was handed
down to it. Nested without passthrough, 3D is out of reach: the Android
emulator then runs on SwiftShader, and no option changes that.
- Once the VM does have 3D, the emulator can be tried with `-gpu host`
instead of its default `-gpu swangle`: `EL_EMULATOR_GPU=host ./todo.sh`.
It stays a manual test — an emulator whose GL context fails hangs instead
of falling back, so `swangle` remains the default.
## QEMU inside QEMU (nested) & exposing the VM via a bridge
If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only
@ -693,6 +730,39 @@ middle VM uses CPU mode `host-passthrough`. Check from inside the KVM host
accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur
à part entière exposé sur le LAN.
## Accélération 3D (GPU de l'hôte)
Une VM graphique sans accélération rend tout par le processeur — le bureau
comme l'émulateur Android qui tourne dedans. Le déploiement prend donc le GPU
de l'hôte **par défaut** (`--gpu auto`) : si l'hôte expose un nœud de rendu,
la VM reçoit un virtio-GPU avec `accel3d` et un affichage `egl-headless` qui
porte le contexte OpenGL **à côté** de la console VNC — il n'ouvre aucun port
et ne remplace rien. Pas de nœud de rendu, pas de 3D, et le déploiement dit
pourquoi au lieu de retomber en silence.
```bash
ls /dev/dri/renderD* # le GPU utilisable par QEMU — vide : pas de 3D
sudo virsh dumpxml <nom-vm> | grep -A2 -E "accel3d|egl-headless"
```
Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est
éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM ›
Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de
démarrer*, ou prenez `[3] Régler le matériel seulement`. vCPU, RAM, 3D et
démarrage automatique s'y règlent — en formulaire si Textual est présent, en
invites sinon.
Deux choses à savoir :
- Un hôte qui est **lui-même une VM** n'a aucun nœud de rendu, sauf si un GPU
lui a été transmis. Imbriqué sans passthrough, la 3D est hors d'atteinte :
l'émulateur Android tourne alors sur SwiftShader, et aucune option n'y
change rien.
- Quand la VM a la 3D, l'émulateur peut être essayé en `-gpu host` plutôt
qu'en `-gpu swangle`, son défaut : `EL_EMULATOR_GPU=host ./todo.sh`. Ça
reste un essai manuel — un émulateur dont le contexte GL échoue reste pendu
au lieu de retomber, d'où `swangle` par défaut.
## QEMU dans QEMU (imbriqué) & exposer la VM via un pont
Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne

View file

@ -287,6 +287,9 @@ de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé
- `--no-install-deps` — n'installe jamais les dépendances automatiquement.
- `--dry-run` — affiche les commandes sans rien exécuter.
- `--force` — écrase le disque de travail qcow2 existant.
- `--gpu` — accélération 3D par le GPU de l'hôte : `auto` (défaut, activée si
l'hôte a un nœud de rendu), `on` (forcer), `off` (rendu logiciel).
- `--gpu-node` — quel nœud de rendu utiliser, sur un hôte à plusieurs cartes.
- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou
`en`. Le menu TODO passe la sienne.
- `--erplibre-dir` — où ERPLibre sera installé dans la VM
@ -364,6 +367,39 @@ Host myvm
accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur
à part entière exposé sur le LAN.
## Accélération 3D (GPU de l'hôte)
Une VM graphique sans accélération rend tout par le processeur — le bureau
comme l'émulateur Android qui tourne dedans. Le déploiement prend donc le GPU
de l'hôte **par défaut** (`--gpu auto`) : si l'hôte expose un nœud de rendu,
la VM reçoit un virtio-GPU avec `accel3d` et un affichage `egl-headless` qui
porte le contexte OpenGL **à côté** de la console VNC — il n'ouvre aucun port
et ne remplace rien. Pas de nœud de rendu, pas de 3D, et le déploiement dit
pourquoi au lieu de retomber en silence.
```bash
ls /dev/dri/renderD* # le GPU utilisable par QEMU — vide : pas de 3D
sudo virsh dumpxml <nom-vm> | grep -A2 -E "accel3d|egl-headless"
```
Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est
éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM ›
Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de
démarrer*, ou prenez `[3] Régler le matériel seulement`. vCPU, RAM, 3D et
démarrage automatique s'y règlent — en formulaire si Textual est présent, en
invites sinon.
Deux choses à savoir :
- Un hôte qui est **lui-même une VM** n'a aucun nœud de rendu, sauf si un GPU
lui a été transmis. Imbriqué sans passthrough, la 3D est hors d'atteinte :
l'émulateur Android tourne alors sur SwiftShader, et aucune option n'y
change rien.
- Quand la VM a la 3D, l'émulateur peut être essayé en `-gpu host` plutôt
qu'en `-gpu swangle`, son défaut : `EL_EMULATOR_GPU=host ./todo.sh`. Ça
reste un essai manuel — un émulateur dont le contexte GL échoue reste pendu
au lieu de retomber, d'où `swangle` par défaut.
## QEMU dans QEMU (imbriqué) & exposer la VM via un pont
Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne

View file

@ -264,6 +264,9 @@ family — and its disk cost is added to the plan before anything is created.
- `--no-install-deps` — never auto-install dependencies.
- `--dry-run` — show the commands without executing anything.
- `--force` — overwrite the existing working qcow2 disk.
- `--gpu` — 3D acceleration by the host GPU: `auto` (default, on when the
host has a render node), `on` (force), `off` (software rendering).
- `--gpu-node` — which render node to use, on a multi-GPU host.
- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The
TODO menu passes its own language.
- `--erplibre-dir` — where ERPLibre will live in the VM
@ -342,6 +345,37 @@ This works over Wi-Fi and needs no VM shutdown — the simplest option for
personal access. Prefer a bridge (below) if the VM must be a full server
exposed on the LAN.
## 3D acceleration (host GPU)
A graphical VM without acceleration renders everything on the CPU — the
desktop, and the Android emulator running inside it. The deployment therefore
takes the host GPU **by default** (`--gpu auto`): when the host exposes a
render node, the VM gets a virtio-GPU with `accel3d` plus an `egl-headless`
display that carries the OpenGL context **beside** the VNC console — it opens
no port and replaces nothing. No render node, no 3D, and the deployment says
why instead of quietly falling back.
```bash
ls /dev/dri/renderD* # the GPU QEMU can use — empty means no 3D
sudo virsh dumpxml <vm-name> | grep -A2 -E "accel3d|egl-headless"
```
An existing VM is adjusted from the TODO menu **while it is shut off**:
libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs ›
[2] Change the state`, then either accept *Adjust hardware before starting*,
or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set
there — in a form when Textual is available, in prompts otherwise.
Two things worth knowing:
- A host that is **itself a VM** has no render node unless a GPU was handed
down to it. Nested without passthrough, 3D is out of reach: the Android
emulator then runs on SwiftShader, and no option changes that.
- Once the VM does have 3D, the emulator can be tried with `-gpu host`
instead of its default `-gpu swangle`: `EL_EMULATOR_GPU=host ./todo.sh`.
It stays a manual test — an emulator whose GL context fails hangs instead
of falling back, so `swangle` remains the default.
## QEMU inside QEMU (nested) & exposing the VM via a bridge
If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only

View file

@ -260,6 +260,103 @@ def host_arch() -> str:
}.get(machine, "amd64")
# Nœud de rendu DRM : le fichier que le processus QEMU ouvre pour créer un
# contexte OpenGL (virgl) et donner la 3D à la VM. Un hôte sans GPU — ou
# lui-même virtualisé sans GPU transmis — n'expose AUCUN « renderD* », et
# aucune option de ligne de commande ne peut y suppléer : la VM retombe alors
# sur le rendu logiciel. On teste donc la présence du nœud, pas nos droits
# dessus : l'accès est accordé par libvirt au démarrage du domaine (cgroup +
# étiquette), et un test de lecture sous notre propre compte rejetterait à
# tort un hôte où seul le groupe « render » entre.
HOST_DRI_DIR = Path("/dev/dri")
def host_render_nodes(directory=HOST_DRI_DIR) -> list[str]:
"""Nœuds de rendu de l'hôte, triés (ex. ['/dev/dri/renderD128'])."""
directory = Path(directory)
try:
names = sorted(p.name for p in directory.iterdir())
except OSError:
return []
return [str(directory / n) for n in names if n.startswith("renderD")]
def host_gpu_node(directory=HOST_DRI_DIR) -> str:
"""Nœud de rendu à confier à QEMU, ou '' si l'hôte n'a pas de GPU.
Le premier de la liste : sur une machine à plusieurs cartes, renderD128
est le nœud du GPU primaire. --gpu-node force un autre choix.
"""
nodes = host_render_nodes(directory)
return nodes[0] if nodes else ""
def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]:
"""(3D activée, message à dire) pour un mode --gpu et un nœud donnés.
Séparée du reste pour être vérifiable sans hôte : c'est ici que se décide
« par défaut avec GPU s'il existe », et le silence n'est pas une option —
une VM en rendu logiciel doit dire pourquoi.
"""
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.",
)
return False, ""
if not node:
if mode == "on":
return (
False,
" ⚠ GPU demandé mais l'hôte n'a aucun nœud de rendu"
" (/dev/dri/renderD*) : la VM démarrerait sans écran."
" Rendu logiciel.",
)
return (
False,
" GPU : aucun sur l'hôte, rendu logiciel (virgl absent).",
)
return True, f" GPU : 3D activée par {node} (virtio-gpu + egl-headless)."
def gpu_apply(
video: list, mode: str, node: str, screen: bool
) -> tuple[list, list, str]:
"""(video, arguments 3D, message) — pour virt-install.
Renvoie le `--video` à garder : celui de la 3D REMPLACE le simple
« --video virtio », il ne s'y ajoute pas. Deux --video donneraient deux
écrans à la VM, et l'invité n'afficherait le bureau que sur un seul.
"""
use_gpu, message = gpu_decision(mode, node, screen)
if not use_gpu:
return video, [], message
return [], gpu_install_args(node), message
def gpu_install_args(node: str) -> list[str]:
"""Arguments virt-install qui donnent la 3D à la VM.
Deux pièces indissociables : l'accélération sur le virtio-gpu, et un
affichage capable de contexte GL. « egl-headless » joue ce second rôle
SANS remplacer la console VNC — il n'ouvre aucun port, il n'existe que
pour porter le contexte OpenGL. C'est la recette documentée pour associer
3D et VNC, là où <gl enable/> ne vaut que pour SPICE.
"""
return [
"--video",
"model.type=virtio,model.acceleration.accel3d=on",
"--graphics",
f"type=egl-headless,gl.rendernode={node}",
]
ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest"
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
@ -2798,6 +2895,16 @@ def virt_install(
# d'avant : --graphics spice,listen=none
graphics = "vnc,listen=127.0.0.1"
video = ["--video", "virtio"]
# 3D : allumée d'office quand l'hôte a un GPU (--gpu auto). Une VM
# graphique sans accélération rend tout par le processeur — le bureau
# comme l'émulateur Android qui tourne dedans — et c'est le défaut le plus
# coûteux qu'on puisse laisser en place sans le dire.
gpu_node = args.gpu_node or host_gpu_node()
video, gpu_args, gpu_msg = gpu_apply(
video, args.gpu, gpu_node, graphics != "none"
)
if gpu_msg:
print(gpu_msg)
cmd = [
"virt-install",
# Sans --connect, un utilisateur non root vise qemu:///session : le
@ -2871,7 +2978,7 @@ def virt_install(
"--channel",
"unix,target.type=virtio,target.name=org.qemu.guest_agent.0",
]
cmd += video
cmd += video + gpu_args
if args.arch == "s390x":
# s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le
# disque (ni BIOS ni UEFI/OVMF -> aucun --boot).
@ -3148,6 +3255,19 @@ def build_parser() -> argparse.ArgumentParser:
"permet. Les paquets GNOME sont posés par la commande d'installation, "
"pas ici.",
)
g_vm.add_argument(
"--gpu",
choices=("auto", "on", "off"),
default="auto",
help="Accélération 3D par le GPU de l'hôte : auto (défaut, activée "
"si un /dev/dri/renderD* existe), on (forcer), off (rendu logiciel).",
)
g_vm.add_argument(
"--gpu-node",
default="",
help="Nœud de rendu à utiliser (défaut : le premier trouvé). Utile "
"sur un hôte à plusieurs cartes.",
)
g_vm.add_argument(
"--osinfo", help="Force la valeur --osinfo (sinon déduite)."
)

View file

@ -0,0 +1,460 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ressources matérielles d'une VM libvirt : lecture, et plan de modification.
Le menu d'état de `todo.py` démarre des VM. C'est le seul moment où leur
matériel est modifiable : libvirt refuse de changer vCPU, RAM ou écran d'un
domaine allumé, et l'accélération 3D n'est lue qu'au démarrage de QEMU. Ce
module tient la logique pure de ce moment-là.
- hw_state(xml) : ce que la VM a aujourd'hui, lu dans son XML.
- hw_plan(...) : les commandes qui l'amènent à l'état voulu, et RIEN de plus
— un plan vide quand rien ne change, et une ligne « skip »
expliquée quand une demande n'a pas d'objet.
- build_want(...) / run_hardware_form(...) : le formulaire Textual.
Aucune commande n'est lancée ici : `todo.py` les exécute, sous sudo, et les
affiche avant. Le plan reste donc vérifiable sans hyperviseur.
Deux pièges, appris sur l'hôte :
- « virt-xml --memory N » ne change que <currentMemory>, la cible du ballon.
Élever la RAM au-delà du maximum exige les deux champs à la fois, sinon la
VM plafonne en silence à son ancien maximum.
- « --add-device --graphics type=egl-headless » n'est PAS idempotent : appelé
deux fois, il pose deux affichages. D'où la lecture de l'état AVANT le plan.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from script.todo.qemu_deploy_form import parse_ram, positive_int
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# URI système : sous sudo, root y va de lui-même, mais l'expliciter écarte le
# piège documenté dans deploy_qemu.py — un appel non root visant
# qemu:///session, où les domaines du parc n'existent pas.
CONNECT = "qemu:///system"
# Le virtio-gpu est le SEUL modèle qui porte virgl. Poser accel3d sur un qxl
# ou un cirrus est accepté par le schéma et ne fait rien.
GPU_VIDEO_MODEL = "virtio"
# Affichages qui donnent un écran à la VM. « egl-headless » n'en est pas un :
# il n'ouvre aucun port et n'existe que pour porter le contexte OpenGL.
SCREEN_TYPES = ("vnc", "spice", "sdl", "desktop")
EGL = "egl-headless"
# libvirt écrit KiB pour la mémoire, mais le schéma autorise les deux systèmes
# d'unités — KB vaut mille octets, KiB en vaut 1024. On convertit en octets
# d'abord : une table « à peu près » ferait dériver l'affichage de la VM.
_UNIT_BYTES = {
"b": 1,
"bytes": 1,
"kb": 1000,
"k": 1024,
"kib": 1024,
"mb": 1000**2,
"m": 1024**2,
"mib": 1024**2,
"gb": 1000**3,
"g": 1024**3,
"gib": 1024**3,
"tb": 1000**4,
"t": 1024**4,
"tib": 1024**4,
}
def _mib(node) -> int:
"""Valeur d'un élément mémoire libvirt, en mébioctets."""
if node is None:
return 0
factor = _UNIT_BYTES.get((node.get("unit") or "KiB").lower(), 1024)
try:
return int(float((node.text or "0").strip()) * factor) // (1024 * 1024)
except ValueError:
return 0
def ram_field(mib) -> str:
"""Valeur du champ RAM : « 32G » plutôt que « 32768 ».
parse_ram relit les deux, mais cinq chiffres ne tiennent pas dans le
champ : la VM de 32 Go y affichait « 3276 », et un nombre tronqué qu'on
valide sans regarder rétrécit la machine.
"""
try:
mib = int(mib or 0)
except (TypeError, ValueError):
return ""
if mib <= 0:
return ""
return f"{mib // 1024}G" if mib % 1024 == 0 else str(mib)
def fmt_mib(mib) -> str:
"""1024 -> « 1 Go », 3072 -> « 3 Go », 1536 -> « 1,5 Go », 512 -> « 512 Mo »."""
try:
mib = int(mib)
except (TypeError, ValueError):
return "?"
if mib <= 0:
return "?"
if mib % 1024 == 0:
return f"{mib // 1024} Go"
if mib > 1024:
return f"{mib / 1024:.1f}".replace(".", ",") + " Go"
return f"{mib} Mo"
def hw_state(xml: str, autostart=None) -> dict:
"""État matériel lu dans le XML du domaine.
`autostart` n'est pas dans le XML — il vit dans un lien symbolique côté
libvirt — donc l'appelant le passe (via « virsh dominfo »).
"""
state = {
"name": "",
"vcpus": 0,
"mem_mib": 0,
"max_mem_mib": 0,
"video": "",
"accel3d": False,
"egl": False,
"render": "",
"screen": False,
"autostart": bool(autostart),
}
try:
root = ET.fromstring(xml or "")
except ET.ParseError:
return state
state["name"] = (root.findtext("name") or "").strip()
try:
state["vcpus"] = int((root.findtext("vcpu") or "0").strip())
except ValueError:
state["vcpus"] = 0
state["max_mem_mib"] = _mib(root.find("memory"))
state["mem_mib"] = _mib(root.find("currentMemory")) or state["max_mem_mib"]
model = root.find("./devices/video/model")
if model is not None:
state["video"] = model.get("type") or ""
accel = model.find("acceleration")
state["accel3d"] = accel is not None and accel.get("accel3d") == "yes"
for graphics in root.findall("./devices/graphics"):
kind = graphics.get("type") or ""
if kind in SCREEN_TYPES:
state["screen"] = True
elif kind == EGL:
state["egl"] = True
gl = graphics.find("gl")
state["render"] = (
(gl.get("rendernode") or "") if gl is not None else ""
)
return state
def _virt_xml(name: str, *args) -> list:
"""Commande virt-xml qui ÉCRIT la définition persistante du domaine.
« --define » explicite : sans lui, virt-xml interroge l'utilisateur quand
le domaine tourne, et une invite dans un menu piloté par un script bloque
sans rien dire.
"""
return ["virt-xml", "--connect", CONNECT, name, "--define", *args]
def hw_plan(state: dict, want: dict, node: str = "") -> list:
"""Commandes menant de `state` à `want`, dans l'ordre.
Chaque entrée est un dict : {'what': …, 'cmd': [...]} pour ce qui sera
lancé, {'what': …, 'skip': raison} pour ce qui est demandé mais sans
objet. Rien à changer -> liste vide, et l'appelant n'exécute rien.
"""
name = state.get("name") or want.get("name") or ""
plan = []
if not name:
return plan
vcpus = positive_int(want.get("vcpus"), 0)
if vcpus and vcpus != state.get("vcpus"):
plan.append(
{
"what": f"vCPU {state.get('vcpus')} → {vcpus}",
"cmd": _virt_xml(name, "--edit", "--vcpus", str(vcpus)),
}
)
ram = positive_int(want.get("ram"), 0)
if ram and ram != state.get("mem_mib"):
plan.append(
{
"what": f"RAM {fmt_mib(state.get('mem_mib'))} → {fmt_mib(ram)}",
"cmd": _virt_xml(
name,
"--edit",
"--memory",
f"memory={ram},currentMemory={ram}",
),
}
)
gpu = want.get("gpu")
if gpu is not None:
plan += _gpu_plan(name, state, bool(gpu), node)
auto = want.get("autostart")
if auto is not None and bool(auto) != bool(state.get("autostart")):
args = ["virsh", "--connect", CONNECT, "autostart"]
if not auto:
args.append("--disable")
plan.append(
{
"what": t("Autostart") + (" : on" if auto else " : off"),
"cmd": args + [name],
}
)
return plan
def _gpu_plan(name: str, state: dict, gpu: bool, node: str) -> list:
"""Volet 3D du plan : accélération sur l'écran, et contexte GL."""
plan = []
if gpu:
if not state.get("screen"):
return [
{
"what": t("3D acceleration (host GPU)"),
"skip": t("this VM has no virtual screen"),
}
]
if not node:
return [
{
"what": t("3D acceleration (host GPU)"),
"skip": t("no render node on the host"),
}
]
if not state.get("accel3d") or state.get("video") != GPU_VIDEO_MODEL:
plan.append(
{
"what": t("3D acceleration (host GPU)") + " : on",
"cmd": _virt_xml(
name,
"--edit",
"--video",
f"model.type={GPU_VIDEO_MODEL}"
",model.acceleration.accel3d=on",
),
}
)
if state.get("render") != node:
if state.get("egl"):
# Déjà un affichage GL, mais sur un autre nœud : le corriger
# en place. L'ajouter une seconde fois en poserait DEUX.
plan.append(
{
"what": f"{t('Render node')} → {node}",
"cmd": _virt_xml(
name,
"--edit",
f"type={EGL}",
"--graphics",
f"gl.rendernode={node}",
),
}
)
else:
plan.append(
{
"what": f"{t('Render node')} : {node}",
"cmd": _virt_xml(
name,
"--add-device",
"--graphics",
f"type={EGL},gl.rendernode={node}",
),
}
)
return plan
if state.get("accel3d"):
plan.append(
{
"what": t("3D acceleration (host GPU)") + " : off",
"cmd": _virt_xml(
name,
"--edit",
"--video",
f"model.type={state.get('video') or GPU_VIDEO_MODEL}"
",model.acceleration.accel3d=off",
),
}
)
if state.get("egl"):
# Ciblé par type : la console VNC de la VM, elle, doit survivre.
plan.append(
{
"what": t("Render node") + " : —",
"cmd": _virt_xml(
name, "--remove-device", "--graphics", f"type={EGL}"
),
}
)
return plan
def build_want(state: dict, vcpus, ram, gpu, autostart) -> dict:
"""Valeurs de widgets -> intention, en retombant sur l'état actuel.
Un champ vidé ou illisible ne veut pas dire « zéro vCPU » : il veut dire
« n'y touche pas ». Sans ce repli, valider le formulaire sans rien saisir
proposerait de rétrécir la VM à néant.
"""
return {
"name": state.get("name") or "",
"vcpus": positive_int(vcpus, state.get("vcpus") or 0),
"ram": parse_ram(ram) or state.get("mem_mib") or 0,
"gpu": bool(gpu),
"autostart": bool(autostart),
}
def gpu_allowed(state: dict, node: str) -> str:
"""'' si la 3D est proposable pour cette VM, sinon la raison du refus."""
if not node:
return t("no render node on the host")
if not state.get("screen"):
return t("this VM has no virtual screen")
return ""
def hw_summary(state: dict) -> str:
"""Ligne d'état lisible : « 8 vCPU, 32 Go, 3D on (renderD128) »."""
bits = [f"{state.get('vcpus') or '?'} vCPU", fmt_mib(state.get("mem_mib"))]
if state.get("accel3d") or state.get("render"):
node = state.get("render") or "?"
bits.append(f"3D {node.rsplit('/', 1)[-1]}")
elif state.get("screen"):
bits.append(t("software rendering"))
return ", ".join(bits)
def run_hardware_form(rows, node: str = "", run_app: bool = True):
"""Formulaire d'ajustement matériel. Renvoie {nom: intention} ou None.
`rows` est une liste d'états (hw_state). `run_app=False` renvoie
l'instance sans la lancer — c'est ainsi que les tests l'inspectent.
"""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Button, Checkbox, Footer, Header, Input, Static
states = [dict(r) for r in rows if r]
class HardwareForm(App):
TITLE = t("VM hardware")
# L'intention se lit SUR l'instance, pas dans une fermeture : c'est
# ainsi que les tests montent le formulaire et vérifient ce qu'il
# rend, sans passer par un terminal.
want = None
CSS = """
#rows { height: 1fr; }
#host { padding: 0 1; }
.vm { padding: 1 1 0 1; text-style: bold; }
.row { height: auto; padding: 0 1; }
.lbl { width: 7; height: 3; content-align: right middle; }
.num { width: 10; }
/* « auto » plutôt qu'une largeur fixe : le libellé traduit change de
longueur, et une case tronquée ne dit plus ce qu'elle coche. */
.cb3d { width: auto; margin: 0 2 0 1; }
.cbauto { width: auto; }
.warn { padding: 0 3; }
#bar { height: auto; padding: 1; }
"""
BINDINGS = [
("ctrl+s", "apply", t("Apply")),
("escape", "quit", t("Cancel")),
]
def compose(self) -> ComposeResult:
yield Header()
head = t("Host GPU:") + " "
head += node if node else t("none (software rendering)")
yield Static(head, id="host")
with VerticalScroll(id="rows"):
for i, st in enumerate(states):
reason = gpu_allowed(st, node)
# Le nom sur SA ligne, avec l'état actuel : les noms du
# parc font trente caractères, en colonne ils seraient
# tronqués — et c'est le nom qui dit quelle VM on règle.
yield Static(
f"{st.get('name', '')} — {hw_summary(st)}",
classes="vm",
)
with Horizontal(classes="row"):
yield Static("vCPU", classes="lbl")
yield Input(
value=str(st.get("vcpus") or ""),
id=f"vcpus{i}",
classes="num",
)
yield Static("RAM", classes="lbl")
yield Input(
value=ram_field(st.get("mem_mib")),
id=f"ram{i}",
classes="num",
placeholder="Mo ou G",
)
yield Checkbox(
t("3D"),
value=bool(st.get("accel3d")),
id=f"gpu{i}",
disabled=bool(reason),
classes="cb3d",
)
yield Checkbox(
t("Autostart"),
value=bool(st.get("autostart")),
id=f"auto{i}",
classes="cbauto",
)
if reason:
yield Static(f"⚠ {reason}", classes="warn")
with Horizontal(id="bar"):
yield Button(t("Apply"), variant="primary", id="apply")
yield Button(t("Cancel"), id="cancel")
yield Footer()
def action_apply(self) -> None:
want = {}
for i, st in enumerate(states):
want[st.get("name", "")] = build_want(
st,
self.query_one(f"#vcpus{i}", Input).value,
self.query_one(f"#ram{i}", Input).value,
self.query_one(f"#gpu{i}", Checkbox).value,
self.query_one(f"#auto{i}", Checkbox).value,
)
self.want = want
self.exit()
def on_button_pressed(self, event) -> None:
if event.button.id == "apply":
self.action_apply()
else:
self.exit()
app = HardwareForm()
if not run_app:
return app
app.run()
return app.want

View file

@ -1675,8 +1675,15 @@ class TODO:
# menu propose lui-même — laisse un instantané en cours, et le lancement
# SUIVANT meurt sur « A snapshot operation is pending and timeout has
# expired ». Vécu, et le message ne dit pas quoi faire.
# « -gpu » reste sur swangle par DÉFAUT, même quand la VM a la 3D : un
# « -gpu host » qui échoue ne rend pas la main, l'émulateur reste pendu, et
# ce n'est pas un défaut à imposer sans l'avoir mesuré sur la machine.
# EL_EMULATOR_GPU permet de l'essayer sans toucher au code, une fois le
# nœud de rendu présent dans l'invité (voir script/qemu/README).
_QEMU_EMULATOR_GPU = os.environ.get("EL_EMULATOR_GPU") or "swangle"
_QEMU_EMULATOR_FLAGS = (
"-no-audio -no-boot-anim -no-snapshot-save -gpu swangle"
"-no-audio -no-boot-anim -no-snapshot-save"
f" -gpu {_QEMU_EMULATOR_GPU}"
" -skin 540x1140 -prop qemu.sf.lcd_density=240"
)
_QEMU_AVD_NAME = "erplibre"
@ -2881,14 +2888,25 @@ class TODO:
print(f"\n{t('Target state:')}")
print(f" [1] {t('Open (start)')}")
print(f" [2] {t('Close (shut down)')}")
print(f" [3] {t('Adjust hardware only (vCPU, RAM, 3D)')}")
st = input(t("Choice: ")).strip()
if st == "1":
action, verb = "start", t("start")
elif st == "2":
action, verb = "shutdown", t("shut down")
elif st == "3":
self._qemu_adjust_hardware(resolved)
return
else:
print(t("Cancelled."))
return
# Le matériel d'une VM ne se règle QUE pendant qu'elle est éteinte :
# démarrer est donc le dernier moment pour le faire, et le seul où la
# question tombe juste.
if action == "start" and self._is_yes(
input(f"\n{t('Adjust hardware before starting? (y/N): ')}")
):
self._qemu_adjust_hardware(resolved)
# DOUBLE validation avant d'appliquer.
summary = f"{verb} -> {', '.join(resolved)}"
if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")):
@ -2902,6 +2920,163 @@ class TODO:
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
@staticmethod
def _qemu_dumpxml(name):
"""XML du domaine, ou '' — source de son état matériel."""
try:
res = subprocess.run(
["sudo", "virsh", "dumpxml", name],
capture_output=True,
text=True,
timeout=20,
env=TODO._qemu_c_env(),
)
except (OSError, subprocess.SubprocessError):
return ""
return res.stdout if res.returncode == 0 else ""
@staticmethod
def _qemu_autostart(name):
"""Démarrage automatique activé ? (absent du XML : virsh seul le sait)"""
try:
res = subprocess.run(
["sudo", "virsh", "dominfo", name],
capture_output=True,
text=True,
timeout=15,
env=TODO._qemu_c_env(),
)
except (OSError, subprocess.SubprocessError):
return False
for line in res.stdout.splitlines():
if line.startswith("Autostart:"):
return line.split(":", 1)[1].strip() == "enable"
return False
def _qemu_ask_bool(self, prompt, default):
"""Question fermée dont le DÉFAUT est l'état actuel de la VM.
Une réponse vide — ou incompréhensible — laisse la VM telle quelle :
sur un formulaire de matériel, le silence ne doit rien modifier.
"""
ans = input(prompt).strip()
if self._is_yes(ans):
return True
if self._is_no(ans):
return False
return default
def _qemu_host_gpu_node(self):
"""Nœud de rendu de l'hôte, vu par deploy_qemu (source unique), ou ''."""
try:
return self._qemu_import_module().host_gpu_node()
except (OSError, AttributeError, ImportError):
return ""
def _qemu_adjust_hardware(self, names):
"""Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES.
Les VM allumées sont écartées, en le disant : virt-xml y écrirait une
définition qui ne prendrait effet qu'au prochain démarrage — un
réglage qui paraît appliqué et ne l'est pas.
"""
from script.todo import qemu_hardware as hw
off, busy = [], []
for name in names:
state = self._qemu_domstate(name)
(off if state == "shut off" else busy).append(name)
if busy:
print(
f"\n ⚠ {t('Not shut off, hardware left untouched:')}"
f" {', '.join(busy)}"
)
if not off:
return
node = self._qemu_host_gpu_node()
gpu_txt = node or t("none (software rendering)")
print(f"\n{t('Host GPU:')} {gpu_txt}")
rows = [
r
for r in (
hw.hw_state(self._qemu_dumpxml(n), self._qemu_autostart(n))
for n in off
)
if r.get("name")
]
if not rows:
print(f" ⚠ {t('Unreadable VM definition.')}")
return
for r in rows:
print(f" {r['name']:<30} {hw.hw_summary(r)}")
want = self._qemu_hw_form(rows, node)
if want is None:
print(t("Cancelled."))
return
if not want:
want = self._qemu_hw_prompts(rows, node)
if not want:
print(t("Cancelled."))
return
plan = []
for r in rows:
plan += hw.hw_plan(r, want.get(r["name"]) or {}, node)
for entry in plan:
if entry.get("skip"):
print(f" ⚠ {entry['what']} : {entry['skip']}")
cmds = [e for e in plan if e.get("cmd")]
if not cmds:
print(f"\n{t('Nothing to change.')}")
return
print(f"\n{t('Changes:')}")
for entry in cmds:
print(f" - {entry['what']}")
if not self._is_yes(input(t("Apply these changes? (y/N): "))):
print(t("Cancelled."))
return
for entry in cmds:
cmd = "sudo " + " ".join(shlex.quote(c) for c in entry["cmd"])
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _qemu_hw_form(self, rows, node):
"""Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour
retomber sur les invites en ligne (textual absent), None si annulé."""
from script.todo import textual_setup
if not textual_setup.ensure():
return {}
try:
from script.todo.qemu_hardware import run_hardware_form
return run_hardware_form(rows, node)
except ImportError:
return {}
def _qemu_hw_prompts(self, rows, node):
"""Même ajustement, en invites, quand Textual n'est pas disponible."""
from script.todo import qemu_hardware as hw
want = {}
for r in rows:
print(f"\n {r['name']} — {hw.hw_summary(r)}")
vcpus = input(f" vCPU [{r.get('vcpus')}] : ")
ram = input(f" RAM [{hw.fmt_mib(r.get('mem_mib'))}] : ")
reason = hw.gpu_allowed(r, node)
if reason:
print(f" ⚠ {t('3D acceleration (host GPU)')} : {reason}")
gpu = False
else:
gpu = self._qemu_ask_bool(
f" {t('3D acceleration (host GPU)')} ? (o/N) : ",
bool(r.get("accel3d")),
)
auto = self._qemu_ask_bool(
f" {t('Autostart')} ? (o/N) : ", bool(r.get("autostart"))
)
want[r["name"]] = hw.build_want(r, vcpus, ram, gpu, auto)
return want
@staticmethod
def _qemu_dominfo(name):
"""(vcpus, max_mem_kib) via « virsh dominfo », ou (0, 0)."""

View file

@ -3043,6 +3043,74 @@ TRANSLATIONS = {
"fr": "VM à modifier (séparées par des virgules) : ",
"en": "VMs to change (comma-separated): ",
},
"Adjust hardware only (vCPU, RAM, 3D)": {
"fr": "Régler le matériel seulement (vCPU, RAM, 3D)",
"en": "Adjust hardware only (vCPU, RAM, 3D)",
},
"Adjust hardware before starting? (y/N): ": {
"fr": "Régler le matériel avant de démarrer ? (o/N) : ",
"en": "Adjust hardware before starting? (y/N): ",
},
"Not shut off, hardware left untouched:": {
"fr": "Pas éteintes, matériel inchangé :",
"en": "Not shut off, hardware left untouched:",
},
"Host GPU:": {
"fr": "GPU de l'hôte :",
"en": "Host GPU:",
},
"none (software rendering)": {
"fr": "aucun (rendu logiciel)",
"en": "none (software rendering)",
},
"software rendering": {
"fr": "rendu logiciel",
"en": "software rendering",
},
"Unreadable VM definition.": {
"fr": "Définition de VM illisible.",
"en": "Unreadable VM definition.",
},
"Nothing to change.": {
"fr": "Rien à changer.",
"en": "Nothing to change.",
},
"Changes:": {
"fr": "Changements :",
"en": "Changes:",
},
"Apply these changes? (y/N): ": {
"fr": "Appliquer ces changements ? (o/N) : ",
"en": "Apply these changes? (y/N): ",
},
"Autostart": {
"fr": "Démarrage automatique",
"en": "Autostart",
},
"VM hardware": {
"fr": "Matériel des VM",
"en": "VM hardware",
},
"3D": {
"fr": "3D",
"en": "3D",
},
"3D acceleration (host GPU)": {
"fr": "Accélération 3D (GPU de l'hôte)",
"en": "3D acceleration (host GPU)",
},
"Render node": {
"fr": "Nœud de rendu",
"en": "Render node",
},
"no render node on the host": {
"fr": "aucun nœud de rendu sur l'hôte",
"en": "no render node on the host",
},
"this VM has no virtual screen": {
"fr": "cette VM n'a pas d'écran virtuel",
"en": "this VM has no virtual screen",
},
"Unknown VM(s):": {
"fr": "VM inconnue(s) :",
"en": "Unknown VM(s):",

View file

@ -15,6 +15,7 @@ IP_VM:5555). Une redirection vers l'IP de la VM ne peut donc PAS aboutir, et
seul un dernier saut dans la VM place « localhost » au bon endroit.
"""
import os
import socket
import subprocess
import sys
@ -460,5 +461,44 @@ class TestPortInUse(unittest.TestCase):
self.assertFalse(TODO._port_in_use(port))
class TestEmulatorGpuOverride(unittest.TestCase):
"""« -gpu host » doit être essayable sans toucher au code.
Les drapeaux sont figés à l'import de todo.py : la variable doit donc être
lue AVANT, ce qu'un sous-processus est seul à démontrer. Le défaut reste
swangle — mesuré — car un contexte GL qui échoue laisse l'émulateur pendu
au lieu de retomber.
"""
def _flags(self, env=None):
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
environ = dict(os.environ, PYTHONPATH=root)
environ.update(env or {})
res = subprocess.run(
[
sys.executable,
"-c",
"import sys; sys.argv=['todo.py']\n"
"from script.todo.todo import TODO\n"
"print(TODO._QEMU_EMULATOR_FLAGS)",
],
capture_output=True,
text=True,
cwd=root,
env=environ,
timeout=120,
)
self.assertEqual(0, res.returncode, res.stderr[-400:])
return res.stdout
def test_the_default_is_the_measured_one(self):
self.assertIn("-gpu swangle", self._flags())
def test_the_variable_replaces_it(self):
out = self._flags({"EL_EMULATOR_GPU": "host"})
self.assertIn("-gpu host", out)
self.assertNotIn("swangle", out)
if __name__ == "__main__":
unittest.main()

561
test/test_qemu_gpu.py Normal file
View file

@ -0,0 +1,561 @@
#!/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>",
)
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
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"})
todo._qemu_hw_form = lambda rows, node: {}
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"})
todo._qemu_hw_form = lambda rows, node: {
"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"})
todo._qemu_hw_form = lambda rows, node: {
"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"})
todo._qemu_hw_form = lambda rows, node: {"vm-a": {"vcpus": 4}}
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"})
todo._qemu_hw_form = lambda rows, node: None
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="")
todo._qemu_hw_form = lambda rows, node: {"vm-a": {"gpu": True}}
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"})
todo._qemu_hw_form = lambda rows, node: None
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"})
todo._qemu_hw_form = lambda rows, node: {}
self._run(todo, ["vm-a"], ["6", "", "o", "n", "o"])
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)
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
rows = [hw.hw_state(XML_SANS_3D), hw.hw_state(XML_SERVEUR)]
app = await self._mount(rows, NODE)
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)
if __name__ == "__main__":
unittest.main(verbosity=1)