diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 30b81b7..f833cc3 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -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 ? diff --git a/script/todo/qemu_menu.py b/script/todo/qemu_menu.py index 9df78eb..9e653ed 100644 --- a/script/todo/qemu_menu.py +++ b/script/todo/qemu_menu.py @@ -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 diff --git a/script/todo/qemu_privilege.py b/script/todo/qemu_privilege.py index 570fceb..a3b31a5 100644 --- a/script/todo/qemu_privilege.py +++ b/script/todo/qemu_privilege.py @@ -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. diff --git a/script/todo/qemu_recover.py b/script/todo/qemu_recover.py index 90c21d5..611435f 100644 --- a/script/todo/qemu_recover.py +++ b/script/todo/qemu_recover.py @@ -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: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 1ebf7f8..f577896 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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:", diff --git a/test/test_qemu_recover.py b/test/test_qemu_recover.py index 7333da2..a51440b 100644 --- a/test/test_qemu_recover.py +++ b/test/test_qemu_recover.py @@ -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", "")) diff --git a/test/test_qemu_start_egl.py b/test/test_qemu_start_egl.py index c72c564..2f7cc40 100644 --- a/test/test_qemu_start_egl.py +++ b/test/test_qemu_start_egl.py @@ -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."""