[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:
parent
a55b03e199
commit
c54bfb7974
7 changed files with 739 additions and 34 deletions
|
|
@ -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 :
|
||||
|
||||
|
|
|
|||
|
|
@ -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 :
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue