[ADD] qemu diagnostic : un relevé à transmettre, Gérer scindé en trois
Un problème de VM se résout souvent par quelqu'un qui n'a pas accès à la machine : vingt et une sondes en lecture écrivent un relevé unique — hôte, hyperviseur, GPU, outils, stockage — chacune bornée dans le temps, une commande qui pend ne devant pas retenir le rapport. Sur le pilote NVIDIA propriétaire, trois conditions vivent hors des briques Mesa et QEMU, dont la liste de périphériques où libvirt n'ajoute jamais les nœuds de la carte : le relevé les nomme et propose la commande qui installe ce qui lui manque, affichée avant la question. Gérer se scinde en trois. --- EN --- A VM problem is often solved by someone with no access to the machine: twenty-one read-only probes write one report — host, hypervisor, GPU, tools, storage — each time-bounded, since a command that hangs must not hold the report. On the proprietary NVIDIA driver three conditions live outside the Mesa and QEMU pieces, among them the device list where libvirt never adds the card's own nodes: the report names them and offers the command installing what it lacks, shown before the question is put. The Manage menu splits in three. Assisted-by: Claude Opus 5
This commit is contained in:
parent
416ed42448
commit
625288fd4c
7 changed files with 767 additions and 40 deletions
|
|
@ -400,6 +400,278 @@ class QemuManageMixin:
|
|||
print(f"\n {t('Check EGL itself on that node:')}")
|
||||
print(" eglinfo -B 2>&1 | head -20")
|
||||
print(" lspci -nnk | grep -A3 -iE 'vga|3d|display'")
|
||||
self._qemu_nvidia_acl_advice()
|
||||
|
||||
# Les relevés du diagnostic : (titre, commande). Tous en LECTURE — un
|
||||
# rapport qui modifie l'hôte n'est plus un rapport, et celui-ci est fait
|
||||
# pour être envoyé à quelqu'un qui n'a pas accès à la machine.
|
||||
#
|
||||
# « head -1 » sur virt-xml n'est pas une curiosité : la ligne d'amorçage
|
||||
# dit quel interpréteur le porte, et c'est ce qui distingue un module
|
||||
# système absent d'un venv qui capture la commande.
|
||||
_DIAG_PROBES = (
|
||||
("uname", "uname -a"),
|
||||
("os-release", "cat /etc/os-release 2>/dev/null | head -5"),
|
||||
("virtualisation", "systemd-detect-virt || true"),
|
||||
("kvm", "ls -l /dev/kvm 2>&1"),
|
||||
("dri", "ls -l /dev/dri/ 2>&1"),
|
||||
("gpu", "lspci -nnk 2>/dev/null | grep -A3 -iE 'vga|3d|display'"),
|
||||
("egl", "eglinfo -B 2>&1 | head -20"),
|
||||
("virsh", "virsh --connect qemu:///system version 2>&1"),
|
||||
("domaines", "virsh --connect qemu:///system list --all 2>&1"),
|
||||
("réseaux", "virsh --connect qemu:///system net-list --all 2>&1"),
|
||||
(
|
||||
"outils",
|
||||
"command -v virsh virt-install virt-xml qemu-img"
|
||||
" guestfish genisoimage cloud-localds 2>&1",
|
||||
),
|
||||
("virt-xml", "head -1 $(command -v virt-xml) 2>&1"),
|
||||
(
|
||||
"python",
|
||||
"command -v python3; python3 -c 'import sys;"
|
||||
" print(sys.executable)' 2>&1",
|
||||
),
|
||||
("modules qemu", "ls /usr/lib/qemu/ 2>/dev/null | head -30"),
|
||||
# NVIDIA propriétaire : trois conditions que Mesa seul ne donne pas.
|
||||
# « modeset » à N prive GBM du pilote, le greffon GBM de NVIDIA vit
|
||||
# hors des chemins de Mesa, et QEMU doit pouvoir ouvrir /dev/nvidia*
|
||||
# — que libvirt n'ajoute PAS d'office à la liste des périphériques
|
||||
# autorisés, même quand il y ajoute le nœud de rendu.
|
||||
# Ce paramètre est lisible par root SEUL : sans sudo, la sonde ne
|
||||
# rapporte qu'un refus de permission, qui n'apprend rien. « sudo -n »
|
||||
# ne demande jamais de mot de passe — il échoue plutôt que de bloquer
|
||||
# un relevé, et le repli dit alors ce qui manque.
|
||||
(
|
||||
"nvidia modeset",
|
||||
"sudo -n cat /sys/module/nvidia_drm/parameters/modeset 2>/dev/null"
|
||||
" || echo '(lisible par root seul : sudo cat"
|
||||
" /sys/module/nvidia_drm/parameters/modeset)'",
|
||||
),
|
||||
("nvidia devices", "ls -l /dev/nvidia* 2>&1"),
|
||||
("gbm backends", "ls -l /usr/lib/gbm/ 2>&1"),
|
||||
# Une sortie vide est ambiguë — fichier absent, ou toutes les clés
|
||||
# en commentaire ? Le dire, puisque « rien de réglé » signifie que
|
||||
# les défauts de libvirt s'appliquent, et c'est une information.
|
||||
(
|
||||
"libvirt qemu.conf",
|
||||
"grep -nE '^[^#]*(user|group|cgroup_device_acl|namespaces)'"
|
||||
" /etc/libvirt/qemu.conf 2>&1 | head -20"
|
||||
" || echo '(aucune clé active : défauts de libvirt)'",
|
||||
),
|
||||
(
|
||||
"utilisateur des VM",
|
||||
"ps -o user=,comm= -C qemu-system-x86_64 2>&1 | sort -u",
|
||||
),
|
||||
("stockage", "df -h /var/lib/libvirt/images 2>&1"),
|
||||
("groupes", "id"),
|
||||
)
|
||||
|
||||
# Ce qui manque au relevé quand un outil est absent. « eglinfo » est le
|
||||
# seul qui éprouve EGL pour de vrai : sans lui, le rapport dit ce qui est
|
||||
# installé, jamais si ça démarre — et c'est justement la question.
|
||||
_DIAG_TOOLS = (
|
||||
(
|
||||
"eglinfo",
|
||||
{
|
||||
"apt": "mesa-utils",
|
||||
"dnf": "mesa-demos",
|
||||
"pacman": "mesa-utils",
|
||||
"zypper": "Mesa-demo-egl",
|
||||
},
|
||||
),
|
||||
(
|
||||
"lspci",
|
||||
{
|
||||
"apt": "pciutils",
|
||||
"dnf": "pciutils",
|
||||
"pacman": "pciutils",
|
||||
"zypper": "pciutils",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def _qemu_diag_offer_tools(self):
|
||||
"""Propose d'installer les outils dont le relevé manque. Rend un bool
|
||||
disant si quelque chose a été installé.
|
||||
|
||||
Proposé et non imposé : un diagnostic qui pose des paquets sans
|
||||
demander n'est plus un diagnostic.
|
||||
"""
|
||||
from script.todo.qemu_privilege import install_cmd_for
|
||||
|
||||
manquants = [
|
||||
(binaire, paquets)
|
||||
for binaire, paquets in self._DIAG_TOOLS
|
||||
if shutil.which(binaire) is None
|
||||
]
|
||||
if not manquants:
|
||||
return False
|
||||
print(f"\n {t('These tools would complete the report:')}")
|
||||
commandes = []
|
||||
for binaire, paquets in manquants:
|
||||
cmd, paquet = install_cmd_for(paquets)
|
||||
if cmd:
|
||||
print(f" {binaire:<10} {t('package')} {paquet}")
|
||||
commandes.append(cmd)
|
||||
else:
|
||||
print(f" {binaire:<10} {t('unknown package manager')}")
|
||||
if not commandes:
|
||||
return False
|
||||
# La commande est annoncée EN ENTIER avant la question, sudo compris :
|
||||
# « les installer ? » ne dit ni ce qui sera lancé, ni avec quels
|
||||
# droits, et c'est ce qu'on approuve.
|
||||
print(f"\n {t('Will run, as root:')}")
|
||||
for cmd in commandes:
|
||||
print(f" {cmd}")
|
||||
try:
|
||||
reponse = input(t("Install them now? (y/N): "))
|
||||
except EOFError:
|
||||
# Sans terminal — un lancement scripté — la question n'a personne
|
||||
# pour y répondre. Ne rien installer, et laisser le rapport, qui
|
||||
# est déjà écrit, faire son travail.
|
||||
return False
|
||||
if not self._is_yes(reponse):
|
||||
return False
|
||||
for cmd in commandes:
|
||||
print(f"\n{t('Will execute:')} {cmd}")
|
||||
self.execute.exec_command_live(
|
||||
cmd,
|
||||
source_erplibre=False,
|
||||
new_env={"PATH": system_path()},
|
||||
)
|
||||
return True
|
||||
|
||||
# Ce que libvirt autorise par défaut. La liste sert de BASE à la
|
||||
# proposition : la compléter suppose de la reprendre en entier, car la
|
||||
# clé remplace le défaut au lieu de s'y ajouter.
|
||||
_ACL_BASE = (
|
||||
"/dev/null",
|
||||
"/dev/full",
|
||||
"/dev/zero",
|
||||
"/dev/random",
|
||||
"/dev/urandom",
|
||||
"/dev/ptmx",
|
||||
"/dev/kvm",
|
||||
)
|
||||
_QEMU_CONF = "/etc/libvirt/qemu.conf"
|
||||
|
||||
@staticmethod
|
||||
def _qemu_nvidia_nodes():
|
||||
"""Nœuds de la carte NVIDIA présents sur l'hôte, triés.
|
||||
|
||||
Le répertoire /dev/nvidia-caps est écarté : ce sont des capacités MIG,
|
||||
que la pile EGL n'ouvre pas, et les lister ferait proposer plus large
|
||||
que nécessaire.
|
||||
"""
|
||||
return sorted(
|
||||
n
|
||||
for n in glob.glob("/dev/nvidia*")
|
||||
if os.path.exists(n) and not os.path.isdir(n)
|
||||
)
|
||||
|
||||
def _qemu_acl_active(self):
|
||||
"""Le contenu ACTIF de cgroup_device_acl, ou None s'il n'y en a pas.
|
||||
|
||||
Rend None aussi quand le fichier est illisible : ne pas pouvoir lire
|
||||
n'est pas la même chose que savoir qu'il n'y a rien, et la proposition
|
||||
le dit plutôt que de conclure.
|
||||
"""
|
||||
try:
|
||||
with open(self._QEMU_CONF, encoding="utf-8") as fh:
|
||||
lignes = [ln for ln in fh if not ln.lstrip().startswith("#")]
|
||||
except OSError:
|
||||
return None
|
||||
texte = "".join(lignes)
|
||||
if "cgroup_device_acl" not in texte:
|
||||
return ""
|
||||
return texte[texte.index("cgroup_device_acl") :]
|
||||
|
||||
def _qemu_nvidia_acl_advice(self):
|
||||
"""Propose la liste de périphériques quand elle explique le blocage.
|
||||
|
||||
Pertinent seulement si l'hôte porte une carte NVIDIA propriétaire ET
|
||||
que sa liste ne nomme pas ces nœuds : libvirt y ajoute le nœud de
|
||||
rendu quand le domaine le déclare, jamais ceux de la carte, et la
|
||||
pile propriétaire ouvre les deux. Rend un bool disant s'il a parlé.
|
||||
"""
|
||||
nodes = self._qemu_nvidia_nodes()
|
||||
if not nodes:
|
||||
return False
|
||||
actif = self._qemu_acl_active()
|
||||
if actif and all(n in actif for n in nodes):
|
||||
return False
|
||||
render = self._qemu_host_gpu_node()
|
||||
entrees = list(self._ACL_BASE) + ([render] if render else []) + nodes
|
||||
print(f"\n── {t('Devices QEMU may open')} ──")
|
||||
if actif is None:
|
||||
print(f" {t('Unreadable, so this may already be set:')}")
|
||||
print(f" sudo grep -n cgroup_device_acl {self._QEMU_CONF}")
|
||||
elif actif:
|
||||
print(f" ⚠ {t('A list exists: ADD to it, never replace it.')}")
|
||||
print(f" {t('In')} {self._QEMU_CONF} :")
|
||||
print(" cgroup_device_acl = [")
|
||||
for i in range(0, len(entrees), 3):
|
||||
bout = ", ".join(f'"{e}"' for e in entrees[i : i + 3])
|
||||
print(f" {bout},")
|
||||
print(" ]")
|
||||
print(f"\n {t('Then restart the daemon and recreate the VM:')}")
|
||||
print(" systemctl is-active libvirtd virtqemud")
|
||||
print(" sudo systemctl restart libvirtd")
|
||||
print(f" {t('then stop and start the VM (a guest reboot is not')}")
|
||||
print(f" {t('enough: the list applies when QEMU is launched).')}")
|
||||
return True
|
||||
|
||||
def _qemu_diagnostics(self):
|
||||
"""Relevé complet de l'hôte, écrit dans un fichier à transmettre.
|
||||
|
||||
Chaque sonde est bornée dans le temps : une commande qui pend ne doit
|
||||
pas retenir le rapport, et son absence est elle-même une information.
|
||||
"""
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
dossier = os.path.expanduser("~/.erplibre")
|
||||
os.makedirs(dossier, exist_ok=True)
|
||||
horo = time.strftime("%Y%m%d-%H%M%S")
|
||||
chemin = os.path.join(dossier, f"qemu-diagnostic-{horo}.log")
|
||||
morceaux = [f"# ERPLibre — diagnostic QEMU — {horo}\n"]
|
||||
print(f"\n🩺 {t('Collecting the diagnostics...')}")
|
||||
for titre, cmd in self._DIAG_PROBES:
|
||||
print(f" … {titre}")
|
||||
morceaux.append(f"\n===== {titre} =====\n$ {cmd}\n")
|
||||
try:
|
||||
res = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=self._qemu_c_env(),
|
||||
)
|
||||
morceaux.append((res.stdout or "") + (res.stderr or ""))
|
||||
except subprocess.TimeoutExpired:
|
||||
morceaux.append("(timeout)\n")
|
||||
except OSError as exc:
|
||||
morceaux.append(f"({exc})\n")
|
||||
tampon = io.StringIO()
|
||||
with redirect_stdout(tampon):
|
||||
self._qemu_gpu_3d_report()
|
||||
morceaux.append("\n===== 3D =====\n" + tampon.getvalue())
|
||||
try:
|
||||
with open(chemin, "w", encoding="utf-8") as fh:
|
||||
fh.write("".join(morceaux))
|
||||
except OSError as exc:
|
||||
print(f" ⚠ {t('Cannot write the report: ')}{exc}")
|
||||
return
|
||||
print(f"\n✅ {t('Report written to: ')}{chemin}")
|
||||
# Le rapport porte le nom de la machine, des chemins de compte et des
|
||||
# adresses : le dire, puisqu'il est fait pour être transmis.
|
||||
print(f" {t('It names this host, its paths and its addresses.')}")
|
||||
print(f" {t('Read it before sharing it.')}")
|
||||
# Après l'écriture : le rapport existe même si l'on refuse, et une
|
||||
# installation acceptée invite à le refaire, plus complet.
|
||||
if self._qemu_diag_offer_tools():
|
||||
print(f"\n {t('Run the diagnostics again for a fuller report.')}")
|
||||
|
||||
def _qemu_egl_failed(self, sortie):
|
||||
"""La sortie accuse-t-elle un EGL inutilisable ?
|
||||
|
|
|
|||
|
|
@ -325,24 +325,16 @@ class QemuMenuMixin:
|
|||
"Reopen install monitoring (last run / history)"
|
||||
)
|
||||
},
|
||||
# Douze entrées sous un seul titre ne se lisent plus : on cherche
|
||||
# à la ligne près. Trois intentions les séparent — vivre avec ses
|
||||
# VM, y entrer, réparer quand ça va mal.
|
||||
{"section": t("Manage")},
|
||||
{"prompt_description": t("List VMs (virsh list --all)")},
|
||||
{"prompt_description": t("Show a VM IP address")},
|
||||
{"prompt_description": t("Open the console on a VM")},
|
||||
{"prompt_description": t("Resize a VM disk")},
|
||||
{"prompt_description": t("Delete VM(s)")},
|
||||
{"prompt_description": t("Clean up QEMU (orphan files)")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Recover files from a VM disk (libguestfs)"
|
||||
)
|
||||
},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Test a VM (open Odoo in a CLI browser)"
|
||||
)
|
||||
},
|
||||
{"prompt_description": t("Statistics (installs, durations, VMs)")},
|
||||
{"section": t("VM access")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"SSH configuration (~/.ssh/config, ProxyJump)"
|
||||
|
|
@ -358,6 +350,20 @@ class QemuMenuMixin:
|
|||
"Android emulator (start, tunnel, scrcpy)"
|
||||
)
|
||||
},
|
||||
{"section": t("Troubleshoot")},
|
||||
{"prompt_description": t("Clean up QEMU (orphan files)")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Recover files from a VM disk (libguestfs)"
|
||||
)
|
||||
},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Test a VM (open Odoo in a CLI browser)"
|
||||
)
|
||||
},
|
||||
{"prompt_description": t("Diagnostics (report to share)")},
|
||||
{"prompt_description": t("Statistics (installs, durations, VMs)")},
|
||||
{"section": t("Catalog")},
|
||||
{"prompt_description": t("List available images and specs")},
|
||||
]
|
||||
|
|
@ -390,20 +396,22 @@ class QemuMenuMixin:
|
|||
elif status == "9":
|
||||
self._qemu_delete_vm()
|
||||
elif status == "10":
|
||||
self._qemu_cleanup()
|
||||
elif status == "11":
|
||||
self._qemu_recover_files()
|
||||
elif status == "12":
|
||||
self._qemu_test_vm()
|
||||
elif status == "13":
|
||||
self._qemu_stats()
|
||||
elif status == "14":
|
||||
self._qemu_ssh_config_menu()
|
||||
elif status == "15":
|
||||
elif status == "11":
|
||||
self._qemu_tunnel_menu()
|
||||
elif status == "16":
|
||||
elif status == "12":
|
||||
self._qemu_emulator_menu()
|
||||
elif status == "13":
|
||||
self._qemu_cleanup()
|
||||
elif status == "14":
|
||||
self._qemu_recover_files()
|
||||
elif status == "15":
|
||||
self._qemu_test_vm()
|
||||
elif status == "16":
|
||||
self._qemu_diagnostics()
|
||||
elif status == "17":
|
||||
self._qemu_stats()
|
||||
elif status == "18":
|
||||
self._qemu_list_images()
|
||||
else:
|
||||
cmd_no_found = True
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ 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
|
||||
|
|
@ -135,6 +139,32 @@ def system_env(env: dict | None = None) -> dict:
|
|||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ import shlex
|
|||
import shutil
|
||||
import subprocess
|
||||
|
||||
from script.todo.qemu_privilege import system_env, system_path
|
||||
from script.todo.qemu_privilege import (
|
||||
install_cmd_for,
|
||||
system_env,
|
||||
system_path,
|
||||
)
|
||||
from script.todo.todo_i18n import t
|
||||
|
||||
# Le paquet qui apporte guestfish, par gestionnaire de paquets. Il ne vit pas
|
||||
|
|
@ -35,14 +39,6 @@ GUESTFS_PACKAGES = {
|
|||
"zypper": "guestfs-tools",
|
||||
}
|
||||
|
||||
# (gestionnaire, binaire à détecter, commande d'installation)
|
||||
GUESTFS_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"),
|
||||
)
|
||||
|
||||
# guestfish suffit à tout faire ; les deux autres ne font que présenter mieux
|
||||
# ce qu'il montre déjà. Les distinguer évite d'exiger un paquet complet là où
|
||||
# l'essentiel est présent.
|
||||
|
|
@ -53,14 +49,10 @@ GUESTFS_BIN_CONFORT = ("virt-filesystems", "virt-df")
|
|||
def guestfs_install_cmd():
|
||||
"""(commande d'installation, paquet) pour cet hôte, ou (None, None).
|
||||
|
||||
Rend None sur un hôte dont le gestionnaire n'est pas connu — y compris
|
||||
macOS, où libguestfs ne tourne pas : proposer une commande qui échouera
|
||||
vaut moins que de dire qu'on ne sait pas.
|
||||
macOS y rend None comme tout hôte au gestionnaire inconnu, et c'est juste :
|
||||
libguestfs n'y tourne pas.
|
||||
"""
|
||||
for cle, binaire, install in GUESTFS_INSTALL:
|
||||
if shutil.which(binaire):
|
||||
return f"{install} {GUESTFS_PACKAGES[cle]}", GUESTFS_PACKAGES[cle]
|
||||
return None, None
|
||||
return install_cmd_for(GUESTFS_PACKAGES)
|
||||
|
||||
|
||||
class QemuRecoverMixin:
|
||||
|
|
|
|||
|
|
@ -5140,6 +5140,90 @@ TRANSLATIONS = {
|
|||
"fr": "Vérifier EGL lui-même sur ce nœud :",
|
||||
"en": "Check EGL itself on that node:",
|
||||
},
|
||||
"VM access": {
|
||||
"fr": "🔌 Accès aux VM",
|
||||
"en": "🔌 VM access",
|
||||
},
|
||||
"Troubleshoot": {
|
||||
"fr": "🩺 Dépanner",
|
||||
"en": "🩺 Troubleshoot",
|
||||
},
|
||||
"Diagnostics (report to share)": {
|
||||
"fr": "🩺 Diagnostic (rapport à transmettre)",
|
||||
"en": "🩺 Diagnostics (report to share)",
|
||||
},
|
||||
"Collecting the diagnostics...": {
|
||||
"fr": "Relevé du diagnostic en cours…",
|
||||
"en": "Collecting the diagnostics...",
|
||||
},
|
||||
"Cannot write the report: ": {
|
||||
"fr": "Impossible d'écrire le rapport : ",
|
||||
"en": "Cannot write the report: ",
|
||||
},
|
||||
"Report written to: ": {
|
||||
"fr": "Rapport écrit dans : ",
|
||||
"en": "Report written to: ",
|
||||
},
|
||||
"It names this host, its paths and its addresses.": {
|
||||
"fr": "Il nomme cette machine, ses chemins et ses adresses.",
|
||||
"en": "It names this host, its paths and its addresses.",
|
||||
},
|
||||
"Read it before sharing it.": {
|
||||
"fr": "Relisez-le avant de le transmettre.",
|
||||
"en": "Read it before sharing it.",
|
||||
},
|
||||
"These tools would complete the report:": {
|
||||
"fr": "Ces outils compléteraient le rapport :",
|
||||
"en": "These tools would complete the report:",
|
||||
},
|
||||
"unknown package manager": {
|
||||
"fr": "gestionnaire de paquets inconnu",
|
||||
"en": "unknown package manager",
|
||||
},
|
||||
"Install them now? (y/N): ": {
|
||||
"fr": "Les installer maintenant ? (o/N) : ",
|
||||
"en": "Install them now? (y/N, default: no): ",
|
||||
},
|
||||
"Run the diagnostics again for a fuller report.": {
|
||||
"fr": "Relancez le diagnostic pour un rapport plus complet.",
|
||||
"en": "Run the diagnostics again for a fuller report.",
|
||||
},
|
||||
"Devices QEMU may open": {
|
||||
"fr": "Périphériques que QEMU peut ouvrir",
|
||||
"en": "Devices QEMU may open",
|
||||
},
|
||||
"Unreadable, so this may already be set:": {
|
||||
"fr": "Illisible, donc peut-être déjà réglé :",
|
||||
"en": "Unreadable, so this may already be set:",
|
||||
},
|
||||
"A list exists: ADD to it, never replace it.": {
|
||||
"fr": "Une liste existe : y AJOUTER, ne jamais la remplacer.",
|
||||
"en": "A list exists: ADD to it, never replace it.",
|
||||
},
|
||||
"In": {
|
||||
"fr": "Dans",
|
||||
"en": "In",
|
||||
},
|
||||
"Then restart the daemon and recreate the VM:": {
|
||||
"fr": "Puis redémarrer le démon et recréer la VM :",
|
||||
"en": "Then restart the daemon and recreate the VM:",
|
||||
},
|
||||
"then stop and start the VM (a guest reboot is not": {
|
||||
"fr": "puis éteindre et rallumer la VM (un redémarrage de",
|
||||
"en": "then stop and start the VM (a guest reboot is not",
|
||||
},
|
||||
"enough: the list applies when QEMU is launched).": {
|
||||
"fr": "l'invité ne suffit pas : la liste s'applique au lancement).",
|
||||
"en": "enough: the list applies when QEMU is launched).",
|
||||
},
|
||||
"package": {
|
||||
"fr": "paquet",
|
||||
"en": "package",
|
||||
},
|
||||
"Will run, as root:": {
|
||||
"fr": "Va exécuter, en root :",
|
||||
"en": "Will run, as root:",
|
||||
},
|
||||
"Full output:": {
|
||||
"fr": "Sortie complète :",
|
||||
"en": "Full output:",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ Ce que ces tests gardent :
|
|||
shell.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import unittest
|
||||
|
|
@ -212,7 +213,8 @@ class LeMenu(unittest.TestCase):
|
|||
def test_the_config_entry_still_reaches_its_command(self):
|
||||
"""L'entrée du catalogue vient de todo.json : elle n'a pas de branche
|
||||
« elif » et dépend du repli par indice. Insérer une entrée codée en
|
||||
dur la décale, et un décalage manqué lancerait la mauvaise."""
|
||||
dur la décale, et un décalage manqué lancerait la mauvaise. Le numéro
|
||||
se lit sur le menu rendu plutôt qu'écrit ici en dur."""
|
||||
from unittest import mock as m
|
||||
|
||||
from script.todo.todo_i18n import set_lang
|
||||
|
|
@ -222,10 +224,21 @@ class LeMenu(unittest.TestCase):
|
|||
todo._menu_header = lambda: "x"
|
||||
lancees = []
|
||||
todo.execute_from_configuration = lambda e: lancees.append(e)
|
||||
# Le numéro se DÉDUIT du menu : l'entrée de config est la dernière
|
||||
# des entrées numérotées. L'écrire en dur ferait passer le test au
|
||||
# premier réarrangement de sections, sans rien prouver.
|
||||
with m.patch("script.todo.qemu_menu.click") as click, m.patch.object(
|
||||
todo, "_qemu_ensure_tools", return_value=True
|
||||
), m.patch("builtins.print"):
|
||||
click.prompt.side_effect = ["18", "0"]
|
||||
click.prompt.side_effect = ["0"]
|
||||
todo.prompt_execute_qemu()
|
||||
aide = click.prompt.call_args[0][0]
|
||||
numeros = re.findall(r"^\[(\d+)\]", aide, re.M)
|
||||
dernier = max(int(n) for n in numeros)
|
||||
with m.patch("script.todo.qemu_menu.click") as click, m.patch.object(
|
||||
todo, "_qemu_ensure_tools", return_value=True
|
||||
), m.patch("builtins.print"):
|
||||
click.prompt.side_effect = [str(dernier), "0"]
|
||||
todo.prompt_execute_qemu()
|
||||
self.assertEqual(1, len(lancees), lancees)
|
||||
self.assertIn("dry-run", lancees[0].get("bash_command", ""))
|
||||
|
|
|
|||
|
|
@ -17,13 +17,19 @@ Ce que ces tests gardent :
|
|||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo import qemu_hardware as hw # noqa: E402
|
||||
from script.todo import qemu_manage as qm # noqa: E402
|
||||
from script.todo.todo import TODO # noqa: E402
|
||||
|
||||
SORTIE_EGL = (
|
||||
|
|
@ -171,6 +177,328 @@ class LeRapport3D(unittest.TestCase):
|
|||
self.todo.execute.exec_command_live.assert_not_called()
|
||||
|
||||
|
||||
class LeDiagnostic(unittest.TestCase):
|
||||
"""Un relevé destiné à quelqu'un qui n'a pas accès à la machine.
|
||||
|
||||
Il doit donc être complet, ne RIEN modifier, et dire à celui qui l'envoie
|
||||
ce qu'il contient — un rapport de machine porte son nom, ses chemins de
|
||||
compte et ses adresses.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.todo = TODO.__new__(TODO)
|
||||
self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128"
|
||||
self.todo._qemu_lib_present = lambda motif: ""
|
||||
# La proposition d'outils a ses propres tests : ici elle ne ferait
|
||||
# qu'attendre une réponse que personne ne donne.
|
||||
self.todo._qemu_diag_offer_tools = lambda: False
|
||||
self.lances = []
|
||||
|
||||
def _lancer(self, tmp):
|
||||
def faux_run(cmd, **k):
|
||||
self.lances.append(cmd)
|
||||
|
||||
class R:
|
||||
stdout = f"sortie de {cmd}\n"
|
||||
stderr = ""
|
||||
|
||||
return R()
|
||||
|
||||
with mock.patch.object(
|
||||
__import__("script.todo.qemu_manage", fromlist=["x"]).subprocess,
|
||||
"run",
|
||||
side_effect=faux_run,
|
||||
), mock.patch.object(
|
||||
os.path, "expanduser", return_value=tmp
|
||||
), mock.patch(
|
||||
"builtins.print"
|
||||
):
|
||||
self.todo._qemu_diagnostics()
|
||||
return sorted(os.listdir(tmp))
|
||||
|
||||
def test_it_writes_one_readable_report(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
fichiers = self._lancer(tmp)
|
||||
self.assertEqual(1, len(fichiers), fichiers)
|
||||
self.assertTrue(fichiers[0].startswith("qemu-diagnostic-"))
|
||||
contenu = Path(tmp, fichiers[0]).read_text(encoding="utf-8")
|
||||
# Les quatre familles qui décident d'un problème QEMU : la machine,
|
||||
# l'hyperviseur, le GPU, et l'interpréteur qui porte virt-xml.
|
||||
for attendu in ("uname", "virsh", "dri", "virt-xml", "3D"):
|
||||
self.assertIn(attendu, contenu)
|
||||
|
||||
# Programmes qui ne peuvent que LIRE. « command -v » cherche un outil
|
||||
# sans le lancer : la présence du mot « virt-install » dans une sonde ne
|
||||
# dit donc rien, seul le programme en tête de commande compte.
|
||||
LECTURE_SEULE = {
|
||||
"uname",
|
||||
"cat",
|
||||
"systemd-detect-virt",
|
||||
"ls",
|
||||
"lspci",
|
||||
"eglinfo",
|
||||
"command",
|
||||
"head",
|
||||
"df",
|
||||
"id",
|
||||
"true",
|
||||
"grep",
|
||||
"ps",
|
||||
"sort",
|
||||
"echo",
|
||||
}
|
||||
VIRSH_LECTURE = {"version", "list", "net-list"}
|
||||
|
||||
def test_every_probe_is_read_only(self):
|
||||
"""Un rapport qui modifie l'hôte n'est plus un rapport."""
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self._lancer(tmp)
|
||||
operateurs = {";", "|", "||", "&&"}
|
||||
for cmd in self.lances:
|
||||
# shlex plutôt qu'un découpage sur « | » : le motif de grep en
|
||||
# contient un, et le couper au milieu ferait juger « 3d » comme
|
||||
# s'il était un programme.
|
||||
morceau = []
|
||||
for jeton in shlex.split(cmd) + [";"]:
|
||||
if jeton not in operateurs:
|
||||
morceau.append(jeton)
|
||||
continue
|
||||
if morceau:
|
||||
self._juger(morceau, cmd)
|
||||
morceau = []
|
||||
|
||||
def _juger(self, jetons, cmd):
|
||||
"""Un morceau de commande ne doit que lire.
|
||||
|
||||
« sudo » n'est qu'un préfixe : l'admettre en tête ouvrirait la porte
|
||||
à tout. On le retire et on juge le programme qu'il porte.
|
||||
"""
|
||||
while jetons and (
|
||||
os.path.basename(jetons[0]) == "sudo" or jetons[0].startswith("-")
|
||||
):
|
||||
jetons = jetons[1:]
|
||||
if not jetons:
|
||||
return
|
||||
tete = os.path.basename(jetons[0])
|
||||
if tete == "python3":
|
||||
return
|
||||
if tete == "virsh":
|
||||
sous = [
|
||||
j
|
||||
for j in jetons[1:]
|
||||
if not j.startswith("-") and "://" not in j
|
||||
]
|
||||
self.assertIn(sous[0], self.VIRSH_LECTURE, cmd)
|
||||
return
|
||||
self.assertIn(tete, self.LECTURE_SEULE, cmd)
|
||||
|
||||
def test_a_hanging_probe_does_not_hold_the_report(self):
|
||||
"""Une commande qui pend ne doit pas retenir le rapport : son absence
|
||||
de réponse est elle-même une information."""
|
||||
import subprocess as sp
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(
|
||||
__import__(
|
||||
"script.todo.qemu_manage", fromlist=["x"]
|
||||
).subprocess,
|
||||
"run",
|
||||
side_effect=sp.TimeoutExpired("x", 30),
|
||||
), mock.patch.object(
|
||||
os.path, "expanduser", return_value=tmp
|
||||
), mock.patch(
|
||||
"builtins.print"
|
||||
):
|
||||
self.todo._qemu_diagnostics()
|
||||
contenu = Path(tmp, os.listdir(tmp)[0]).read_text(encoding="utf-8")
|
||||
self.assertIn("(timeout)", contenu)
|
||||
|
||||
|
||||
class LesOutilsDuRapport(unittest.TestCase):
|
||||
"""eglinfo est le seul qui ÉPROUVE EGL : sans lui le rapport dit ce qui
|
||||
est installé, jamais si ça démarre — et c'est la question posée."""
|
||||
|
||||
def setUp(self):
|
||||
self.todo = TODO.__new__(TODO)
|
||||
self.lances = []
|
||||
self.todo.execute = mock.MagicMock()
|
||||
self.todo.execute.exec_command_live.side_effect = (
|
||||
lambda cmd, **k: self.lances.append(cmd) or 0
|
||||
)
|
||||
|
||||
def _proposer(self, presents, reponse="o"):
|
||||
"""`presents` : les outils du rapport déjà installés.
|
||||
|
||||
UN SEUL patch de shutil.which : « qm.shutil » et le shutil de
|
||||
qemu_privilege sont le MÊME objet, et deux patchs concurrents dessus
|
||||
se recouvrent — l'hôte est donc simulé en une fois, gestionnaire de
|
||||
paquets compris.
|
||||
"""
|
||||
connus = set(presents) | {"pacman"}
|
||||
|
||||
def faux_which(binaire):
|
||||
return "/usr/bin/x" if binaire in connus else None
|
||||
|
||||
with mock.patch.object(
|
||||
qm.shutil, "which", side_effect=faux_which
|
||||
), mock.patch("builtins.input", return_value=reponse), mock.patch(
|
||||
"builtins.print"
|
||||
):
|
||||
return self.todo._qemu_diag_offer_tools()
|
||||
|
||||
def test_nothing_is_offered_when_all_are_there(self):
|
||||
self.assertFalse(self._proposer({"eglinfo", "lspci"}))
|
||||
self.assertEqual([], self.lances)
|
||||
|
||||
def test_the_package_follows_the_distribution(self):
|
||||
"""« mesa-utils » n'existe pas sur Fedora, qui livre « mesa-demos »."""
|
||||
self._proposer(set())
|
||||
joint = " ".join(self.lances)
|
||||
self.assertIn("pacman", joint)
|
||||
self.assertIn("mesa-utils", joint)
|
||||
self.assertIn("pciutils", joint)
|
||||
|
||||
def test_the_command_is_announced_before_the_question(self):
|
||||
"""« Les installer ? » ne dit ni ce qui sera lancé ni avec quels
|
||||
droits : la commande entière, sudo compris, précède la question."""
|
||||
connus = {"pacman"}
|
||||
vus = []
|
||||
with mock.patch.object(
|
||||
qm.shutil,
|
||||
"which",
|
||||
side_effect=lambda b: "/usr/bin/x" if b in connus else None,
|
||||
), mock.patch(
|
||||
"builtins.input",
|
||||
side_effect=lambda p="": (vus.append(("?", p)), "n")[1],
|
||||
), mock.patch(
|
||||
"builtins.print",
|
||||
side_effect=lambda *a, **k: vus.append(
|
||||
("!", " ".join(str(x) for x in a))
|
||||
),
|
||||
):
|
||||
self.todo._qemu_diag_offer_tools()
|
||||
avant = [txt for genre, txt in vus[: [g for g, _ in vus].index("?")]]
|
||||
joint = "\n".join(avant)
|
||||
self.assertIn("sudo pacman", joint)
|
||||
self.assertIn("mesa-utils", joint)
|
||||
|
||||
def test_a_refusal_installs_nothing(self):
|
||||
self.assertFalse(self._proposer(set(), reponse="n"))
|
||||
self.assertEqual([], self.lances)
|
||||
|
||||
def test_without_a_terminal_it_installs_nothing(self):
|
||||
"""Un lancement scripté n'a personne pour répondre : l'invite y lève
|
||||
EOFError, et le rapport — déjà écrit — ne doit pas tomber avec."""
|
||||
connus = {"pacman"}
|
||||
with mock.patch.object(
|
||||
qm.shutil,
|
||||
"which",
|
||||
side_effect=lambda b: "/usr/bin/x" if b in connus else None,
|
||||
), mock.patch("builtins.input", side_effect=EOFError), mock.patch(
|
||||
"builtins.print"
|
||||
):
|
||||
self.assertFalse(self.todo._qemu_diag_offer_tools())
|
||||
self.assertEqual([], self.lances)
|
||||
|
||||
def test_only_the_missing_ones_are_proposed(self):
|
||||
self._proposer({"lspci"})
|
||||
self.assertEqual(1, len(self.lances), self.lances)
|
||||
self.assertIn("mesa-utils", self.lances[0])
|
||||
|
||||
|
||||
class LeConseilAcl(unittest.TestCase):
|
||||
"""La liste de périphériques n'est proposée que si elle explique le
|
||||
blocage : hôte à carte NVIDIA propriétaire, dont la liste ne nomme pas
|
||||
ses nœuds. libvirt y ajoute le nœud de rendu quand le domaine le
|
||||
déclare, jamais ceux de la carte, et la pile propriétaire ouvre les deux.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.todo = TODO.__new__(TODO)
|
||||
self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128"
|
||||
|
||||
def _conseil(self, nodes, acl):
|
||||
vus = []
|
||||
with mock.patch.object(
|
||||
self.todo, "_qemu_nvidia_nodes", return_value=nodes
|
||||
), mock.patch.object(
|
||||
self.todo, "_qemu_acl_active", return_value=acl
|
||||
), mock.patch(
|
||||
"builtins.print",
|
||||
side_effect=lambda *a, **k: vus.append(
|
||||
" ".join(str(x) for x in a)
|
||||
),
|
||||
):
|
||||
parle = self.todo._qemu_nvidia_acl_advice()
|
||||
return parle, "\n".join(vus)
|
||||
|
||||
def test_a_host_without_nvidia_hears_nothing(self):
|
||||
parle, rendu = self._conseil([], "")
|
||||
self.assertFalse(parle)
|
||||
self.assertEqual("", rendu)
|
||||
|
||||
def test_a_list_that_already_names_them_is_left_alone(self):
|
||||
"""Répéter un conseil déjà suivi le rend invisible quand il compte."""
|
||||
nodes = ["/dev/nvidia0", "/dev/nvidiactl"]
|
||||
parle, _ = self._conseil(
|
||||
nodes, 'cgroup_device_acl = ["/dev/nvidia0", "/dev/nvidiactl"]'
|
||||
)
|
||||
self.assertFalse(parle)
|
||||
|
||||
def test_a_missing_node_brings_the_whole_list_back(self):
|
||||
"""La clé REMPLACE le défaut au lieu de s'y ajouter : proposer
|
||||
seulement les nœuds manquants ferait perdre /dev/kvm."""
|
||||
parle, rendu = self._conseil(
|
||||
["/dev/nvidia0", "/dev/nvidiactl"],
|
||||
'cgroup_device_acl = ["/dev/nvidia0"]',
|
||||
)
|
||||
self.assertTrue(parle)
|
||||
for attendu in ("/dev/kvm", "/dev/null", "/dev/dri/renderD128"):
|
||||
self.assertIn(attendu, rendu)
|
||||
self.assertIn("AJOUTER", rendu)
|
||||
|
||||
def test_an_unreadable_file_says_so_instead_of_concluding(self):
|
||||
parle, rendu = self._conseil(["/dev/nvidia0"], None)
|
||||
self.assertTrue(parle)
|
||||
self.assertIn("grep -n cgroup_device_acl", rendu)
|
||||
|
||||
def test_unreadable_and_absent_are_not_the_same_answer(self):
|
||||
"""Ne pas pouvoir lire n'est pas savoir qu'il n'y a rien : le premier
|
||||
cas doit rendre None, pour que le conseil le DISE au lieu de conclure
|
||||
qu'aucune liste n'existe."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
vide = Path(tmp, "vide.conf")
|
||||
vide.write_text("# tout en commentaire\n", encoding="utf-8")
|
||||
with mock.patch.object(type(self.todo), "_QEMU_CONF", str(vide)):
|
||||
self.assertEqual("", self.todo._qemu_acl_active())
|
||||
with mock.patch.object(
|
||||
type(self.todo), "_QEMU_CONF", str(Path(tmp, "absent.conf"))
|
||||
):
|
||||
self.assertIsNone(self.todo._qemu_acl_active())
|
||||
regle = Path(tmp, "regle.conf")
|
||||
regle.write_text(
|
||||
'#cgroup_device_acl = ["/dev/vieux"]\n'
|
||||
'cgroup_device_acl = ["/dev/nvidia0"]\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(type(self.todo), "_QEMU_CONF", str(regle)):
|
||||
actif = self.todo._qemu_acl_active()
|
||||
# La ligne en COMMENTAIRE ne compte pas : la lire ferait croire à un
|
||||
# réglage que libvirt ignore.
|
||||
self.assertIn("/dev/nvidia0", actif)
|
||||
self.assertNotIn("/dev/vieux", actif)
|
||||
|
||||
def test_it_names_the_restart_and_the_vm_recreation(self):
|
||||
"""Un redémarrage de l'invité ne suffit pas : la liste s'applique au
|
||||
lancement de QEMU, donc il faut éteindre et rallumer."""
|
||||
_, rendu = self._conseil(["/dev/nvidia0"], "")
|
||||
self.assertIn("systemctl restart", rendu)
|
||||
self.assertIn("is-active", rendu)
|
||||
|
||||
|
||||
class _FauxModule:
|
||||
"""deploy_qemu réduit à ce que le menu lui demande."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue