From fba3b8aed118acb2182fd8b4745a01d4e5a77d29 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 1 Aug 2026 03:15:46 -0400 Subject: [PATCH] [FIX] qemu: really prepare the host in the Deployment profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The « ERPLibre Deployment (+ QEMU + dev) » profile installed packages with a one-liner ending in « || true » and hiding stderr, so a broken host looked installed. Reproduced on erplibre-arch-latest: every binary was present, yet virt-install failed and « virsh list --all » could not reach any hypervisor. Three distinct causes, all unhandled: - The user was never added to the libvirt group. Without it a non-root libvirt client falls back to qemu:///session, where the « default » network does not exist, so « --network network=default » fails while everything looks installed. Being in the group grants the RIGHT to reach qemu:///system but does NOT change the default URI, so virt-install and virsh now pass « --connect qemu:///system » explicitly (new LIBVIRT_URI constant). - dnsmasq was missing: ensure_tools only installed DAEMON_PACKAGES when the daemon was absent, and libvirt was already there. setup_host now forces them. - On a rolling distro, « make install_os » upgrades the kernel and the package manager removes /lib/modules/. modprobe bridge then fails and libvirt cannot create virbr0 (« Unable to create bridge virbr0: Package not installed »). No package fixes that, only a reboot: it is now diagnosed and reported instead of surfacing as an unreadable virsh error. The profile now calls « deploy_qemu.py --setup-host », which reuses the existing ensure_* chain, so package names stay defined in one place (TOOL_PACKAGES / DAEMON_PACKAGES) and keep working for apt, dnf, pacman, zypper and brew. It fails loudly instead of « || true ». Verified on the Arch VM that failed: setup-host reports the stale kernel and exits 1; after a reboot it installs dnsmasq, joins libvirt/kvm, starts the default network, and reports « Hôte prêt » (exit 0, idempotent on rerun). The generated command now reads « virt-install --connect qemu:///system ». Co-Authored-By: Claude Opus 4.8 (1M context) --- script/qemu/deploy_qemu.py | 206 +++++++++++++++++++++++++++++++++++-- script/todo/todo.py | 21 ++-- 2 files changed, 205 insertions(+), 22 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 8c3a798..fb2b312 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -47,6 +47,7 @@ from __future__ import annotations import argparse import getpass +import grp import hashlib import os import re @@ -148,6 +149,7 @@ def host_arch() -> str: "s390x": "s390x", }.get(machine, "amd64") + ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest" CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com" @@ -237,7 +239,9 @@ def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str: for base in bases: index = f"{base}/{version}/Cloud/{a}/images/" try: - with urllib.request.urlopen(index, timeout=30) as resp: # noqa: S310 + with urllib.request.urlopen( + index, timeout=30 + ) as resp: # noqa: S310 html = resp.read().decode(errors="replace") except Exception as exc: # pragma: no cover - dépend du réseau last_err = str(exc) @@ -377,6 +381,14 @@ def need_tool(name: str) -> None: # --------------------------------------------------------------------------- # # Outils indispensables au déploiement complet (le mode --download-only n'en # requiert aucun). +# URI libvirt visée par TOUS les clients (virsh, virt-install). À ne jamais +# laisser implicite : pour un utilisateur non root, libvirt choisit +# « qemu:///session », où le réseau « default » N'EXISTE PAS — virt-install +# échoue alors sur « --network network=default » de façon incompréhensible. +# Appartenir au groupe libvirt donne le DROIT d'accéder à qemu:///system mais +# ne change PAS l'URI par défaut : il faut l'imposer. +LIBVIRT_URI = "qemu:///system" + REQUIRED_TOOLS: tuple[str, ...] = ("qemu-img", "virt-install", "virsh") # Pour construire le seed cloud-init il faut AU MOINS un de ces outils. SEED_TOOLS: tuple[str, ...] = ("cloud-localds", "genisoimage") @@ -613,11 +625,142 @@ def ensure_libvirt_service(runner: Runner) -> None: ) -def ensure_tools(runner: Runner, assume_yes: bool, no_install: bool) -> None: +def invoking_user() -> str: + """Utilisateur réel derrière l'appel, même sous sudo.""" + return os.environ.get("SUDO_USER") or getpass.getuser() + + +def ensure_libvirt_group(runner: Runner) -> bool: + """Ajoute l'utilisateur au groupe libvirt. Renvoie True s'il l'était déjà. + + SANS ce groupe, les clients libvirt d'un utilisateur non root retombent sur + « qemu:///session », où le réseau « default » N'EXISTE PAS : virt-install + échoue alors sur « --network network=default » alors que tous les paquets + sont pourtant installés. C'est le trou qui manquait au profil + « ERPLibre Déploiement » : il installait QEMU sans jamais rendre + qemu:///system accessible. + """ + user = invoking_user() + if user == "root": + return True + lst_group = [] + for group in ("libvirt", "kvm"): + try: + grp.getgrnam(group) + except KeyError: + continue # groupe absent sur cette distro + try: + if user in grp.getgrnam(group).gr_mem: + continue + except KeyError: + pass + lst_group.append(group) + if not lst_group: + print(f" Utilisateur « {user} » déjà dans les groupes libvirt/kvm.") + return True + for group in lst_group: + print(f" Ajout de « {user} » au groupe « {group} »…") + runner.run( + ["usermod", "-aG", group, user], privileged=True, check=False + ) + return False + + +def kernel_modules_stale() -> str: + """Renvoie un message si les modules du noyau EN COURS ont disparu. + + Sur une distro roulante, « make install_os » met le noyau à jour et le + gestionnaire de paquets SUPPRIME /lib/modules/. Tant qu'on + n'a pas redémarré, « modprobe bridge » échoue et libvirt ne peut pas créer + virbr0 : « Unable to create bridge virbr0: Package not installed ». Aucun + paquet ne corrige ça, seul un redémarrage le fait — d'où ce diagnostic. + """ + if not shutil.which("uname"): + return "" + running = os.uname().release + path_module = f"/lib/modules/{running}" + if os.path.isdir(path_module): + return "" + return ( + f"Le noyau en cours ({running}) n'a plus ses modules " + f"({path_module} absent) : il a été mis à jour depuis le démarrage.\n" + " libvirt ne pourra pas créer le pont virbr0 tant que la machine\n" + " n'aura pas REDÉMARRÉ. Redémarrez, puis relancez --setup-host." + ) + + +def setup_host(runner: Runner, assume_yes: bool, no_install: bool) -> None: + """Prépare l'hôte à faire tourner des VM : paquets, démon, groupe, réseau. + + Point d'entrée unique du profil d'installation « ERPLibre Déploiement ». + Il réutilise les mêmes fonctions que le déploiement, donc les noms de + paquets restent définis à UN SEUL endroit (TOOL_PACKAGES / + DAEMON_PACKAGES) et restent valides pour apt, dnf, pacman, zypper et brew. + """ + print("\n== Préparation de l'hôte pour QEMU/libvirt ==") + # ensure_tools installe les clients ET, si le démon manque, les paquets de + # DAEMON_PACKAGES (qemu système + libvirt + firmware UEFI), puis démarre le + # service. ensure_emulator ne sert QU'À l'émulation croisée (arm64 sur x86) + # et n'a pas de clé pour l'architecture hôte. + ensure_tools(runner, assume_yes, no_install, force_daemon=True) + ensure_libvirt_service(runner) + already_in_group = ensure_libvirt_group(runner) + # Diagnostiqué AVANT le réseau : sans les modules du noyau en cours, le + # pont virbr0 est impossible et l'erreur de virsh est indéchiffrable. + stale = kernel_modules_stale() + if stale: + print(f"\n⚠ {stale}") + ensure_network("default", runner) + + if runner.dry_run: + print("\n[dry-run] Rien n'a été modifié, vérification ignorée.") + return + + print("\n== Vérification ==") + ok = libvirt_ready(runner.use_sudo) + print(f" hyperviseur qemu:///system : {'OK' if ok else 'INJOIGNABLE'}") + active, autostart = network_state("default", runner.use_sudo) + print( + f" réseau libvirt « default » : " + f"{'actif' if active else 'INACTIF'}" + f" / {'autostart' if autostart else 'PAS autostart'}" + ) + if not (ok and active): + sys.exit( + "Erreur : l'hôte n'est pas prêt.\n" + + (f" {stale}\n" if stale else "") + + " Sinon vérifiez :\n" + " sudo systemctl enable --now libvirtd\n" + " sudo virsh -c qemu:///system net-start default" + ) + if not already_in_group: + print( + f"\n⚠ « {invoking_user()} » vient d'être ajouté au groupe libvirt." + " Les groupes ne s'appliquent qu'aux NOUVELLES sessions :" + " reconnectez-vous (ou « newgrp libvirt »), sinon virt-install" + " continuera d'utiliser qemu:///session et le réseau « default »" + " restera introuvable." + ) + print("\nHôte prêt.") + + +def ensure_tools( + runner: Runner, + assume_yes: bool, + no_install: bool, + force_daemon: bool = False, +) -> None: """Vérifie outils, démon libvirt et émulateur ; installe/démarre ce qui - manque, puis vérifie la connexion à l'hyperviseur.""" + manque, puis vérifie la connexion à l'hyperviseur. + + `force_daemon` réinstalle DAEMON_PACKAGES même quand le démon répond déjà. + Vécu sur Arch : libvirt était présent (posé par un ancien one-liner qui ne + listait pas dnsmasq), donc daemon_missing() renvoyait False et dnsmasq + n'était jamais installé -> « Failed to start network default ». Les + gestionnaires de paquets ignorent ce qui est déjà là : c'est bon marché. + """ missing = missing_tools() - need_daemon = daemon_missing() + need_daemon = daemon_missing() or force_daemon # Tout est là et l'hyperviseur répond : rien à faire. if not missing and not need_daemon and libvirt_ready(runner.use_sudo): @@ -1138,7 +1281,13 @@ def network_name(network_arg: str) -> str | None: def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]: """(actif, autostart) d'un réseau libvirt, via « virsh net-info ».""" - cmd = (["sudo"] if use_sudo else []) + ["virsh", "net-info", name] + cmd = (["sudo"] if use_sudo else []) + [ + "virsh", + "-c", + LIBVIRT_URI, + "net-info", + name, + ] try: res = subprocess.run(cmd, capture_output=True, text=True, timeout=15) except (OSError, subprocess.SubprocessError): @@ -1156,9 +1305,15 @@ def ensure_network(name: str | None, runner: Runner) -> None: return if runner.dry_run: print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire") - runner.run(["virsh", "net-start", name], privileged=True, check=False) runner.run( - ["virsh", "net-autostart", name], privileged=True, check=False + ["virsh", "-c", LIBVIRT_URI, "net-start", name], + privileged=True, + check=False, + ) + runner.run( + ["virsh", "-c", LIBVIRT_URI, "net-autostart", name], + privileged=True, + check=False, ) return active, autostart = network_state(name, runner.use_sudo) @@ -1167,10 +1322,16 @@ def ensure_network(name: str | None, runner: Runner) -> None: return print(f" Configuration du réseau libvirt '{name}'…") if not active: - runner.run(["virsh", "net-start", name], privileged=True, check=False) + runner.run( + ["virsh", "-c", LIBVIRT_URI, "net-start", name], + privileged=True, + check=False, + ) if not autostart: runner.run( - ["virsh", "net-autostart", name], privileged=True, check=False + ["virsh", "-c", LIBVIRT_URI, "net-autostart", name], + privileged=True, + check=False, ) @@ -1188,6 +1349,11 @@ def virt_install( console_target = "sclp" if args.arch == "s390x" else "serial" cmd = [ "virt-install", + # Sans --connect, un utilisateur non root vise qemu:///session : le + # réseau « default » n'y existe pas et la création échoue même quand + # tous les paquets sont installés et le réseau actif côté système. + "--connect", + LIBVIRT_URI, "--name", args.name, "--memory", @@ -1284,7 +1450,8 @@ def wait_for_ip(name: str, use_sudo: bool, timeout: int) -> str | None: peut avoir PLUSIEURS baux (l'image demande d'abord une IP avec le hostname par défaut « ubuntu », puis cloud-init fixe le vrai hostname -> 2e bail) : on renvoie une IP JOIGNABLE (sshd up), sinon la plus récente, jamais - aveuglément la 1re (souvent le bail précoce périmé, « No route to host »).""" + aveuglément la 1re (souvent le bail précoce périmé, « No route to host »). + """ base = (["sudo"] if use_sudo else []) + [ "virsh", "domifaddr", @@ -1544,6 +1711,12 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="N'installe jamais les dépendances manquantes (échoue si absentes).", ) + g_run.add_argument( + "--setup-host", + action="store_true", + help="Prépare l'hôte (paquets QEMU/libvirt, démon, groupe libvirt, " + "réseau default) puis quitte. Ne déploie aucune VM.", + ) g_run.add_argument( "--list-images", action="store_true", @@ -1590,6 +1763,19 @@ def main() -> None: list_images() return + # Préparation de l'hôte : aucune image, aucune distro cible, aucun --name. + # Traité AVANT la résolution de version/image pour rester utilisable seul. + if args.setup_host: + setup_host( + Runner( + use_sudo=not args.no_sudo and os.geteuid() != 0, + dry_run=args.dry_run, + ), + args.assume_yes, + args.no_install_deps, + ) + return + versions, default_version = DISTROS[args.distro] if args.version is None: args.version = default_version diff --git a/script/todo/todo.py b/script/todo/todo.py index ffe408e..7c5ae8a 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -3070,18 +3070,15 @@ class TODO: # sshd répondait au moins (mieux qu'un abandon silencieux). return ssh_up - # Paquets QEMU/libvirt pour le profil « Déploiement » (nos 3 gestionnaires). - # Paquets SYSTÈME du profil « ERPLibre Déploiement » : QEMU/libvirt/ - # virtinst + libguestfs (virt-resize/virt-filesystems, requis pour la - # RÉDUCTION SÛRE de disque). Ce sont des binaires système (OCaml) : ils ne - # peuvent PAS vivre dans .venv.erplibre (venv Python) — d'où leur place ici. - _QEMU_QEMU_PKGS = ( - "(sudo apt-get install -y qemu-kvm libvirt-daemon-system virtinst " - "cloud-image-utils libguestfs-tools 2>/dev/null || sudo dnf install " - "-y qemu-kvm libvirt virt-install cloud-utils guestfs-tools " - "2>/dev/null || sudo pacman -S --needed --noconfirm qemu-desktop " - "libvirt virt-install libguestfs 2>/dev/null || true)" - ) + # Préparation hôte QEMU/libvirt du profil « ERPLibre Déploiement ». + # Délègue à deploy_qemu.py --setup-host : les noms de paquets y sont déjà + # définis pour apt/dnf/pacman/zypper/brew (TOOL_PACKAGES, DAEMON_PACKAGES), + # et il fait ce que l'ancien one-liner ne faisait PAS — démarrer le démon, + # ajouter l'utilisateur au groupe libvirt et activer le réseau « default ». + # Sans le groupe, virt-install retombe sur qemu:///session où « default » + # n'existe pas : la VM échoue alors que tous les paquets sont installés. + # L'ancien one-liner finissait par « || true » et masquait ses erreurs. + _QEMU_QEMU_PKGS = "./script/qemu/deploy_qemu.py --setup-host --assume-yes" def _qemu_ask_prod(self): """Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est