diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index 36db9ce..f216e83 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -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 | 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 | 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 diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 8abb57b..d5e35ca 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -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 | 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 diff --git a/script/qemu/README.md b/script/qemu/README.md index 0076fd9..a6775ed 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -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 | 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 diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 45e23aa..c253bf0 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -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ù 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)." ) diff --git a/script/todo/qemu_hardware.py b/script/todo/qemu_hardware.py new file mode 100644 index 0000000..dc480ec --- /dev/null +++ b/script/todo/qemu_hardware.py @@ -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 , 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 diff --git a/script/todo/todo.py b/script/todo/todo.py index 55aeea6..c0b8b52 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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).""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0806bb7..39db9ca 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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):", diff --git a/test/test_qemu_emulator_menu.py b/test/test_qemu_emulator_menu.py index 8386dc0..387446f 100644 --- a/test/test_qemu_emulator_menu.py +++ b/test/test_qemu_emulator_menu.py @@ -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() diff --git a/test/test_qemu_gpu.py b/test/test_qemu_gpu.py new file mode 100644 index 0000000..94e173a --- /dev/null +++ b/test/test_qemu_gpu.py @@ -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 : 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 = """ + erplibre-ubuntu-2604-gnome + 33554432 + 33554432 + 8 + + + + + + + +""" + +XML_AVEC_3D = XML_SANS_3D.replace( + "", + "" + "", +).replace( + "", + "\n " + "", +) + +XML_SERVEUR = """ + erplibre-serveur + 2097152 + 2 + + +""" + +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( + "", + "", + ) + self.assertFalse(hw.hw_state(xml)["screen"]) + + def test_the_balloon_target_is_what_the_vm_gets(self): + """ est le maximum, ce que la VM voit. Lire le + premier annoncerait 32 Go à une VM qui en a 4.""" + xml = XML_SANS_3D.replace( + "33554432", + "4194304", + ) + 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( + "2097152", + f"{value}", + ) + self.assertEqual(mib, hw.hw_state(xml)["mem_mib"], unit) + + def test_broken_xml_gives_an_empty_state(self): + st = hw.hw_state(" : 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">([^<>]+)", svg)) + self.assertNotIn("…", texte) + + +if __name__ == "__main__": + unittest.main(verbosity=1)