[FIX] qemu menu : sortir le venv du PATH des outils système
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
This commit is contained in:
parent
f016eefce9
commit
12cef44991
4 changed files with 128 additions and 4 deletions
|
|
@ -16,6 +16,7 @@ from script.todo import todo_install
|
||||||
from script.todo.qemu_privilege import (
|
from script.todo.qemu_privilege import (
|
||||||
LIBVIRT_URI as URI,
|
LIBVIRT_URI as URI,
|
||||||
sudo_prefix,
|
sudo_prefix,
|
||||||
|
system_path,
|
||||||
virsh_argv,
|
virsh_argv,
|
||||||
)
|
)
|
||||||
from script.todo.todo_i18n import t
|
from script.todo.todo_i18n import t
|
||||||
|
|
@ -399,7 +400,14 @@ class QemuManageMixin:
|
||||||
shlex.quote(c) for c in entry["cmd"]
|
shlex.quote(c) for c in entry["cmd"]
|
||||||
)
|
)
|
||||||
print(f"\n{t('Will execute:')} {cmd}")
|
print(f"\n{t('Will execute:')} {cmd}")
|
||||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
# virt-xml est un script Python du système : sans PATH assaini, il
|
||||||
|
# s'amorce sur l'interpréteur du venv, où les modules de la
|
||||||
|
# distribution n'existent pas.
|
||||||
|
self.execute.exec_command_live(
|
||||||
|
cmd,
|
||||||
|
source_erplibre=False,
|
||||||
|
new_env={"PATH": system_path()},
|
||||||
|
)
|
||||||
|
|
||||||
def _qemu_hw_form(self, rows, node, nets=None):
|
def _qemu_hw_form(self, rows, node, nets=None):
|
||||||
"""Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour
|
"""Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,48 @@ def virsh_cmd(args: str = "") -> str:
|
||||||
return f"{base} {args}" if args else base
|
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]:
|
def group_state(user: str = "") -> tuple[bool, bool]:
|
||||||
"""(déclaré, actif) pour le groupe libvirt.
|
"""(déclaré, actif) pour le groupe libvirt.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from script.todo.qemu_privilege import system_env, system_path
|
||||||
from script.todo.todo_i18n import t
|
from script.todo.todo_i18n import t
|
||||||
|
|
||||||
# Le paquet qui apporte guestfish, par gestionnaire de paquets. Il ne vit pas
|
# Le paquet qui apporte guestfish, par gestionnaire de paquets. Il ne vit pas
|
||||||
|
|
@ -102,6 +103,7 @@ class QemuRecoverMixin:
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
|
env=system_env(),
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
return []
|
return []
|
||||||
|
|
@ -135,7 +137,9 @@ class QemuRecoverMixin:
|
||||||
input(t("Install libguestfs now? (Y/n): "))
|
input(t("Install libguestfs now? (Y/n): "))
|
||||||
):
|
):
|
||||||
return not manque_essentiel
|
return not manque_essentiel
|
||||||
self.execute.exec_command_live(install, source_erplibre=False)
|
self.execute.exec_command_live(
|
||||||
|
install, source_erplibre=False, new_env={"PATH": system_path()}
|
||||||
|
)
|
||||||
return shutil.which(GUESTFS_BIN_ESSENTIEL) is not None
|
return shutil.which(GUESTFS_BIN_ESSENTIEL) is not None
|
||||||
|
|
||||||
def _qemu_recover_ready(self, name):
|
def _qemu_recover_ready(self, name):
|
||||||
|
|
@ -239,7 +243,9 @@ class QemuRecoverMixin:
|
||||||
f"copy-out {shlex.quote(source)} {shlex.quote(dest)}",
|
f"copy-out {shlex.quote(source)} {shlex.quote(dest)}",
|
||||||
)
|
)
|
||||||
print(f"\n{t('Will execute:')} {cmd}")
|
print(f"\n{t('Will execute:')} {cmd}")
|
||||||
code = self.execute.exec_command_live(cmd, source_erplibre=False)
|
code = self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, new_env={"PATH": system_path()}
|
||||||
|
)
|
||||||
if code:
|
if code:
|
||||||
print(f" ⚠ {t('Extraction failed.')}")
|
print(f" ⚠ {t('Extraction failed.')}")
|
||||||
return False
|
return False
|
||||||
|
|
@ -274,7 +280,9 @@ class QemuRecoverMixin:
|
||||||
for titre, cmd in sondes:
|
for titre, cmd in sondes:
|
||||||
print(f"\n── {titre} ──")
|
print(f"\n── {titre} ──")
|
||||||
print(f"{t('Will execute:')} {cmd}")
|
print(f"{t('Will execute:')} {cmd}")
|
||||||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
self.execute.exec_command_live(
|
||||||
|
cmd, source_erplibre=False, new_env={"PATH": system_path()}
|
||||||
|
)
|
||||||
|
|
||||||
def _qemu_recover_files(self):
|
def _qemu_recover_files(self):
|
||||||
"""Récupère des fichiers dans le disque d'une VM, sans la démarrer."""
|
"""Récupère des fichiers dans le disque d'une VM, sans la démarrer."""
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ Ce que ces tests gardent :
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
|
|
@ -182,5 +183,70 @@ class LUriEstToujoursExplicite(unittest.TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LePathDesOutilsSysteme(unittest.TestCase):
|
||||||
|
"""TODO tourne dans son venv, dont le « bin » est en tête du PATH.
|
||||||
|
|
||||||
|
Ce répertoire contient un « python3 ». Un outil de la distribution amorcé
|
||||||
|
par « #!/usr/bin/env python3 » y trouve donc l'interpréteur du venv, où
|
||||||
|
les modules du système n'existent pas — l'import échoue sur un module que
|
||||||
|
la machine possède pourtant. Sous sudo le piège était invisible : sudo
|
||||||
|
réinitialise le PATH.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_the_project_venvs_are_dropped(self):
|
||||||
|
chemin = os.pathsep.join(
|
||||||
|
[
|
||||||
|
"/home/x/git/erplibre/.venv.erplibre/bin",
|
||||||
|
"/home/x/git/erplibre/.venv.odoo18.0_python3.12.10/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"/usr/bin",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
|
||||||
|
propre = qp.system_path(chemin)
|
||||||
|
self.assertNotIn(".venv", propre)
|
||||||
|
# L'ordre du reste ne bouge pas : il décide quel outil gagne.
|
||||||
|
self.assertEqual(["/usr/local/bin", "/usr/bin"], propre.split(":"))
|
||||||
|
|
||||||
|
def test_the_active_venv_is_dropped_even_without_the_name(self):
|
||||||
|
"""Un venv hors du dépôt ne porte pas « .venv » : VIRTUAL_ENV le
|
||||||
|
désigne, et c'est ce nom-là qui tranche."""
|
||||||
|
with mock.patch.dict(
|
||||||
|
qp.os.environ, {"VIRTUAL_ENV": "/opt/env"}, clear=False
|
||||||
|
):
|
||||||
|
propre = qp.system_path("/opt/env/bin:/usr/bin")
|
||||||
|
self.assertEqual("/usr/bin", propre)
|
||||||
|
|
||||||
|
def test_a_path_without_any_venv_is_untouched(self):
|
||||||
|
with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
|
||||||
|
self.assertEqual(
|
||||||
|
"/usr/local/bin:/usr/bin",
|
||||||
|
qp.system_path("/usr/local/bin:/usr/bin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_entries_do_not_become_the_current_directory(self):
|
||||||
|
"""Une entrée vide dans PATH signifie « le répertoire courant » : la
|
||||||
|
recopier ferait chercher un outil système là où on se trouve."""
|
||||||
|
with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
|
||||||
|
self.assertEqual("/usr/bin", qp.system_path("/usr/bin::"))
|
||||||
|
|
||||||
|
def test_the_env_carries_the_cleaned_path(self):
|
||||||
|
with mock.patch.dict(
|
||||||
|
qp.os.environ,
|
||||||
|
{"VIRTUAL_ENV": "/opt/env", "PATH": "/opt/env/bin:/usr/bin"},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
env = qp.system_env()
|
||||||
|
self.assertEqual("/usr/bin", env["PATH"])
|
||||||
|
|
||||||
|
def test_the_hardware_plan_uses_it(self):
|
||||||
|
"""virt-xml est un script Python du système : sans PATH assaini, il
|
||||||
|
s'amorce sur l'interpréteur du venv."""
|
||||||
|
source = Path("script/todo/qemu_manage.py").read_text(encoding="utf-8")
|
||||||
|
debut = source.index("def _qemu_adjust_hardware")
|
||||||
|
corps = source[debut : source.index("\n def ", debut + 10)]
|
||||||
|
self.assertIn("system_path()", corps)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue