[ADD] qemu : régler le mode CPU, les écrans et le réseau d'une VM

Le formulaire matériel ne réglait que vCPU, RAM, 3D et démarrage. Trois
manques : le mode CPU, qui décide si on peut virtualiser DANS la VM, les
écrans du virtio-gpu, et le réseau — dont le passage au pont, seul moyen
d'exposer la VM sur le LAN.

La vram n'est pas offerte : domxml-to-native prouve que QEMU ne la reçoit
jamais sur un virtio-gpu, seul max_outputs y arrive.

Vérifié sur un domaine jetable et un pont d'essai : max_outputs=2, -cpu
host, vnet sur le pont, MAC et emplacement PCI conservés. 486 tests.

--- EN ---

The hardware form only set vCPU, RAM, 3D and autostart. Three gaps: the CPU
mode, which decides whether one can virtualize INSIDE the VM, the virtio-GPU
screens, and the network — including the switch to a bridge, the only way to
put the VM on the LAN.

vram is not offered: domxml-to-native proves QEMU never receives it on a
virtio-GPU, only max_outputs gets through.

Verified on a throwaway domain and a test bridge: max_outputs=2, -cpu host,
vnet on the bridge, MAC and PCI slot preserved. 486 tests.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-21 23:13:42 -04:00
parent a55b03e199
commit c54bfb7974
7 changed files with 739 additions and 34 deletions

View file

@ -705,8 +705,22 @@ 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.
or take `[3] Adjust hardware only`. In a form when Textual is available, in
prompts otherwise, it sets:
- **vCPU, RAM, autostart** — the plain sizing knobs.
- **CPU mode** — `host-passthrough` (what the fleet uses) hands the host CPU
instructions over as they are: that is what makes nested virtualization
possible *inside* the VM. `host-model` describes an equivalent model,
migratable to another machine.
- **Screens** — the virtio-GPU `heads`, which becomes `max_outputs` on the
QEMU command line. `vram` is deliberately *not* offered: on a virtio-GPU
libvirt writes it into the XML and QEMU never receives it (check with
`virsh domxml-to-native` — only `max_outputs` shows up). Only qxl uses vram.
- **Network** — the libvirt networks and the host bridges, the latter to put
the VM on the LAN (see the bridge section below). Switching keeps the MAC
address and the PCI slot, so the guest finds *its* card again — same
interface name, same DHCP lease.
Two things worth knowing:
@ -748,9 +762,23 @@ 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.
démarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si
Textual est présent, en invites sinon, il règle :
- **vCPU, RAM, démarrage automatique** — le dimensionnement ordinaire.
- **Mode CPU** — `host-passthrough` (celui du parc) donne les instructions du
processeur hôte telles quelles : c'est lui qui rend la virtualisation
imbriquée possible *dans* la VM. `host-model` décrit un modèle équivalent,
migrable vers une autre machine.
- **Écrans** — le `heads` du virtio-gpu, qui devient `max_outputs` sur la
ligne QEMU. La `vram` n'est délibérément *pas* proposée : sur un virtio-gpu,
libvirt l'écrit dans le XML et QEMU ne la reçoit jamais (à vérifier avec
`virsh domxml-to-native` : seul `max_outputs` y apparaît). Seul qxl la
consomme.
- **Réseau** — les réseaux libvirt et les ponts de l'hôte, ces derniers pour
poser la VM sur le LAN (voir la section du pont plus bas). Le basculement
garde l'adresse MAC et l'emplacement PCI : l'invité retrouve *sa* carte,
donc son nom d'interface et son bail DHCP.
Deux choses à savoir :

View file

@ -385,9 +385,23 @@ 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.
démarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si
Textual est présent, en invites sinon, il règle :
- **vCPU, RAM, démarrage automatique** — le dimensionnement ordinaire.
- **Mode CPU** — `host-passthrough` (celui du parc) donne les instructions du
processeur hôte telles quelles : c'est lui qui rend la virtualisation
imbriquée possible *dans* la VM. `host-model` décrit un modèle équivalent,
migrable vers une autre machine.
- **Écrans** — le `heads` du virtio-gpu, qui devient `max_outputs` sur la
ligne QEMU. La `vram` n'est délibérément *pas* proposée : sur un virtio-gpu,
libvirt l'écrit dans le XML et QEMU ne la reçoit jamais (à vérifier avec
`virsh domxml-to-native` : seul `max_outputs` y apparaît). Seul qxl la
consomme.
- **Réseau** — les réseaux libvirt et les ponts de l'hôte, ces derniers pour
poser la VM sur le LAN (voir la section du pont plus bas). Le basculement
garde l'adresse MAC et l'emplacement PCI : l'invité retrouve *sa* carte,
donc son nom d'interface et son bail DHCP.
Deux choses à savoir :

View file

@ -363,8 +363,22 @@ 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.
or take `[3] Adjust hardware only`. In a form when Textual is available, in
prompts otherwise, it sets:
- **vCPU, RAM, autostart** — the plain sizing knobs.
- **CPU mode** — `host-passthrough` (what the fleet uses) hands the host CPU
instructions over as they are: that is what makes nested virtualization
possible *inside* the VM. `host-model` describes an equivalent model,
migratable to another machine.
- **Screens** — the virtio-GPU `heads`, which becomes `max_outputs` on the
QEMU command line. `vram` is deliberately *not* offered: on a virtio-GPU
libvirt writes it into the XML and QEMU never receives it (check with
`virsh domxml-to-native` — only `max_outputs` shows up). Only qxl uses vram.
- **Network** — the libvirt networks and the host bridges, the latter to put
the VM on the LAN (see the bridge section below). Switching keeps the MAC
address and the PCI slot, so the guest finds *its* card again — same
interface name, same DHCP lease.
Two things worth knowing:

View file

@ -48,6 +48,21 @@ CONNECT = "qemu:///system"
# ou un cirrus est accepté par le schéma et ne fait rien.
GPU_VIDEO_MODEL = "virtio"
# Modes CPU proposés. « host-passthrough » donne les instructions du
# processeur hôte telles quelles — c'est ce qui rend la virtualisation
# IMBRIQUÉE possible dans la VM, et ce que virt-install pose par défaut ;
# « host-model » décrit un modèle équivalent, migrable vers une autre machine.
# Les attributs check/migratable accompagnent le passthrough, comme
# virt-install les écrit : sans eux libvirt vérifie un modèle qu'il n'a pas
# calculé.
CPU_MODES = ("host-passthrough", "host-model")
CPU_EXTRA = {"host-passthrough": ",check=none,migratable=on"}
# « vram » n'est PAS proposé : sur un virtio-gpu, libvirt l'écrit dans le XML
# et QEMU ne le reçoit jamais — vérifié par « virsh domxml-to-native », qui
# ne montre que « max_outputs » (les écrans). Ce serait un bouton sans effet.
# Seul qxl consomme vram, et le parc n'utilise pas qxl.
# 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")
@ -132,6 +147,11 @@ def hw_state(xml: str, autostart=None) -> dict:
"egl": False,
"render": "",
"screen": False,
"heads": 1,
"cpu": "",
# PREMIÈRE interface seulement : c'est celle que « virt-xml --edit
# --network » modifie, et une VM du parc n'en a qu'une.
"net": "",
"autostart": bool(autostart),
}
try:
@ -145,9 +165,23 @@ def hw_state(xml: str, autostart=None) -> dict:
state["vcpus"] = 0
state["max_mem_mib"] = _mib(root.find("memory"))
state["mem_mib"] = _mib(root.find("currentMemory")) or state["max_mem_mib"]
cpu = root.find("cpu")
if cpu is not None:
state["cpu"] = cpu.get("mode") or ""
iface = root.find("./devices/interface")
if iface is not None:
source = iface.find("source")
state["net"] = net_token(
iface.get("type") or "",
source if source is not None else None,
)
model = root.find("./devices/video/model")
if model is not None:
state["video"] = model.get("type") or ""
try:
state["heads"] = int(model.get("heads") or 1)
except ValueError:
state["heads"] = 1
accel = model.find("acceleration")
state["accel3d"] = accel is not None and accel.get("accel3d") == "yes"
for graphics in root.findall("./devices/graphics"):
@ -163,6 +197,62 @@ def hw_state(xml: str, autostart=None) -> dict:
return state
def net_token(kind: str, source) -> str:
"""Identité du réseau d'une interface : « network:default », « bridge:br0 ».
Un seul jeton pour comparer, choisir et appliquer : le type et le nom vont
toujours ensemble — « br0 » ne veut rien dire sans savoir que c'est un
pont, et libvirt refuse type='network' avec un pont pour source.
"""
if not kind:
return ""
name = ""
if source is not None:
name = (
source.get("network")
or source.get("bridge")
or source.get("dev")
or ""
)
return f"{kind}:{name}" if name else kind
def net_label(token: str) -> str:
"""« network:default » -> « default », « bridge:br0 » -> « br0 (pont) ».
Le réseau libvirt ne porte pas de suffixe : c'est le cas ordinaire, et le
nommer allongeait le libellé au-delà de la liste déroulante. Seul le pont
est marqué, parce que c'est lui qui change le comportement de la VM.
"""
if not token:
return "—"
kind, _, name = token.partition(":")
if kind == "network":
return name
if kind == "bridge":
return f"{name} ({t('bridge')})"
return token
# Les modes CPU s'affichent en court : la valeur écrite dans le XML reste
# entière, seul le libellé raccourcit.
CPU_LABELS = {"host-passthrough": "passthrough", "host-model": "model"}
def cpu_label(mode: str) -> str:
return CPU_LABELS.get(mode, mode or "—")
def net_spec(token: str) -> str:
"""Jeton -> argument « --network » de virt-xml."""
kind, _, name = token.partition(":")
if kind == "network":
return f"network={name}"
if kind == "bridge":
return f"bridge={name}"
return token
def _virt_xml(name: str, *args) -> list:
"""Commande virt-xml qui ÉCRIT la définition persistante du domaine.
@ -208,6 +298,55 @@ def hw_plan(state: dict, want: dict, node: str = "") -> list:
}
)
cpu = (want.get("cpu") or "").strip()
if cpu and cpu != state.get("cpu"):
plan.append(
{
"what": f"CPU {state.get('cpu') or '—'} → {cpu}",
"cmd": _virt_xml(
name, "--edit", "--cpu", cpu + CPU_EXTRA.get(cpu, "")
),
}
)
heads = positive_int(want.get("heads"), 0)
if heads and heads != state.get("heads"):
if not state.get("video"):
# « --edit --video » n'a aucun périphérique à modifier : virt-xml
# sortirait en erreur au milieu du lot.
plan.append(
{
"what": t("Screens"),
"skip": t("this VM has no virtual screen"),
}
)
else:
plan.append(
{
"what": f"{t('Screens')} {state.get('heads')} → {heads}",
"cmd": _virt_xml(
name, "--edit", "--video", f"model.heads={heads}"
),
}
)
net = (want.get("net") or "").strip()
if net and net != state.get("net"):
if not state.get("net"):
plan.append(
{"what": t("Network"), "skip": t("this VM has no interface")}
)
else:
plan.append(
{
"what": f"{t('Network')} {net_label(state.get('net'))}"
f" → {net_label(net)}",
"cmd": _virt_xml(
name, "--edit", "--network", net_spec(net)
),
}
)
gpu = want.get("gpu")
if gpu is not None:
plan += _gpu_plan(name, state, bool(gpu), node)
@ -312,7 +451,9 @@ def _gpu_plan(name: str, state: dict, gpu: bool, node: str) -> list:
return plan
def build_want(state: dict, vcpus, ram, gpu, autostart) -> dict:
def build_want(
state: dict, vcpus, ram, gpu, autostart, cpu="", heads="", net=""
) -> 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
@ -325,6 +466,9 @@ def build_want(state: dict, vcpus, ram, gpu, autostart) -> dict:
"ram": parse_ram(ram) or state.get("mem_mib") or 0,
"gpu": bool(gpu),
"autostart": bool(autostart),
"cpu": (cpu or state.get("cpu") or "").strip(),
"heads": positive_int(heads, state.get("heads") or 1),
"net": (net or state.get("net") or "").strip(),
}
@ -345,20 +489,67 @@ def hw_summary(state: dict) -> str:
bits.append(f"3D {node.rsplit('/', 1)[-1]}")
elif state.get("screen"):
bits.append(t("software rendering"))
if (state.get("heads") or 1) > 1:
bits.append(f"{state['heads']} {t('Screens').lower()}")
if state.get("net"):
bits.append(net_label(state["net"]))
# Le mode CPU n'est dit que s'il n'est PAS le passthrough : c'est le défaut
# du parc, et une ligne de résumé ne doit porter que l'inattendu.
if state.get("cpu") and state["cpu"] != "host-passthrough":
bits.append(f"CPU {state['cpu']}")
return ", ".join(bits)
def run_hardware_form(rows, node: str = "", run_app: bool = True):
def cpu_choices(states) -> list:
"""Modes CPU à proposer, le mode courant compris s'il sort de la liste.
Une VM en mode « custom » ne doit pas voir son réglage disparaître d'une
liste qui l'ignore : la liste déroulante afficherait alors un autre mode
que le sien, et valider le formulaire le changerait sans le dire.
"""
modes = list(CPU_MODES)
for state in states or ():
mode = (state or {}).get("cpu")
if mode and mode not in modes:
modes.append(mode)
return modes
def net_choices(states, nets=None) -> list:
"""[(jeton, libellé)] des réseaux proposables, courants inclus."""
tokens = []
for token in list(nets or ()):
if token and token not in tokens:
tokens.append(token)
for state in states or ():
token = (state or {}).get("net")
if token and token not in tokens:
tokens.append(token)
return [(tok, net_label(tok)) for tok in tokens]
def run_hardware_form(rows, node: str = "", nets=None, 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.
`rows` est une liste d'états (hw_state), `nets` les réseaux que l'hôte
peut offrir. `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
from textual.widgets import (
Button,
Checkbox,
Footer,
Header,
Input,
Select,
Static,
)
states = [dict(r) for r in rows if r]
cpus = cpu_choices(states)
reseaux = net_choices(states, nets)
class HardwareForm(App):
TITLE = t("VM hardware")
@ -378,6 +569,15 @@ def run_hardware_form(rows, node: str = "", run_app: bool = True):
.cb3d { width: auto; margin: 0 2 0 1; }
.cbauto { width: auto; }
.warn { padding: 0 3; }
.sel { width: 22; }
/* Le réseau porte un suffixe (« br0 (pont) ») : deux colonnes de plus
que le mode CPU, qui s'affiche en un mot. */
.selnet { width: 24; }
.lbl2 { width: 8; height: 3; content-align: right middle; }
/* 8 et pas 5 : sous cette largeur, Textual dessine le cadre du champ
mais PAS son contenu — la valeur devient invisible, ce qui est pire
qu'une troncature (on valide un champ qu'on croit vide). */
.heads { width: 8; }
#bar { height: auto; padding: 1; }
"""
BINDINGS = [
@ -427,6 +627,30 @@ def run_hardware_form(rows, node: str = "", run_app: bool = True):
id=f"auto{i}",
classes="cbauto",
)
with Horizontal(classes="row"):
yield Static("CPU", classes="lbl2")
yield Select(
[(cpu_label(m), m) for m in cpus],
value=st.get("cpu") or cpus[0],
allow_blank=False,
id=f"cpu{i}",
classes="sel",
)
yield Static(t("Screens"), classes="lbl2")
yield Input(
value=str(st.get("heads") or 1),
id=f"heads{i}",
classes="heads",
)
if reseaux:
yield Static(t("Network"), classes="lbl2")
yield Select(
[(lab, tok) for tok, lab in reseaux],
value=st.get("net") or reseaux[0][0],
allow_blank=False,
id=f"net{i}",
classes="selnet",
)
if reason:
yield Static(f"⚠ {reason}", classes="warn")
with Horizontal(id="bar"):
@ -437,12 +661,18 @@ def run_hardware_form(rows, node: str = "", run_app: bool = True):
def action_apply(self) -> None:
want = {}
for i, st in enumerate(states):
net = st.get("net") or ""
if reseaux:
net = self.query_one(f"#net{i}", Select).value or net
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,
cpu=self.query_one(f"#cpu{i}", Select).value or "",
heads=self.query_one(f"#heads{i}", Input).value,
net=net,
)
self.want = want
self.exit()

View file

@ -2922,10 +2922,16 @@ class TODO:
@staticmethod
def _qemu_dumpxml(name):
"""XML du domaine, ou '' — source de son état matériel."""
"""XML PERSISTANT du domaine, ou '' — source de son état matériel.
« --inactive » n'est pas décoratif : sur une VM allumée, « dumpxml »
rend la vue VIVANTE, décorée de ce que libvirt a alloué au démarrage
(portid du réseau, vnetN, alias). C'est la définition persistante que
virt-xml modifie, et c'est donc elle qu'il faut lire.
"""
try:
res = subprocess.run(
["sudo", "virsh", "dumpxml", name],
["sudo", "virsh", "dumpxml", "--inactive", name],
capture_output=True,
text=True,
timeout=20,
@ -2973,6 +2979,52 @@ class TODO:
except (OSError, AttributeError, ImportError):
return ""
def _qemu_net_choices(self):
"""Réseaux proposables : réseaux libvirt, puis ponts de l'hôte.
Les ponts appartenant à un réseau libvirt (virbr0 pour « default »)
sont écartés : les proposer offrirait DEUX fois le même chemin, dont
un qui contourne la gestion du réseau par libvirt.
"""
tokens = []
nets = self._qemu_cmd_lines(
["sudo", "virsh", "net-list", "--all", "--name"]
)
owned = set()
for net in nets:
tokens.append(f"network:{net}")
for line in self._qemu_cmd_lines(
["sudo", "virsh", "net-info", net]
):
if line.startswith("Bridge:"):
owned.add(line.split(":", 1)[1].strip())
for line in self._qemu_cmd_lines(
["ip", "-o", "link", "show", "type", "bridge"]
):
# « 3: br0: <BROADCAST,...» -> br0
parts = line.split(":")
bridge = parts[1].strip() if len(parts) > 1 else ""
if bridge and bridge not in owned:
tokens.append(f"bridge:{bridge}")
return tokens
@staticmethod
def _qemu_cmd_lines(cmd):
"""Lignes non vides d'une commande, ou [] si elle échoue."""
try:
res = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=15,
env=TODO._qemu_c_env(),
)
except (OSError, subprocess.SubprocessError):
return []
if res.returncode != 0:
return []
return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
def _qemu_adjust_hardware(self, names):
"""Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES.
@ -3009,12 +3061,13 @@ class TODO:
return
for r in rows:
print(f" {r['name']:<30} {hw.hw_summary(r)}")
want = self._qemu_hw_form(rows, node)
nets = self._qemu_net_choices()
want = self._qemu_hw_form(rows, node, nets)
if want is None:
print(t("Cancelled."))
return
if not want:
want = self._qemu_hw_prompts(rows, node)
want = self._qemu_hw_prompts(rows, node, nets)
if not want:
print(t("Cancelled."))
return
@ -3039,7 +3092,7 @@ class TODO:
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _qemu_hw_form(self, rows, node):
def _qemu_hw_form(self, rows, node, nets=None):
"""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
@ -3049,14 +3102,34 @@ class TODO:
try:
from script.todo.qemu_hardware import run_hardware_form
return run_hardware_form(rows, node)
return run_hardware_form(rows, node, nets)
except ImportError:
return {}
def _qemu_hw_prompts(self, rows, node):
def _qemu_pick(self, title, values, current, labels=None):
"""Liste numérotée dont le DÉFAUT est la valeur actuelle.
Rendre la valeur actuelle sur une réponse vide, et sur une réponse
illisible : dans un formulaire de matériel, ne rien comprendre ne doit
rien changer.
"""
labels = labels or values
print(f"{title} :")
for i, (val, lab) in enumerate(zip(values, labels), 1):
mark = " ←" if val == current else ""
print(f" [{i}] {lab}{mark}")
ans = input(" " + t("Choice: ")).strip()
if not ans.isdigit():
return current
idx = int(ans)
return values[idx - 1] if 1 <= idx <= len(values) else current
def _qemu_hw_prompts(self, rows, node, nets=None):
"""Même ajustement, en invites, quand Textual n'est pas disponible."""
from script.todo import qemu_hardware as hw
cpus = hw.cpu_choices(rows)
reseaux = hw.net_choices(rows, nets)
want = {}
for r in rows:
print(f"\n {r['name']} — {hw.hw_summary(r)}")
@ -3074,7 +3147,23 @@ class TODO:
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)
cpu = self._qemu_pick(f" {t('CPU mode')}", cpus, r.get("cpu"))
heads = ""
if r.get("video"):
heads = input(f" {t('Screens')} [{r.get('heads') or 1}] : ")
net = r.get("net") or ""
# Une seule possibilité : rien à demander. C'est le cas d'un hôte
# sans pont, où le réseau libvirt est la seule voie.
if len(reseaux) > 1:
net = self._qemu_pick(
f" {t('Network')}",
[tok for tok, _lab in reseaux],
net,
labels=[lab for _tok, lab in reseaux],
)
want[r["name"]] = hw.build_want(
r, vcpus, ram, gpu, auto, cpu=cpu, heads=heads, net=net
)
return want
@staticmethod

View file

@ -3103,6 +3103,22 @@ TRANSLATIONS = {
"fr": "Nœud de rendu",
"en": "Render node",
},
"Screens": {
"fr": "Écrans",
"en": "Screens",
},
"bridge": {
"fr": "pont",
"en": "bridge",
},
"this VM has no interface": {
"fr": "cette VM n'a pas d'interface réseau",
"en": "this VM has no interface",
},
"CPU mode": {
"fr": "Mode CPU",
"en": "CPU mode",
},
"no render node on the host": {
"fr": "aucun nœud de rendu sur l'hôte",
"en": "no render node on the host",

View file

@ -75,6 +75,29 @@ XML_AVEC_3D = XML_SANS_3D.replace(
"<gl rendernode='/dev/dri/renderD128'/></graphics>",
)
# XML persistant complet : mode CPU, écrans, interface réseau. C'est cette
# forme-là que « virsh dumpxml --inactive » rend, sans les décorations que
# libvirt ajoute au démarrage (portid, vnetN, alias).
XML_COMPLET = """<domain type='kvm'>
<name>erplibre-ubuntu-2604-gnome</name>
<memory unit='KiB'>33554432</memory>
<currentMemory unit='KiB'>33554432</currentMemory>
<vcpu placement='static'>8</vcpu>
<cpu mode='host-passthrough' check='none' migratable='on'/>
<devices>
<interface type='network'>
<mac address='52:54:00:fc:a1:34'/>
<source network='default'/>
<model type='virtio'/>
</interface>
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>
<video>
<model type='virtio' heads='1' primary='yes'/>
</video>
</devices>
</domain>
"""
XML_SERVEUR = """<domain type='kvm'>
<name>erplibre-serveur</name>
<memory unit='KiB'>2097152</memory>
@ -388,6 +411,8 @@ class TestMenuGlue(unittest.TestCase):
)
todo._qemu_autostart = lambda name: False
todo._qemu_host_gpu_node = lambda: node
# Aucun test ne doit atteindre virsh : la liste des réseaux est fournie.
todo._qemu_net_choices = lambda: ["network:default"]
todo.launched = []
todo.execute = mock.Mock()
todo.execute.exec_command_live = (
@ -407,14 +432,14 @@ class TestMenuGlue(unittest.TestCase):
"""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: {}
todo._qemu_hw_form = lambda rows, node, nets=None: {}
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: {
todo._qemu_hw_form = lambda rows, node, nets=None: {
"vm-a": {"vcpus": 4, "ram": 8192, "gpu": True}
}
self._run(todo, ["vm-a"], ["o"])
@ -425,7 +450,7 @@ class TestMenuGlue(unittest.TestCase):
def test_nothing_to_change_launches_nothing(self):
todo = self._todo({"vm-a": "shut off"})
todo._qemu_hw_form = lambda rows, node: {
todo._qemu_hw_form = lambda rows, node, nets=None: {
"vm-a": {"vcpus": 8, "ram": 32768, "gpu": False}
}
out = self._run(todo, ["vm-a"], [])
@ -434,26 +459,30 @@ class TestMenuGlue(unittest.TestCase):
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}}
todo._qemu_hw_form = lambda rows, node, nets=None: {
"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
todo._qemu_hw_form = lambda rows, node, nets=None: 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}}
todo._qemu_hw_form = lambda rows, node, nets=None: {
"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
todo._qemu_hw_form = lambda rows, node, nets=None: None
out = self._run(todo, ["vm-a"], [])
self.assertIn(NODE, out)
@ -461,8 +490,9 @@ class TestMenuGlue(unittest.TestCase):
"""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"])
todo._qemu_hw_form = lambda rows, node, nets=None: {}
# vCPU, RAM, 3D, démarrage auto, mode CPU, écrans, puis la validation.
self._run(todo, ["vm-a"], ["6", "", "o", "n", "", "", "o"])
self.assertIn("--vcpus 6", " ".join(todo.launched))
def test_an_empty_answer_keeps_the_current_state(self):
@ -543,13 +573,86 @@ class TestForm(unittest.IsolatedAsyncioTestCase):
self.assertEqual("8", app.query_one("#vcpus0", Input).value)
self.assertEqual("32G", app.query_one("#ram0", Input).value)
async def test_the_second_row_carries_cpu_screens_and_network(self):
from textual.widgets import Input, Select
app = hw.run_hardware_form(
[hw.hw_state(XML_COMPLET)],
NODE,
nets=["network:default", "bridge:br0"],
run_app=False,
)
async with app.run_test() as pilot:
await pilot.pause()
app.query_one("#cpu0", Select).value = "host-model"
app.query_one("#heads0", Input).value = "2"
app.query_one("#net0", Select).value = "bridge:br0"
await pilot.press("ctrl+s")
await pilot.pause()
want = app.want["erplibre-ubuntu-2604-gnome"]
self.assertEqual("host-model", want["cpu"])
self.assertEqual(2, want["heads"])
self.assertEqual("bridge:br0", want["net"])
async def test_the_screens_value_is_actually_visible(self):
"""Sous six colonnes, Textual dessine le cadre du champ mais PAS son
contenu : la valeur devient invisible, et on valide un champ qu'on
croit vide. Pire qu'une troncature, donc vérifié à l'écran."""
import re
app = hw.run_hardware_form(
[dict(hw.hw_state(XML_COMPLET), heads=3)], NODE, run_app=False
)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
svg = app.export_screenshot()
rendu = re.findall(r">([^<>]+)</text>", svg)
self.assertIn("3", [txt.strip() for txt in rendu])
async def test_without_networks_to_offer_there_is_no_network_field(self):
"""Un hôte sans pont n'a qu'une voie : une liste à un seul choix ne
vaut pas la place qu'elle prend."""
app = hw.run_hardware_form(
[dict(hw.hw_state(XML_COMPLET), net="")], NODE, run_app=False
)
async with app.run_test() as pilot:
await pilot.pause()
self.assertEqual(0, len(app.query("#net0")))
await pilot.press("ctrl+s")
await pilot.pause()
self.assertEqual("", app.want["erplibre-ubuntu-2604-gnome"]["net"])
async def test_the_current_network_stays_selected(self):
"""La liste montre ce que la VM a : sans cela, valider sans y toucher
la basculerait sur le premier choix de la liste."""
from textual.widgets import Select
app = hw.run_hardware_form(
[hw.hw_state(XML_COMPLET)],
NODE,
nets=["bridge:br0", "network:default"],
run_app=False,
)
async with app.run_test() as pilot:
await pilot.pause()
self.assertEqual(
"network:default", app.query_one("#net0", Select).value
)
await pilot.press("ctrl+s")
await pilot.pause()
want = app.want["erplibre-ubuntu-2604-gnome"]
self.assertEqual("network:default", want["net"])
self.assertEqual([], hw.hw_plan(hw.hw_state(XML_COMPLET), want, NODE))
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)
rows = [hw.hw_state(XML_COMPLET), hw.hw_state(XML_SERVEUR)]
app = hw.run_hardware_form(
rows, NODE, nets=["network:default", "bridge:br0"], run_app=False
)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
svg = app.export_screenshot()
@ -557,5 +660,216 @@ class TestForm(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("…", texte)
class TestCpuScreensNetwork(unittest.TestCase):
"""Les trois réglages ajoutés, et pourquoi chacun est celui-là.
Mesuré sur l'hôte avant de les offrir : « heads » atteint QEMU
(max_outputs), « vram » NON sur un virtio-gpu — il n'est donc pas proposé.
"""
def _plan(self, xml, want, node=NODE):
return hw.hw_plan(hw.hw_state(xml), want, node)
def test_it_reads_the_cpu_mode_screens_and_network(self):
st = hw.hw_state(XML_COMPLET)
self.assertEqual("host-passthrough", st["cpu"])
self.assertEqual(1, st["heads"])
self.assertEqual("network:default", st["net"])
def test_a_vm_without_an_interface_has_no_network(self):
self.assertEqual("", hw.hw_state(XML_SERVEUR)["net"])
def test_a_bridge_is_read_as_a_bridge(self):
"""libvirt refuse type='network' avec un pont pour source : le type et
le nom doivent voyager ensemble."""
xml = XML_COMPLET.replace(
"<interface type='network'>", "<interface type='bridge'>"
).replace("<source network='default'/>", "<source bridge='br0'/>")
self.assertEqual("bridge:br0", hw.hw_state(xml)["net"])
def test_the_label_marks_the_bridge_only(self):
"""Le réseau libvirt est le cas ordinaire ; le suffixe allongeait le
libellé au-delà de la liste déroulante, qui se repliait."""
self.assertEqual("default", hw.net_label("network:default"))
self.assertIn("br0", hw.net_label("bridge:br0"))
self.assertIn(hw.t("bridge"), hw.net_label("bridge:br0"))
def test_the_spec_names_the_right_virt_xml_key(self):
self.assertEqual("network=default", hw.net_spec("network:default"))
self.assertEqual("bridge=br0", hw.net_spec("bridge:br0"))
def test_passthrough_keeps_check_and_migratable(self):
"""C'est ce que virt-install écrit, et ce que veut la virtualisation
imbriquée : sans eux libvirt vérifie un modèle qu'il n'a pas calculé.
"""
xml = XML_COMPLET.replace(
"<cpu mode='host-passthrough' check='none' migratable='on'/>",
"<cpu mode='host-model'/>",
)
plan = self._plan(xml, {"cpu": "host-passthrough"})
arg = plan[0]["cmd"][-1]
self.assertIn("check=none", arg)
self.assertIn("migratable=on", arg)
def test_host_model_carries_nothing_extra(self):
plan = self._plan(XML_COMPLET, {"cpu": "host-model"})
self.assertEqual("host-model", plan[0]["cmd"][-1])
def test_the_same_cpu_mode_changes_nothing(self):
self.assertEqual(
[], self._plan(XML_COMPLET, {"cpu": "host-passthrough"})
)
def test_screens_go_through_the_video_model(self):
"""« heads » devient max_outputs sur la ligne QEMU — vérifié par
domxml-to-native. C'est le seul réglage vidéo qui y arrive."""
plan = self._plan(XML_COMPLET, {"heads": 2})
self.assertIn("model.heads=2", plan[0]["cmd"])
def test_screens_on_a_screenless_vm_are_refused_not_attempted(self):
"""« --edit --video » sortirait en erreur au milieu du lot, et les
commandes suivantes ne partiraient pas."""
plan = self._plan(XML_SERVEUR, {"heads": 2})
self.assertEqual(1, len(plan))
self.assertIn("skip", plan[0])
def test_screens_and_3d_are_two_separate_edits(self):
"""Vérifié sur un domaine réel : le second « --edit --video » ne
remet pas heads à 1, et le premier ne perd pas l'accélération."""
plan = self._plan(XML_COMPLET, {"heads": 2, "gpu": True})
videos = [e for e in plan if "--video" in e.get("cmd", [])]
self.assertEqual(2, len(videos))
heads = [e for e in videos if "model.heads=2" in e["cmd"]]
self.assertEqual(1, len(heads))
self.assertNotIn("accel3d", " ".join(heads[0]["cmd"]))
def test_switching_to_a_bridge(self):
"""Le MAC et l'adresse PCI survivent — vérifié sur un domaine réel,
démarré : sans cela l'invité verrait une carte neuve, et son bail
DHCP comme son nom d'interface changeraient."""
plan = self._plan(XML_COMPLET, {"net": "bridge:br0"})
self.assertIn("bridge=br0", plan[0]["cmd"])
self.assertNotIn("mac", " ".join(plan[0]["cmd"]))
def test_switching_back_to_a_libvirt_network(self):
xml = XML_COMPLET.replace(
"<interface type='network'>", "<interface type='bridge'>"
).replace("<source network='default'/>", "<source bridge='br0'/>")
plan = hw.hw_plan(hw.hw_state(xml), {"net": "network:default"}, NODE)
self.assertIn("network=default", plan[0]["cmd"])
def test_the_same_network_changes_nothing(self):
self.assertEqual(
[], self._plan(XML_COMPLET, {"net": "network:default"})
)
def test_a_vm_without_an_interface_is_told_not_attempted(self):
plan = self._plan(XML_SERVEUR, {"net": "bridge:br0"})
self.assertEqual(1, len(plan))
self.assertIn("skip", plan[0])
def test_an_unknown_current_cpu_mode_stays_offered(self):
"""Une VM en mode « custom » ne doit pas voir son réglage disparaître
d'une liste qui l'ignore : la liste afficherait un AUTRE mode que le
sien, et valider le formulaire le changerait sans le dire."""
modes = hw.cpu_choices([{"cpu": "custom"}])
self.assertIn("custom", modes)
self.assertIn("host-passthrough", modes)
def test_the_network_list_merges_the_host_and_the_current_value(self):
choices = hw.net_choices(
[{"net": "bridge:br9"}], ["network:default", "bridge:br9"]
)
self.assertEqual(
["network:default", "bridge:br9"], [tok for tok, _ in choices]
)
def test_empty_answers_keep_the_current_hardware(self):
st = hw.hw_state(XML_COMPLET)
want = hw.build_want(
st, "", "", False, False, cpu="", heads="", net=""
)
self.assertEqual("host-passthrough", want["cpu"])
self.assertEqual(1, want["heads"])
self.assertEqual("network:default", want["net"])
self.assertEqual([], hw.hw_plan(st, want, ""))
class TestHostNetworks(unittest.TestCase):
"""Ce que l'hôte propose : ses réseaux libvirt, et ses ponts à lui."""
def _choices(self, nets, infos, bridges):
todo = TODO.__new__(TODO)
sorties = {}
sorties["net-list"] = nets
sorties["bridge"] = bridges
sorties.update(infos)
def fake(cmd):
if "net-list" in cmd:
return sorties["net-list"]
if "net-info" in cmd:
return sorties.get(cmd[-1], [])
return sorties["bridge"]
todo._qemu_cmd_lines = fake
return todo._qemu_net_choices()
def test_a_libvirt_owned_bridge_is_not_offered_twice(self):
"""virbr0 EST le réseau « default » : l'offrir aussi comme pont
proposerait deux fois le même chemin, dont un qui contourne la
gestion du réseau par libvirt."""
got = self._choices(
["default"],
{"default": ["Name: default", "Bridge: virbr0"]},
["3: virbr0: <BROADCAST,MULTICAST,UP,LOWER_UP>"],
)
self.assertEqual(["network:default"], got)
def test_a_real_bridge_is_offered(self):
got = self._choices(
["default"],
{"default": ["Bridge: virbr0"]},
[
"3: virbr0: <BROADCAST>",
"4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP>",
],
)
self.assertEqual(["network:default", "bridge:br0"], got)
def test_a_host_without_libvirt_answers_nothing(self):
todo = TODO.__new__(TODO)
todo._qemu_cmd_lines = lambda cmd: []
self.assertEqual([], todo._qemu_net_choices())
def test_the_persistent_definition_is_what_gets_read(self):
"""Sur une VM allumée, « dumpxml » sans --inactive rend la vue VIVANTE
(portid, vnetN, alias) — pas la définition que virt-xml modifie."""
vu = {}
def fake_run(cmd, **kw):
vu["cmd"] = cmd
return subprocess.CompletedProcess([], 0, "<domain/>", "")
with mock.patch("subprocess.run", side_effect=fake_run):
TODO._qemu_dumpxml("vm-a")
self.assertIn("--inactive", vu["cmd"])
def test_a_numbered_pick_defaults_to_the_current_value(self):
todo = TODO.__new__(TODO)
out = io.StringIO()
for reponse, attendu in (
("", "b"),
("mille", "b"),
("9", "b"),
("1", "a"),
("2", "b"),
):
with mock.patch("builtins.input", lambda *a, r=reponse: r):
with contextlib.redirect_stdout(out):
got = todo._qemu_pick("t", ["a", "b"], "b")
self.assertEqual(attendu, got, reponse)
if __name__ == "__main__":
unittest.main(verbosity=1)