[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:
parent
a98d6ec9cd
commit
a55b03e199
9 changed files with 1566 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
)
|
||||
|
|
|
|||
460
script/todo/qemu_hardware.py
Normal file
460
script/todo/qemu_hardware.py
Normal 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
|
||||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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):",
|
||||
|
|
|
|||
|
|
@ -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
561
test/test_qemu_gpu.py
Normal 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)
|
||||
Loading…
Reference in a new issue