[FIX] qemu: really prepare the host in the Deployment profile

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/<running kernel>. 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) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-01 03:15:46 -04:00
parent a9ef5a0e8e
commit fba3b8aed1
2 changed files with 205 additions and 22 deletions

View file

@ -47,6 +47,7 @@ from __future__ import annotations
import argparse import argparse
import getpass import getpass
import grp
import hashlib import hashlib
import os import os
import re import re
@ -148,6 +149,7 @@ def host_arch() -> str:
"s390x": "s390x", "s390x": "s390x",
}.get(machine, "amd64") }.get(machine, "amd64")
ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest" ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest"
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com" 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: for base in bases:
index = f"{base}/{version}/Cloud/{a}/images/" index = f"{base}/{version}/Cloud/{a}/images/"
try: 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") html = resp.read().decode(errors="replace")
except Exception as exc: # pragma: no cover - dépend du réseau except Exception as exc: # pragma: no cover - dépend du réseau
last_err = str(exc) 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 # Outils indispensables au déploiement complet (le mode --download-only n'en
# requiert aucun). # 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") REQUIRED_TOOLS: tuple[str, ...] = ("qemu-img", "virt-install", "virsh")
# Pour construire le seed cloud-init il faut AU MOINS un de ces outils. # Pour construire le seed cloud-init il faut AU MOINS un de ces outils.
SEED_TOOLS: tuple[str, ...] = ("cloud-localds", "genisoimage") 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 », 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/<noyau en cours>. 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 """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à : c'est bon marché.
"""
missing = missing_tools() missing = missing_tools()
need_daemon = daemon_missing() need_daemon = daemon_missing() or force_daemon
# Tout est là et l'hyperviseur répond : rien à faire. # Tout est là et l'hyperviseur répond : rien à faire.
if not missing and not need_daemon and libvirt_ready(runner.use_sudo): 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]: def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]:
"""(actif, autostart) d'un réseau libvirt, via « virsh net-info ».""" """(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: try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15) res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
@ -1156,9 +1305,15 @@ def ensure_network(name: str | None, runner: Runner) -> None:
return return
if runner.dry_run: if runner.dry_run:
print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire") print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire")
runner.run(["virsh", "net-start", name], privileged=True, check=False)
runner.run( 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 return
active, autostart = network_state(name, runner.use_sudo) active, autostart = network_state(name, runner.use_sudo)
@ -1167,10 +1322,16 @@ def ensure_network(name: str | None, runner: Runner) -> None:
return return
print(f" Configuration du réseau libvirt '{name}'") print(f" Configuration du réseau libvirt '{name}'")
if not active: 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: if not autostart:
runner.run( 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" console_target = "sclp" if args.arch == "s390x" else "serial"
cmd = [ cmd = [
"virt-install", "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", "--name",
args.name, args.name,
"--memory", "--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 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) : 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 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 []) + [ base = (["sudo"] if use_sudo else []) + [
"virsh", "virsh",
"domifaddr", "domifaddr",
@ -1544,6 +1711,12 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true", action="store_true",
help="N'installe jamais les dépendances manquantes (échoue si absentes).", 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( g_run.add_argument(
"--list-images", "--list-images",
action="store_true", action="store_true",
@ -1590,6 +1763,19 @@ def main() -> None:
list_images() list_images()
return 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] versions, default_version = DISTROS[args.distro]
if args.version is None: if args.version is None:
args.version = default_version args.version = default_version

View file

@ -3070,18 +3070,15 @@ class TODO:
# sshd répondait au moins (mieux qu'un abandon silencieux). # sshd répondait au moins (mieux qu'un abandon silencieux).
return ssh_up return ssh_up
# Paquets QEMU/libvirt pour le profil « Déploiement » (nos 3 gestionnaires). # Préparation hôte QEMU/libvirt du profil « ERPLibre Déploiement ».
# Paquets SYSTÈME du profil « ERPLibre Déploiement » : QEMU/libvirt/ # Délègue à deploy_qemu.py --setup-host : les noms de paquets y sont déjà
# virtinst + libguestfs (virt-resize/virt-filesystems, requis pour la # définis pour apt/dnf/pacman/zypper/brew (TOOL_PACKAGES, DAEMON_PACKAGES),
# RÉDUCTION SÛRE de disque). Ce sont des binaires système (OCaml) : ils ne # et il fait ce que l'ancien one-liner ne faisait PAS — démarrer le démon,
# peuvent PAS vivre dans .venv.erplibre (venv Python) — d'où leur place ici. # ajouter l'utilisateur au groupe libvirt et activer le réseau « default ».
_QEMU_QEMU_PKGS = ( # Sans le groupe, virt-install retombe sur qemu:///session où « default »
"(sudo apt-get install -y qemu-kvm libvirt-daemon-system virtinst " # n'existe pas : la VM échoue alors que tous les paquets sont installés.
"cloud-image-utils libguestfs-tools 2>/dev/null || sudo dnf install " # L'ancien one-liner finissait par « || true » et masquait ses erreurs.
"-y qemu-kvm libvirt virt-install cloud-utils guestfs-tools " _QEMU_QEMU_PKGS = "./script/qemu/deploy_qemu.py --setup-host --assume-yes"
"2>/dev/null || sudo pacman -S --needed --noconfirm qemu-desktop "
"libvirt virt-install libguestfs 2>/dev/null || true)"
)
def _qemu_ask_prod(self): def _qemu_ask_prod(self):
"""Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est """Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est