Le « bin » du venv est en tête du PATH de chaque commande lancée par le menu, et il contient un python3. Un outil système écrit en Python et amorcé par « env python3 » s'y amorce donc, dans un interpréteur où les modules de la distribution n'existent pas : l'import échoue sur un module que la machine possède pourtant. Sous sudo le piège était invisible, sudo réinitialisant le PATH ; le retirer là où il ne servait plus l'a mis au jour. Vérifié : 6 tests, rougis par trois mutations. La ligne d'amorçage des outils visés n'a pas été inspectée : le mécanisme est démontré, pas qu'il soit la cause sur un hôte donné. --- EN --- The venv's « bin » leads the PATH of every command the menu launches, and it holds a python3. A system tool written in Python and started through « env python3 » therefore boots on that interpreter, where the distribution's modules do not exist: the import fails on a module the machine does have. Under sudo the trap was invisible, sudo resetting the PATH; removing it where it was no longer needed brought it out. Checked: 6 tests, turned red by three mutations. The shebang of the tools concerned was not inspected: the mechanism is demonstrated, not that it is the cause on any given host. Assisted-by: Claude Opus 5
167 lines
6 KiB
Python
167 lines
6 KiB
Python
#!/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 ».
|
|
"""
|
|
|
|
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
|
|
|
|
|
|
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 ""
|