[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**:
|
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 ›
|
libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs ›
|
||||||
[2] Change the state`, then either accept *Adjust hardware before starting*,
|
[2] Change the state`, then either accept *Adjust hardware before starting*,
|
||||||
or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set
|
or take `[3] Adjust hardware only`. In a form when Textual is available, in
|
||||||
there — in a form when Textual is available, in prompts otherwise.
|
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:
|
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
|
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 ›
|
é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
|
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émarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si
|
||||||
démarrage automatique s'y règlent — en formulaire si Textual est présent, en
|
Textual est présent, en invites sinon, il règle :
|
||||||
invites sinon.
|
|
||||||
|
- **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 :
|
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
|
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 ›
|
é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
|
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émarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si
|
||||||
démarrage automatique s'y règlent — en formulaire si Textual est présent, en
|
Textual est présent, en invites sinon, il règle :
|
||||||
invites sinon.
|
|
||||||
|
- **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 :
|
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**:
|
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 ›
|
libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs ›
|
||||||
[2] Change the state`, then either accept *Adjust hardware before starting*,
|
[2] Change the state`, then either accept *Adjust hardware before starting*,
|
||||||
or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set
|
or take `[3] Adjust hardware only`. In a form when Textual is available, in
|
||||||
there — in a form when Textual is available, in prompts otherwise.
|
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:
|
Two things worth knowing:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,21 @@ CONNECT = "qemu:///system"
|
||||||
# ou un cirrus est accepté par le schéma et ne fait rien.
|
# ou un cirrus est accepté par le schéma et ne fait rien.
|
||||||
GPU_VIDEO_MODEL = "virtio"
|
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 :
|
# 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.
|
# il n'ouvre aucun port et n'existe que pour porter le contexte OpenGL.
|
||||||
SCREEN_TYPES = ("vnc", "spice", "sdl", "desktop")
|
SCREEN_TYPES = ("vnc", "spice", "sdl", "desktop")
|
||||||
|
|
@ -132,6 +147,11 @@ def hw_state(xml: str, autostart=None) -> dict:
|
||||||
"egl": False,
|
"egl": False,
|
||||||
"render": "",
|
"render": "",
|
||||||
"screen": False,
|
"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),
|
"autostart": bool(autostart),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
|
@ -145,9 +165,23 @@ def hw_state(xml: str, autostart=None) -> dict:
|
||||||
state["vcpus"] = 0
|
state["vcpus"] = 0
|
||||||
state["max_mem_mib"] = _mib(root.find("memory"))
|
state["max_mem_mib"] = _mib(root.find("memory"))
|
||||||
state["mem_mib"] = _mib(root.find("currentMemory")) or state["max_mem_mib"]
|
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")
|
model = root.find("./devices/video/model")
|
||||||
if model is not None:
|
if model is not None:
|
||||||
state["video"] = model.get("type") or ""
|
state["video"] = model.get("type") or ""
|
||||||
|
try:
|
||||||
|
state["heads"] = int(model.get("heads") or 1)
|
||||||
|
except ValueError:
|
||||||
|
state["heads"] = 1
|
||||||
accel = model.find("acceleration")
|
accel = model.find("acceleration")
|
||||||
state["accel3d"] = accel is not None and accel.get("accel3d") == "yes"
|
state["accel3d"] = accel is not None and accel.get("accel3d") == "yes"
|
||||||
for graphics in root.findall("./devices/graphics"):
|
for graphics in root.findall("./devices/graphics"):
|
||||||
|
|
@ -163,6 +197,62 @@ def hw_state(xml: str, autostart=None) -> dict:
|
||||||
return state
|
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:
|
def _virt_xml(name: str, *args) -> list:
|
||||||
"""Commande virt-xml qui ÉCRIT la définition persistante du domaine.
|
"""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")
|
gpu = want.get("gpu")
|
||||||
if gpu is not None:
|
if gpu is not None:
|
||||||
plan += _gpu_plan(name, state, bool(gpu), node)
|
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
|
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.
|
"""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
|
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,
|
"ram": parse_ram(ram) or state.get("mem_mib") or 0,
|
||||||
"gpu": bool(gpu),
|
"gpu": bool(gpu),
|
||||||
"autostart": bool(autostart),
|
"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]}")
|
bits.append(f"3D {node.rsplit('/', 1)[-1]}")
|
||||||
elif state.get("screen"):
|
elif state.get("screen"):
|
||||||
bits.append(t("software rendering"))
|
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)
|
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.
|
"""Formulaire d'ajustement matériel. Renvoie {nom: intention} ou None.
|
||||||
|
|
||||||
`rows` est une liste d'états (hw_state). `run_app=False` renvoie
|
`rows` est une liste d'états (hw_state), `nets` les réseaux que l'hôte
|
||||||
l'instance sans la lancer — c'est ainsi que les tests l'inspectent.
|
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.app import App, ComposeResult
|
||||||
from textual.containers import Horizontal, VerticalScroll
|
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]
|
states = [dict(r) for r in rows if r]
|
||||||
|
cpus = cpu_choices(states)
|
||||||
|
reseaux = net_choices(states, nets)
|
||||||
|
|
||||||
class HardwareForm(App):
|
class HardwareForm(App):
|
||||||
TITLE = t("VM hardware")
|
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; }
|
.cb3d { width: auto; margin: 0 2 0 1; }
|
||||||
.cbauto { width: auto; }
|
.cbauto { width: auto; }
|
||||||
.warn { padding: 0 3; }
|
.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; }
|
#bar { height: auto; padding: 1; }
|
||||||
"""
|
"""
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
|
|
@ -427,6 +627,30 @@ def run_hardware_form(rows, node: str = "", run_app: bool = True):
|
||||||
id=f"auto{i}",
|
id=f"auto{i}",
|
||||||
classes="cbauto",
|
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:
|
if reason:
|
||||||
yield Static(f"⚠ {reason}", classes="warn")
|
yield Static(f"⚠ {reason}", classes="warn")
|
||||||
with Horizontal(id="bar"):
|
with Horizontal(id="bar"):
|
||||||
|
|
@ -437,12 +661,18 @@ def run_hardware_form(rows, node: str = "", run_app: bool = True):
|
||||||
def action_apply(self) -> None:
|
def action_apply(self) -> None:
|
||||||
want = {}
|
want = {}
|
||||||
for i, st in enumerate(states):
|
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(
|
want[st.get("name", "")] = build_want(
|
||||||
st,
|
st,
|
||||||
self.query_one(f"#vcpus{i}", Input).value,
|
self.query_one(f"#vcpus{i}", Input).value,
|
||||||
self.query_one(f"#ram{i}", Input).value,
|
self.query_one(f"#ram{i}", Input).value,
|
||||||
self.query_one(f"#gpu{i}", Checkbox).value,
|
self.query_one(f"#gpu{i}", Checkbox).value,
|
||||||
self.query_one(f"#auto{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.want = want
|
||||||
self.exit()
|
self.exit()
|
||||||
|
|
|
||||||
|
|
@ -2922,10 +2922,16 @@ class TODO:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _qemu_dumpxml(name):
|
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:
|
try:
|
||||||
res = subprocess.run(
|
res = subprocess.run(
|
||||||
["sudo", "virsh", "dumpxml", name],
|
["sudo", "virsh", "dumpxml", "--inactive", name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=20,
|
timeout=20,
|
||||||
|
|
@ -2973,6 +2979,52 @@ class TODO:
|
||||||
except (OSError, AttributeError, ImportError):
|
except (OSError, AttributeError, ImportError):
|
||||||
return ""
|
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):
|
def _qemu_adjust_hardware(self, names):
|
||||||
"""Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES.
|
"""Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES.
|
||||||
|
|
||||||
|
|
@ -3009,12 +3061,13 @@ class TODO:
|
||||||
return
|
return
|
||||||
for r in rows:
|
for r in rows:
|
||||||
print(f" {r['name']:<30} {hw.hw_summary(r)}")
|
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:
|
if want is None:
|
||||||
print(t("Cancelled."))
|
print(t("Cancelled."))
|
||||||
return
|
return
|
||||||
if not want:
|
if not want:
|
||||||
want = self._qemu_hw_prompts(rows, node)
|
want = self._qemu_hw_prompts(rows, node, nets)
|
||||||
if not want:
|
if not want:
|
||||||
print(t("Cancelled."))
|
print(t("Cancelled."))
|
||||||
return
|
return
|
||||||
|
|
@ -3039,7 +3092,7 @@ class TODO:
|
||||||
print(f"\n{t('Will execute:')} {cmd}")
|
print(f"\n{t('Will execute:')} {cmd}")
|
||||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
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
|
"""Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour
|
||||||
retomber sur les invites en ligne (textual absent), None si annulé."""
|
retomber sur les invites en ligne (textual absent), None si annulé."""
|
||||||
from script.todo import textual_setup
|
from script.todo import textual_setup
|
||||||
|
|
@ -3049,14 +3102,34 @@ class TODO:
|
||||||
try:
|
try:
|
||||||
from script.todo.qemu_hardware import run_hardware_form
|
from script.todo.qemu_hardware import run_hardware_form
|
||||||
|
|
||||||
return run_hardware_form(rows, node)
|
return run_hardware_form(rows, node, nets)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return {}
|
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."""
|
"""Même ajustement, en invites, quand Textual n'est pas disponible."""
|
||||||
from script.todo import qemu_hardware as hw
|
from script.todo import qemu_hardware as hw
|
||||||
|
|
||||||
|
cpus = hw.cpu_choices(rows)
|
||||||
|
reseaux = hw.net_choices(rows, nets)
|
||||||
want = {}
|
want = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
print(f"\n {r['name']} — {hw.hw_summary(r)}")
|
print(f"\n {r['name']} — {hw.hw_summary(r)}")
|
||||||
|
|
@ -3074,7 +3147,23 @@ class TODO:
|
||||||
auto = self._qemu_ask_bool(
|
auto = self._qemu_ask_bool(
|
||||||
f" {t('Autostart')} ? (o/N) : ", bool(r.get("autostart"))
|
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
|
return want
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -3103,6 +3103,22 @@ TRANSLATIONS = {
|
||||||
"fr": "Nœud de rendu",
|
"fr": "Nœud de rendu",
|
||||||
"en": "Render node",
|
"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": {
|
"no render node on the host": {
|
||||||
"fr": "aucun nœud de rendu sur l'hôte",
|
"fr": "aucun nœud de rendu sur l'hôte",
|
||||||
"en": "no render node on the host",
|
"en": "no render node on the host",
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,29 @@ XML_AVEC_3D = XML_SANS_3D.replace(
|
||||||
"<gl rendernode='/dev/dri/renderD128'/></graphics>",
|
"<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'>
|
XML_SERVEUR = """<domain type='kvm'>
|
||||||
<name>erplibre-serveur</name>
|
<name>erplibre-serveur</name>
|
||||||
<memory unit='KiB'>2097152</memory>
|
<memory unit='KiB'>2097152</memory>
|
||||||
|
|
@ -388,6 +411,8 @@ class TestMenuGlue(unittest.TestCase):
|
||||||
)
|
)
|
||||||
todo._qemu_autostart = lambda name: False
|
todo._qemu_autostart = lambda name: False
|
||||||
todo._qemu_host_gpu_node = lambda: node
|
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.launched = []
|
||||||
todo.execute = mock.Mock()
|
todo.execute = mock.Mock()
|
||||||
todo.execute.exec_command_live = (
|
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
|
"""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."""
|
prochain démarrage : un réglage qui paraît appliqué et ne l'est pas."""
|
||||||
todo = self._todo({"vm-a": "running"})
|
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"], [])
|
out = self._run(todo, ["vm-a"], [])
|
||||||
self.assertIn("vm-a", out)
|
self.assertIn("vm-a", out)
|
||||||
self.assertEqual([], todo.launched)
|
self.assertEqual([], todo.launched)
|
||||||
|
|
||||||
def test_a_shut_off_vm_is_adjusted(self):
|
def test_a_shut_off_vm_is_adjusted(self):
|
||||||
todo = self._todo({"vm-a": "shut off"})
|
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}
|
"vm-a": {"vcpus": 4, "ram": 8192, "gpu": True}
|
||||||
}
|
}
|
||||||
self._run(todo, ["vm-a"], ["o"])
|
self._run(todo, ["vm-a"], ["o"])
|
||||||
|
|
@ -425,7 +450,7 @@ class TestMenuGlue(unittest.TestCase):
|
||||||
|
|
||||||
def test_nothing_to_change_launches_nothing(self):
|
def test_nothing_to_change_launches_nothing(self):
|
||||||
todo = self._todo({"vm-a": "shut off"})
|
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}
|
"vm-a": {"vcpus": 8, "ram": 32768, "gpu": False}
|
||||||
}
|
}
|
||||||
out = self._run(todo, ["vm-a"], [])
|
out = self._run(todo, ["vm-a"], [])
|
||||||
|
|
@ -434,26 +459,30 @@ class TestMenuGlue(unittest.TestCase):
|
||||||
|
|
||||||
def test_refusing_the_confirmation_launches_nothing(self):
|
def test_refusing_the_confirmation_launches_nothing(self):
|
||||||
todo = self._todo({"vm-a": "shut off"})
|
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._run(todo, ["vm-a"], ["n"])
|
||||||
self.assertEqual([], todo.launched)
|
self.assertEqual([], todo.launched)
|
||||||
|
|
||||||
def test_cancelling_the_form_launches_nothing(self):
|
def test_cancelling_the_form_launches_nothing(self):
|
||||||
todo = self._todo({"vm-a": "shut off"})
|
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._run(todo, ["vm-a"], [])
|
||||||
self.assertEqual([], todo.launched)
|
self.assertEqual([], todo.launched)
|
||||||
|
|
||||||
def test_the_skipped_3d_is_explained_not_silent(self):
|
def test_the_skipped_3d_is_explained_not_silent(self):
|
||||||
todo = self._todo({"vm-a": "shut off"}, node="")
|
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"], [])
|
out = self._run(todo, ["vm-a"], [])
|
||||||
self.assertIn(hw.t("no render node on the host"), out)
|
self.assertIn(hw.t("no render node on the host"), out)
|
||||||
self.assertEqual([], todo.launched)
|
self.assertEqual([], todo.launched)
|
||||||
|
|
||||||
def test_the_host_gpu_is_announced_before_anything_else(self):
|
def test_the_host_gpu_is_announced_before_anything_else(self):
|
||||||
todo = self._todo({"vm-a": "shut off"})
|
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"], [])
|
out = self._run(todo, ["vm-a"], [])
|
||||||
self.assertIn(NODE, out)
|
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
|
"""Le repli en ligne n'est pas décoratif : sans Textual, c'est la SEULE
|
||||||
voie, et un {} mal interprété annulerait tout."""
|
voie, et un {} mal interprété annulerait tout."""
|
||||||
todo = self._todo({"vm-a": "shut off"})
|
todo = self._todo({"vm-a": "shut off"})
|
||||||
todo._qemu_hw_form = lambda rows, node: {}
|
todo._qemu_hw_form = lambda rows, node, nets=None: {}
|
||||||
self._run(todo, ["vm-a"], ["6", "", "o", "n", "o"])
|
# 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))
|
self.assertIn("--vcpus 6", " ".join(todo.launched))
|
||||||
|
|
||||||
def test_an_empty_answer_keeps_the_current_state(self):
|
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("8", app.query_one("#vcpus0", Input).value)
|
||||||
self.assertEqual("32G", app.query_one("#ram0", 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):
|
async def test_it_fits_in_eighty_columns(self):
|
||||||
"""Un terminal de 80 colonnes est le plus petit qu'on rencontre ;
|
"""Un terminal de 80 colonnes est le plus petit qu'on rencontre ;
|
||||||
au-delà, les libellés se tronquent en « Démarrage automatiq… »."""
|
au-delà, les libellés se tronquent en « Démarrage automatiq… »."""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
rows = [hw.hw_state(XML_SANS_3D), hw.hw_state(XML_SERVEUR)]
|
rows = [hw.hw_state(XML_COMPLET), hw.hw_state(XML_SERVEUR)]
|
||||||
app = await self._mount(rows, NODE)
|
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:
|
async with app.run_test(size=(80, 24)) as pilot:
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
svg = app.export_screenshot()
|
svg = app.export_screenshot()
|
||||||
|
|
@ -557,5 +660,216 @@ class TestForm(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertNotIn("…", texte)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main(verbosity=1)
|
unittest.main(verbosity=1)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue