diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index f216e83..cf6668a 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -705,8 +705,22 @@ sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" An existing VM is adjusted from the TODO menu **while it is shut off**: libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs › [2] Change the state`, then either accept *Adjust hardware before starting*, -or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set -there — in a form when Textual is available, in prompts otherwise. +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 | 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 : diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index d5e35ca..fcff7ef 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -385,9 +385,23 @@ sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM › Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de -démarrer*, ou prenez `[3] Régler le matériel seulement`. vCPU, RAM, 3D et -démarrage automatique s'y règlent — en formulaire si Textual est présent, en -invites sinon. +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 : diff --git a/script/qemu/README.md b/script/qemu/README.md index a6775ed..38cf1e2 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -363,8 +363,22 @@ sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" An existing VM is adjusted from the TODO menu **while it is shut off**: libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs › [2] Change the state`, then either accept *Adjust hardware before starting*, -or take `[3] Adjust hardware only`. vCPU, RAM, 3D and autostart are set -there — in a form when Textual is available, in prompts otherwise. +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: diff --git a/script/todo/qemu_hardware.py b/script/todo/qemu_hardware.py index dc480ec..700cfc4 100644 --- a/script/todo/qemu_hardware.py +++ b/script/todo/qemu_hardware.py @@ -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() diff --git a/script/todo/todo.py b/script/todo/todo.py index c0b8b52..07ab2f4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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: 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 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 39db9ca..daf155c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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", diff --git a/test/test_qemu_gpu.py b/test/test_qemu_gpu.py index 94e173a..071fddc 100644 --- a/test/test_qemu_gpu.py +++ b/test/test_qemu_gpu.py @@ -75,6 +75,29 @@ XML_AVEC_3D = XML_SANS_3D.replace( "", ) +# 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 = """ + erplibre-ubuntu-2604-gnome + 33554432 + 33554432 + 8 + + + + + + + + + + + +""" + XML_SERVEUR = """ erplibre-serveur 2097152 @@ -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">([^<>]+)", 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( + "", "" + ).replace("", "") + 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( + "", + "", + ) + 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( + "", "" + ).replace("", "") + 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: "], + ) + self.assertEqual(["network:default"], got) + + def test_a_real_bridge_is_offered(self): + got = self._choices( + ["default"], + {"default": ["Bridge: virbr0"]}, + [ + "3: virbr0: ", + "4: br0: ", + ], + ) + 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, "", "") + + 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)