#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Faut-il « sudo » pour parler à libvirt ? Une seule réponse pour tout TODO. Appartenir au groupe libvirt suffit à joindre qemu:///system : préfixer alors les commandes de « sudo » ne donne aucun droit de plus et réclame un mot de passe pour rien. La question se tranche en ESSAYANT, jamais en lisant /etc/group : les groupes d'un processus sont figés à l'ouverture de session, donc un utilisateur fraîchement ajouté y figure sans que le shell courant en dispose. L'essai dit ce que le shell peut FAIRE, la table dit ce qui a été DÉCLARÉ — et c'est l'écart entre les deux qui explique « je suis pourtant dans le groupe ». Le même fichier porte de quoi ATTEINDRE ces outils : le PATH à assainir pour qu'un outil de la distribution ne s'amorce pas sur le venv, et la commande qui installe un paquet manquant selon le gestionnaire de l'hôte. """ import grp import os import shutil import subprocess # Réponse du sondage, gardée pour la session : chaque commande du menu la # demande, et lancer un virsh par entrée de menu se verrait. _CACHE: bool | None = None PROBE = ["virsh", "--connect", "qemu:///system", "list", "--name"] def reset_cache() -> None: """Oublie le sondage. À appeler après un changement de droits.""" global _CACHE _CACHE = None def libvirt_reachable(force: bool = False) -> bool: """qemu:///system répond-il SANS sudo ? Rend faux quand virsh est absent : il n'y a alors rien à joindre, et le dire évite de conclure « il faut sudo » sur une machine sans libvirt. """ global _CACHE if _CACHE is not None and not force: return _CACHE if shutil.which("virsh") is None: _CACHE = False return _CACHE try: probe = subprocess.run( PROBE, capture_output=True, text=True, timeout=15 ) _CACHE = probe.returncode == 0 except (OSError, subprocess.SubprocessError): _CACHE = False return _CACHE def needs_sudo() -> bool: """Faut-il préfixer les commandes libvirt de « sudo » ? Root n'en a jamais besoin. Sans virsh, il n'y a rien à préfixer : rendre faux laisse la commande échouer sur « command not found » plutôt que sur une invite de mot de passe qui ne mène nulle part. """ if os.geteuid() == 0: return False if shutil.which("virsh") is None: return False return not libvirt_reachable() def sudo_prefix() -> str: """« sudo » et son espace, ou la chaîne vide. À coller devant virsh.""" return "sudo " if needs_sudo() else "" # L'URI ne se laisse JAMAIS implicite. Pour un utilisateur non root, libvirt # choisit « qemu:///session », un hyperviseur séparé où AUCUNE des VM du # système n'existe : « virsh list --all » y rend une liste vide, sans erreur # et sans avertissement. Appartenir au groupe libvirt donne le DROIT d'accéder # à qemu:///system, mais ne change pas l'URI par défaut. Tant que les # commandes passaient par sudo, l'URI de root masquait l'omission. LIBVIRT_URI = "qemu:///system" def virsh_argv(*args: str) -> list: """Argv d'un virsh local : sudo si besoin, URI toujours.""" prefixe = ["sudo"] if needs_sudo() else [] return prefixe + ["virsh", "--connect", LIBVIRT_URI, *args] def virsh_cmd(args: str = "") -> str: """Même chose, en chaîne pour un shell. « args » est déjà échappé.""" base = f"{sudo_prefix()}virsh --connect {LIBVIRT_URI}" return f"{base} {args}" if args else base def system_path(path: str = "") -> str: """PATH débarrassé des répertoires de venv du projet. TODO tourne DANS son venv, dont le « bin » est en TÊTE du PATH : chaque commande lancée par le menu le voit en premier, et ce répertoire contient un « python3 ». Un outil système écrit en Python et amorcé par « #!/usr/bin/env python3 » y trouve donc l'interpréteur du venv, où les modules fournis par la distribution — PyGObject, entre autres — n'existent pas, et l'import échoue sur un module que le système possède pourtant. Sous sudo le piège ne se voyait pas : sudo réinitialise le PATH par son « secure_path ». Le retirer là où il n'était pas nécessaire l'a mis au jour, et l'assainissement doit donc être explicite. """ path = path or os.environ.get("PATH", "") venv = os.environ.get("VIRTUAL_ENV", "") gardees = [] for entree in path.split(os.pathsep): if not entree: continue # Un répertoire du venv courant, ou de n'importe quel « .venv* » du # dépôt : les deux mènent au même interpréteur de trop. if venv and os.path.normpath(entree).startswith( os.path.normpath(venv) + os.sep ): continue if any( part.startswith(".venv") for part in os.path.normpath(entree).split(os.sep) ): continue gardees.append(entree) return os.pathsep.join(gardees) def system_env(env: dict | None = None) -> dict: """Environnement où un outil système trouve l'interpréteur système.""" base = dict(env or os.environ) base["PATH"] = system_path(base.get("PATH", "")) return base # (gestionnaire, binaire à détecter, commande d'installation). L'ordre est # celui de la détection : le premier binaire présent gagne. PKG_INSTALL = ( ("apt", "apt-get", "sudo apt-get install -y"), ("dnf", "dnf", "sudo dnf install -y"), ("pacman", "pacman", "sudo pacman -S --needed --noconfirm"), ("zypper", "zypper", "sudo zypper --non-interactive install"), ) def install_cmd_for(par_gestionnaire: dict) -> tuple: """(commande, paquet) pour cet hôte, ou (None, None). `par_gestionnaire` donne le nom du paquet POUR CHAQUE gestionnaire : ces noms diffèrent d'une distribution à l'autre, et celui de Debian ne marche presque jamais ailleurs. Rend None sur un hôte dont le gestionnaire n'est pas connu — proposer une commande qui échouera vaut moins que de dire qu'on ne sait pas. """ for cle, binaire, install in PKG_INSTALL: if shutil.which(binaire) and par_gestionnaire.get(cle): paquet = par_gestionnaire[cle] return f"{install} {paquet}", paquet return None, None def group_state(user: str = "") -> tuple[bool, bool]: """(déclaré, actif) pour le groupe libvirt. « déclaré » : le nom figure dans le groupe, d'après la base système. « actif » : le processus courant PORTE le groupe. Les deux diffèrent tant que la session n'a pas été rouverte, et c'est le cas qui déroute le plus. Les deux valent faux quand le groupe n'existe pas — libvirt pas encore installé. """ user = user or _current_user() try: entry = grp.getgrnam("libvirt") except KeyError: return False, False declared = user in entry.gr_mem try: declared = declared or grp.getgrgid(os.getgid()).gr_name == "libvirt" except (KeyError, OSError): pass return declared, entry.gr_gid in os.getgroups() def _current_user() -> str: """Le nom de l'utilisateur courant, sans lever si l'environnement ment.""" try: import getpass return getpass.getuser() except Exception: return os.environ.get("USER") or ""