#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici.""" import glob import json import os import re import shlex import shutil import subprocess import time from script.todo import ssh_config, todo_install from script.todo.qemu_cache_menu import bypass_menage from script.todo.qemu_privilege import ( LIBVIRT_URI as URI, ) from script.todo.qemu_privilege import ( sudo_prefix, system_path, virsh_argv, ) from script.todo.todo_i18n import t # Les fichiers d'état de dnsmasq, un par réseau libvirt. Sur une installation # standard ils sont en 0644 dans un répertoire en 0755, donc lisibles sans # privilège — le « .conf » posé à côté est en 0600, et c'est lui qui donne # l'impression que le répertoire est fermé. DNSMASQ_STATUS = "/var/lib/libvirt/dnsmasq/*.status" def lease_status_text(*, paths=None, read=None, run=None, euid=None) -> str: """Le contenu des fichiers d'état de dnsmasq, ou la chaîne vide. La lecture DIRECTE passe d'abord, et elle suffit sur une installation standard, où ces fichiers sont en lecture pour tous. Ce n'est qu'ensuite que « sudo -n » est tenté, pour l'installation durcie — jamais « sudo » tout court. L'attente d'une VM appelle ce chemin toutes les trois secondes pendant dix minutes, et l'affichage d'une liste l'appelle une fois par VM : un sudo interactif y demande donc un mot de passe root en boucle, au milieu d'un écran, ce qui entraîne précisément le réflexe de le taper dans ce qui le demande. `needs_sudo()` ne tranche pas ici : il dit si libvirt est joignable, pas si un fichier de root est lisible. Les deux questions n'ont ni la même réponse ni la même cause. Rend la chaîne vide quand rien n'est lisible — un répertoire interdit rend un glob VIDE, indistinguable de « aucun réseau », d'où l'essai privilégié même sans chemin trouvé. Les appelants se replient déjà sur une autre source. """ if paths is None: paths = glob.glob if read is None: def read(chemin): with open(chemin, encoding="utf-8") as fh: return fh.read() if run is None: run = subprocess.run if euid is None: euid = os.geteuid morceaux = [] for chemin in sorted(paths(DNSMASQ_STATUS)): try: morceaux.append(read(chemin)) except OSError: morceaux = [] break if morceaux: return "".join(morceaux) if euid() == 0: # Root a déjà tout vu : il n'y a rien à lire, et sudo n'y changerait # rien. return "" try: res = run( [ "sudo", "-n", "sh", "-c", f"cat {DNSMASQ_STATUS} 2>/dev/null", ], capture_output=True, text=True, timeout=10, ) except (OSError, subprocess.SubprocessError): return "" return res.stdout or "" def parse_ssh_blocks(content) -> dict: """{nom: {"hostname": …, "proxyjump": …}} pour CHAQUE nom déclaré. Une ligne « Host » peut en porter plusieurs : ils partagent alors le même corps, donc la même entrée. Ce qui compte comme un nom de machine est tranché par `ssh_config.declared_names`, partagé avec les deux lecteurs de todo.py.""" blocs, courant = {}, [] for ligne in (content or "").splitlines(): declares = ssh_config.declared_names(ligne) if declares is not None: corps = {} courant = declares for nom in courant: blocs[nom] = corps continue if not courant: continue if ligne.strip() and not ligne[:1].isspace(): courant = [] continue mots = ligne.split() if len(mots) >= 2 and mots[0].lower() in ("hostname", "proxyjump"): blocs[courant[0]][mots[0].lower()] = mots[1] return blocs def ssh_orphans(blocs, juge, prefixe="erplibre-"): """(gardées, orphelines) — chacune [(nom, raison)]. Un ProxyJump ne vaut pas preuve de vie à lui seul : « écrite pour une VM imbriquée, que virsh ne connaîtra jamais » oublie que le rebond, lui, peut avoir disparu. Effacer la VM qui servait de rebond retire son entrée — correctement — et laisse celles qui rebondissaient par elle : des culs-de-sac présentés comme « mènent encore quelque part ». D'où le point fixe : retirer un parent peut orpheliner ses enfants, et ceux-ci peuvent en orpheliner d'autres. On tourne jusqu'à ce que plus rien ne bouge. `juge(nom)` ne rend que les preuves DIRECTES — un domaine vivant, une adresse qui mène à l'un d'eux, une VM de l'hôte Proxmox. Si le rebond comptait comme preuve directe, une chaîne de rebonds morts se soutiendrait toute seule.""" noms = [n for n in blocs if n.startswith(prefixe)] raisons = {n: juge(n) for n in noms} def rebond_vivant(saut): if saut in blocs: # Une entrée qu'on ne gère PAS — hôte personnel — n'est jamais # notre affaire : on la suppose vivante plutôt que d'effacer sur # une supposition. return ( bool(raisons.get(saut)) if saut.startswith(prefixe) else True ) # Plus AUCUNE entrée de ce nom. Deux lectures, et il faut les # séparer : une adresse ou un nom DNS, ssh saura le joindre et ce # n'est pas notre affaire ; un nom de NOTRE nommage, en revanche, # n'existe que par son entrée — celle-ci partie, le rebond ne mène # nulle part. C'est l'état exact laissé par un nettoyage précédent, # qui avait retiré le parent et gardé les enfants. if not saut.startswith(prefixe): return True return bool(juge(saut)) bouge = True while bouge: bouge = False for nom in noms: if raisons[nom]: continue saut = blocs[nom].get("proxyjump") if saut and rebond_vivant(saut): raisons[nom] = t("reached through a jump host") bouge = True gardes, orphelines = [], [] for nom in noms: if raisons[nom]: gardes.append((nom, raisons[nom])) else: saut = blocs[nom].get("proxyjump") orphelines.append( (nom, f"{t('its jump host is gone:')} {saut}" if saut else "") ) return gardes, orphelines # Programme de la sonde « video du qemu en cours » du diagnostic. Il vit # ici, et non dans un littéral au milieu de la table des sondes : un # programme Python cité dans une commande shell citée dans une chaîne # Python porte trois niveaux d'échappement, et le premier guillemet le # casse. `shlex.quote` s'occupe du seul niveau qui reste. _DIAG_VIDEO_PY = """ import glob MOTS = ("vga", "virtio-gpu", "egl", "virgl", "rendernode") for chemin in sorted(glob.glob("/proc/[0-9]*/cmdline")): try: with open(chemin, "rb") as fh: argv = fh.read().decode("utf-8", "replace").split(chr(0)) except OSError: continue # Le tri se fait sur argv[0] : sur la ligne entière, la sonde se # verrait elle-même, son propre motif contenant « qemu-system ». if not argv or "qemu-system" not in argv[0]: continue noms = [a[6:].split(",")[0] for a in argv if a.startswith("guest=")] print(noms[0] if noms else chemin.split("/")[2]) for arg in argv: if any(mot in arg.lower() for mot in MOTS): print(" " + arg) """ class QemuManageMixin: """Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici.""" def _qemu_download_image(self): script_path = self._qemu_script_path() distro = self._qemu_prompt_distro() version = self._qemu_prompt_version(distro) ans = input(t("Verify SHA256 after download? (y/N): ")) parts = [ "sudo", script_path, "--download-only", "--distro", distro, "--version", version, ] if self._is_yes(ans): parts.append("--verify") cmd = " ".join(shlex.quote(p) for p in parts) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) def _qemu_list_vms(self, ask_advanced=False): cmd = f"{sudo_prefix()}virsh --connect {URI} list --all" print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) if not ask_advanced: return # Menu contextuel : infos avancées, ou changer l'état de VM. print(f"\n{t('What do you want to do?')}") print(f" [1] {t('Advanced info (vCPU, RAM, disk)')}") print(f" [2] {t('Change the state of one or more VMs')}") print(f" [{t('Enter')}] {t('Nothing')}") choice = input(t("Choice: ")).strip() if choice == "1": self._qemu_list_vms_advanced() elif choice == "2": self._qemu_change_state() def _qemu_change_state(self): """Démarre (« ouvrir ») ou éteint (« fermer ») une liste de VM saisie séparée par des virgules, avec DOUBLE validation.""" names = self._qemu_list_domains() if not names: print(f"\n{t('No VM found.')}") return # Liste NUMÉROTÉE, comme l'écran de suppression : les noms de VM sont # longs et se ressemblent, les retaper invite à la faute de frappe sur # une commande qui change l'état d'une machine. print(f"\n{t('Available VMs:')}") for i, n in enumerate(names, 1): print(f" [{i}] {n}") print(f" [all] {t('select all')}") raw = input(t("Selection (numbers, or 'all'): ")).strip() if not raw: print(t("Nothing selected.")) return if raw.lower() in ("all", "*"): resolved = list(names) else: resolved = self._parse_index_selection(raw.lower(), names) # Le parseur ignore en silence ce qu'il ne reconnaît pas. Sur une # sélection qui va démarrer ou éteindre des VM, un numéro hors # liste doit être dit, pas escamoté. unknown = [ tok for tok in re.split(r"[\s,]+", raw.strip()) if tok and tok not in names and not self._is_index(tok, names) ] if unknown: print(f"{t('Unknown VM(s):')} {', '.join(unknown)}") return if not resolved: print(t("Nothing selected.")) return # Choix de l'état cible : ouvrir (démarrer) ou fermer (éteindre). print(f"\n{t('Target state:')}") print(f" [1] {t('Open (start)')}") print(f" [2] {t('Close (shut down)')}") print(f" [3] {t('Adjust hardware only (vCPU, RAM, 3D)')}") st = input(t("Choice: ")).strip() if st == "1": action, verb = "start", t("start") elif st == "2": action, verb = "shutdown", t("shut down") elif st == "3": self._qemu_adjust_hardware(resolved) return else: print(t("Cancelled.")) return # Le matériel d'une VM ne se règle QUE pendant qu'elle est éteinte : # démarrer est donc le dernier moment pour le faire, et le seul où la # question tombe juste. if action == "start" and self._is_yes( input(f"\n{t('Adjust hardware before starting? (y/N): ')}") ): self._qemu_adjust_hardware(resolved) # DOUBLE validation avant d'appliquer. summary = f"{verb} -> {', '.join(resolved)}" if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")): print(t("Cancelled.")) return if not self._is_yes(input(t("Confirm for real? (y/N): "))): print(t("Cancelled.")) return for real in resolved: cmd = ( f"{sudo_prefix()}virsh --connect {URI} " f"{action} {shlex.quote(real)}" ) print(f"\n{t('Will execute:')} {cmd}") if action != "start": self.execute.exec_command_live(cmd, source_erplibre=False) continue # Un démarrage est le seul moment où la 3D écrite dans la # définition se met à l'épreuve : QEMU refuse le domaine si EGL # n'y démarre pas, et la VM reste inutilisable jusqu'à ce que # quelqu'un défasse le réglage. code, lignes = self.execute.exec_command_live( cmd, source_erplibre=False, return_status_and_output=True ) if code: self._qemu_start_failed(real, cmd, "\n".join(lignes or [])) @staticmethod def _qemu_dumpxml(name, inactive=True): """XML du domaine, ou '' — source de son état matériel. « --inactive » n'est pas décoratif : sur une VM allumée, « dumpxml » rend la vue VIVANTE, décorée de ce que libvirt a alloué au démarrage (portid du réseau, vnetN, alias). C'est la définition persistante que virt-xml modifie, et c'est donc elle qu'il faut lire — d'où le défaut. `inactive=False` demande justement la vue vivante : pour SAVOIR CE QUI EST OUVERT, c'est elle qui compte, un disque attaché à chaud n'existant que là. """ argv = virsh_argv("dumpxml") if inactive: argv.append("--inactive") argv.append(name) try: res = subprocess.run( argv, capture_output=True, text=True, timeout=20, env=QemuManageMixin._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): return "" return res.stdout if res.returncode == 0 else "" @staticmethod def _qemu_autostart(name): """Démarrage automatique activé ? (absent du XML : virsh seul le sait)""" try: res = subprocess.run( virsh_argv("dominfo", name), capture_output=True, text=True, timeout=15, env=QemuManageMixin._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): return False for line in res.stdout.splitlines(): if line.startswith("Autostart:"): return line.split(":", 1)[1].strip() == "enable" return False def _qemu_ask_bool(self, prompt, default): """Question fermée dont le DÉFAUT est l'état actuel de la VM. Une réponse vide — ou incompréhensible — laisse la VM telle quelle : sur un formulaire de matériel, le silence ne doit rien modifier. """ ans = input(prompt).strip() if self._is_yes(ans): return True if self._is_no(ans): return False return default def _qemu_host_gpu_node(self): """Nœud de rendu de l'hôte, vu par deploy_qemu (source unique), ou ''.""" try: return self._qemu_import_module().host_gpu_node() except (OSError, AttributeError, ImportError): return "" def _qemu_net_choices(self): """Réseaux proposables : réseaux libvirt, puis ponts de l'hôte. Les ponts appartenant à un réseau libvirt (virbr0 pour « default ») sont écartés : les proposer offrirait DEUX fois le même chemin, dont un qui contourne la gestion du réseau par libvirt. """ tokens = [] nets = self._qemu_cmd_lines(virsh_argv("net-list", "--all", "--name")) owned = set() for net in nets: tokens.append(f"network:{net}") for line in self._qemu_cmd_lines(virsh_argv("net-info", net)): if line.startswith("Bridge:"): owned.add(line.split(":", 1)[1].strip()) for line in self._qemu_cmd_lines( ["ip", "-o", "link", "show", "type", "bridge"] ): # « 3: br0: br0 parts = line.split(":") bridge = parts[1].strip() if len(parts) > 1 else "" if bridge and bridge not in owned: tokens.append(f"bridge:{bridge}") return tokens @staticmethod def _qemu_cmd_lines(cmd): """Lignes non vides d'une commande, ou [] si elle échoue.""" try: res = subprocess.run( cmd, capture_output=True, text=True, timeout=15, env=QemuManageMixin._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): return [] if res.returncode != 0: return [] return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()] # Ce que la 3D d'une VM exige de l'hôte, en DEUX briques que le même # réglage met en jeu et qu'on confond souvent : # # « --graphics egl-headless » ouvre le nœud de rendu et crée un contexte # EGL par GBM — c'est Mesa qui répond, et « eglInitialize failed » vient # de là. Sans lui, le domaine ne démarre pas du tout. # # « accel3d=on » confie ensuite les commandes 3D de l'invité à # virglrenderer, qui les traduit vers l'OpenGL de l'hôte. Il n'entre en # jeu qu'APRÈS qu'EGL a démarré : l'installer ne répare donc pas un EGL # qui refuse. # # Les fichiers plutôt que les paquets : leurs noms changent d'une # distribution à l'autre, leur emplacement beaucoup moins. # Les deux devices 3D sont des modules SÉPARÉS, empaquetés à part sur # les distributions qui découpent QEMU. Lequel sert dépend du type de # vidéo : « virtio-vga-gl » pour l'écran principal compatible VGA, # « virtio-gpu-gl » pour un affichage sans VGA. Ne chercher que le # second laisse le rapport tout en vert alors que le premier manque. # QEMU n'annonce pas un device dont le module est absent, libvirt le # remplace alors par « virtio-vga » nu, et rien ne le signale : la VM # démarre sans 3D, avec l'egl-headless demandé toujours en place. _GPU_3D_PIECES = ( ("qemu ui-egl-headless", "qemu/ui-egl-headless.so"), ("qemu virtio-vga-gl", "qemu/hw-display-virtio-vga-gl.so"), ("qemu virtio-gpu-gl", "qemu/hw-display-virtio-gpu-gl.so"), ("virglrenderer", "libvirglrenderer.so"), ("mesa libgbm", "libgbm.so"), ("mesa libEGL", "libEGL.so"), ) @staticmethod def _qemu_lib_present(motif): """Chemin du fichier s'il existe dans les emplacements habituels des bibliothèques et des modules QEMU, sinon la chaîne vide.""" racines = ( "/usr/lib", "/usr/lib64", "/usr/lib/x86_64-linux-gnu", "/usr/local/lib", ) for racine in racines: trouves = sorted(glob.glob(os.path.join(racine, motif + "*"))) if trouves: return trouves[0] return "" def _qemu_gpu_3d_report(self): """Dit ce qui manque à l'hôte pour que la 3D d'une VM démarre. Ne modifie rien. Un nœud de rendu présent ne prouve RIEN : c'est l'initialisation d'EGL dessus qui décide, et elle ne se teste qu'en essayant — d'où les deux commandes suggérées à la fin. """ print(f"\n── {t('3D on this host')} ──") node = self._qemu_host_gpu_node() print(f" {t('Render node:')} {node or t('none')}") for nom, motif in self._GPU_3D_PIECES: chemin = self._qemu_lib_present(motif) # Absent : dire ce qui a été CHERCHÉ. Une case vide laisse sans # prise, le motif dit quoi installer et où regarder. print(f" {'✅' if chemin else '❌'} {nom:<24} {chemin or motif}") 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() def _qemu_vm_3d_report(self): """État 3D de chaque VM définie, lu dans sa définition PERSISTANTE. Le rapport sur l'hôte dit ce que la machine peut faire ; celui-ci dit ce que chaque VM recevra au prochain démarrage, ce qui n'est pas la même question. Trois valeurs y répondent ensemble et aucune seule : le type de vidéo, « accel3d », et le device figé. Ce dernier (attribut « device », depuis libvirt 12.5.0) grave le device QEMU retenu pour tenir l'ABI de l'invité stable d'un démarrage à l'autre, et il l'emporte sur « accel3d ». Une VM démarrée une première fois sans 3D garde donc un device sans GL, que cocher la 3D ensuite ne change pas : la définition et la ligne de commande se contredisent alors sans que rien ne le signale. """ from script.todo import qemu_hardware as hw print(f"\n── {t('3D per VM')} ──") noms = self._qemu_list_domains() if not noms: print(f" {t('none')}") return figees = [] for nom in noms: etat = hw.hw_state(self._qemu_dumpxml(nom)) if not etat.get("video"): print(f" · {nom} — {t('no video device')}") continue champs = [ f"video={etat['video']}", f"accel3d={'on' if etat.get('accel3d') else 'off'}", f"device={etat.get('video_device') or t('not pinned')}", ] if etat.get("render"): champs.append(f"rendernode={etat['render']}") if hw.pin_defeats_3d(etat): marque = "❌" figees.append(nom) elif etat.get("accel3d"): marque = "✅" else: marque = "· " print(f" {marque} {nom} — {', '.join(champs)}") if nom in figees: print( f" ⚠ {t('non-GL device pinned: 3D will not start')}" ) if figees: # Le rapport ne modifie rien : la commande est ÉCRITE, jamais # lancée. Elle passe par la définition persistante et non par # virt-xml, dont le vocabulaire ne connaît pas partout cet # attribut ; « define » l'accepte quelle que soit sa version. print(f"\n {t('To unpin, VM stopped:')}") print(" v=$(mktemp) ; vm=" + figees[0]) print( " virsh --connect qemu:///system dumpxml --inactive" " $vm > $v" ) print( " sed -i \"s/device='virtio-vga'/device='virtio-vga-gl'/\"" " $v" ) print(" virsh --connect qemu:///system define $v") # 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", ), # Ce que la VM a REÇU, et non ce que sa définition demande : entre # les deux, libvirt peut avoir retiré l'accélération sans le dire. # « virtio-vga-gl » est le device 3D des QEMU récents ; l'ancienne # forme « virtio-vga,virgl=on » ne s'écrit plus, si bien qu'un grep # sur « virgl » ne rend rien sur un QEMU pourtant accéléré et fait # conclure à tort qu'il manque un réglage. Le suffixe « -gl » est ce # qui distingue le device 3D de celui du rendu logiciel, et c'est la # seule pièce que la ligne de commande dise sans ambiguïté : # « egl-headless » s'y trouve dans les deux cas. ( "video du qemu en cours", "python3 -c " + shlex.quote(_DIAG_VIDEO_PY) + " 2>&1", ), ("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 @staticmethod def _diag_section(titre, rendu): """Une section du rapport, isolée : elle échoue SEULE. Les sondes shell sont déjà protégées une à une ; les sections écrites en Python ne l'étaient pas. Or le rapport s'écrit d'un bloc à la FIN : une section qui lève emporte avec elle tout ce qui a été relevé avant, et l'utilisateur se retrouve sans fichier — au moment précis où il en a besoin. L'échec devient donc une ligne du rapport, ce qui est en soi une information. """ import io from contextlib import redirect_stdout tampon = io.StringIO() try: with redirect_stdout(tampon): rendu() except Exception as exc: tampon.write(f"\n({type(exc).__name__}: {exc})\n") return f"\n===== {titre} =====\n" + tampon.getvalue() 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") morceaux.append(self._diag_section("3D", self._qemu_gpu_3d_report)) morceaux.append( self._diag_section("3D par VM", self._qemu_vm_3d_report) ) 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 ? La signature vit dans deploy_qemu.py, qui doit tenir seul en tant que script : la lire là plutôt que la recopier ici évite deux vérités. """ try: return bool(self._qemu_import_module().egl_failed(sortie)) except Exception: return False def _qemu_start_failed(self, name, cmd, sortie): """Un démarrage refusé : proposer ce qui le débloque, s'il y a lieu. Le seul échec qui se rattrape ici est celui de la 3D : le nœud de rendu existe, mais EGL n'y démarre pas, et rien ne permettait de le savoir avant l'essai. Retirer l'accélération rend une VM qui démarre. """ if not self._qemu_egl_failed(sortie): return print(f"\n ⚠ {t('EGL does not start on this host GPU.')}") print( f" {t('The VM cannot boot while 3D stays in its definition.')}" ) self._qemu_gpu_3d_report() if not self._is_yes_default_yes( input(t("Remove the 3D and start again? (Y/n): ")) ): return from script.todo import qemu_hardware as hw etat = hw.hw_state( self._qemu_dumpxml(name), self._qemu_autostart(name) ) if not etat.get("name"): print(f" ⚠ {t('Unreadable VM definition.')}") return # Seule la 3D change. build_want retombe sur l'état pour les vCPU, la # RAM, les écrans, le CPU et le réseau — mais PAS pour « autostart », # qu'il lit en booléen sec : y passer None éteindrait le démarrage # automatique d'une VM qu'on voulait seulement débloquer. want = hw.build_want(etat, None, None, False, etat.get("autostart")) plan = hw.hw_plan(etat, want) for entree in plan: if "cmd" not in entree: continue retrait = sudo_prefix() + " ".join( shlex.quote(c) for c in entree["cmd"] ) print(f"\n{t('Will execute:')} {retrait}") self.execute.exec_command_live( retrait, source_erplibre=False, new_env={"PATH": system_path()}, ) print(f"\n{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) def _qemu_adjust_hardware(self, names): """Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES. Les VM allumées sont écartées, en le disant : virt-xml y écrirait une définition qui ne prendrait effet qu'au prochain démarrage — un réglage qui paraît appliqué et ne l'est pas. """ from script.todo import qemu_hardware as hw off, busy = [], [] for name in names: state = self._qemu_domstate(name) (off if state == "shut off" else busy).append(name) if busy: print( f"\n ⚠ {t('Not shut off, hardware left untouched:')}" f" {', '.join(busy)}" ) if not off: return node = self._qemu_host_gpu_node() gpu_txt = node or t("none (software rendering)") print(f"\n{t('Host GPU:')} {gpu_txt}") rows = [ r for r in ( hw.hw_state(self._qemu_dumpxml(n), self._qemu_autostart(n)) for n in off ) if r.get("name") ] if not rows: print(f" ⚠ {t('Unreadable VM definition.')}") return for r in rows: print(f" {r['name']:<30} {hw.hw_summary(r)}") nets = self._qemu_net_choices() want = self._qemu_hw_form(rows, node, nets) if want is None: print(t("Cancelled.")) return if not want: want = self._qemu_hw_prompts(rows, node, nets) if not want: print(t("Cancelled.")) return plan = [] for r in rows: plan += hw.hw_plan(r, want.get(r["name"]) or {}, node) for entry in plan: if entry.get("skip"): print(f" ⚠ {entry['what']} : {entry['skip']}") cmds = [e for e in plan if e.get("cmd")] if not cmds: print(f"\n{t('Nothing to change.')}") return print(f"\n{t('Changes:')}") for entry in cmds: print(f" - {entry['what']}") if not self._is_yes(input(t("Apply these changes? (y/N): "))): print(t("Cancelled.")) return for entry in cmds: cmd = sudo_prefix() + " ".join( shlex.quote(c) for c in entry["cmd"] ) print(f"\n{t('Will execute:')} {cmd}") # 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): """Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour retomber sur les invites en ligne (textual absent), None si annulé.""" from script.todo import textual_setup if not textual_setup.ensure(): return {} try: from script.todo.qemu_hardware import run_hardware_form return run_hardware_form(rows, node, nets) except ImportError: return {} def _qemu_pick(self, title, values, current, labels=None): """Liste numérotée dont le DÉFAUT est la valeur actuelle. Rendre la valeur actuelle sur une réponse vide, et sur une réponse illisible : dans un formulaire de matériel, ne rien comprendre ne doit rien changer. """ labels = labels or values print(f"{title} :") for i, (val, lab) in enumerate(zip(values, labels), 1): mark = " ←" if val == current else "" print(f" [{i}] {lab}{mark}") ans = input(" " + t("Choice: ")).strip() if not ans.isdigit(): return current idx = int(ans) return values[idx - 1] if 1 <= idx <= len(values) else current def _qemu_hw_prompts(self, rows, node, nets=None): """Même ajustement, en invites, quand Textual n'est pas disponible.""" from script.todo import qemu_hardware as hw cpus = hw.cpu_choices(rows) reseaux = hw.net_choices(rows, nets) want = {} for r in rows: print(f"\n {r['name']} — {hw.hw_summary(r)}") vcpus = input(f" vCPU [{r.get('vcpus')}] : ") ram = input(f" RAM [{hw.fmt_mib(r.get('mem_mib'))}] : ") reason = hw.gpu_allowed(r, node) if reason: print(f" ⚠ {t('3D acceleration (host GPU)')} : {reason}") gpu = False else: gpu = self._qemu_ask_bool( f" {t('3D acceleration (host GPU)')} ? (o/N) : ", bool(r.get("accel3d")), ) auto = self._qemu_ask_bool( f" {t('Autostart')} ? (o/N) : ", bool(r.get("autostart")) ) cpu = self._qemu_pick(f" {t('CPU mode')}", cpus, r.get("cpu")) heads = "" if r.get("video"): heads = input(f" {t('Screens')} [{r.get('heads') or 1}] : ") net = r.get("net") or "" # Une seule possibilité : rien à demander. C'est le cas d'un hôte # sans pont, où le réseau libvirt est la seule voie. if len(reseaux) > 1: net = self._qemu_pick( f" {t('Network')}", [tok for tok, _lab in reseaux], net, labels=[lab for _tok, lab in reseaux], ) want[r["name"]] = hw.build_want( r, vcpus, ram, gpu, auto, cpu=cpu, heads=heads, net=net ) return want @staticmethod def _qemu_dominfo(name): """(vcpus, max_mem_kib) via « virsh dominfo », ou (0, 0).""" try: res = subprocess.run( virsh_argv("dominfo", name), capture_output=True, text=True, timeout=15, env=QemuManageMixin._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): return 0, 0 vcpus, mem = 0, 0 for line in res.stdout.splitlines(): if line.startswith("CPU(s):"): try: vcpus = int(line.split(":", 1)[1].strip()) except ValueError: pass elif line.startswith("Max memory:"): # « 4194304 KiB » try: mem = int(line.split(":", 1)[1].split()[0]) except (ValueError, IndexError): pass return vcpus, mem @staticmethod def _qemu_disk_sizes(disk): """(taille virtuelle, taille réelle sur disque) en octets, via qemu-img info -U (lit même VM allumée). (0, 0) si échec.""" try: res = subprocess.run( ["sudo", "qemu-img", "info", "-U", "--output=json", disk], capture_output=True, text=True, timeout=20, ) data = json.loads(res.stdout) return ( int(data.get("virtual-size", 0)), int(data.get("actual-size", 0)), ) except (OSError, subprocess.SubprocessError, ValueError): return 0, 0 @staticmethod def _qemu_domain_uptime(name): """Secondes depuis le démarrage du domaine, ou None. libvirt n'expose pas l'uptime d'un invité : ni dominfo, ni domstats, ni l'agent. Mais le processus QEMU du domaine est né avec lui, et son âge est donc exactement celui de la VM. « guest=, » est le motif que libvirt met dans sa ligne de commande — la virgule évite qu'un nom préfixe d'un autre matche à sa place.""" try: res = subprocess.run( ["pgrep", "-f", f"guest={name},"], capture_output=True, text=True, timeout=10, ) pid = (res.stdout or "").split()[0] age = subprocess.run( ["ps", "-o", "etimes=", "-p", pid], capture_output=True, text=True, timeout=10, ) return int((age.stdout or "").strip()) except (OSError, subprocess.SubprocessError, ValueError, IndexError): return None @staticmethod def _qemu_dommemstat(name): """(utilisée, totale) en KiO vues par l'INVITÉ, ou (0, 0). « available » est ce que l'invité voit, « usable » ce qu'il peut encore rendre : leur différence est son « used », à quelques mégaoctets près — calibré contre le « free » de deux VM (1186 contre 1216, 4831 contre 4838). « unused » ne convient pas : il ignore le cache, et donnait 10,8 Go d'« utilisé » sur une VM qui en occupait 1,2. La période de collecte est posée d'abord, et c'est indispensable : sans elle le ballon ne rafraîchit rien, et une VM qui occupait 4,8 Go en annonçait 490 Mo — vécu. « --live » ne touche pas le XML : le réglage disparaît au prochain démarrage du domaine.""" try: subprocess.run( virsh_argv("dommemstat", name, "--period", "5", "--live"), capture_output=True, text=True, timeout=15, env=QemuManageMixin._qemu_c_env(), ) res = subprocess.run( virsh_argv("dommemstat", name), capture_output=True, text=True, timeout=15, env=QemuManageMixin._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): return 0, 0 stat = {} for line in (res.stdout or "").splitlines(): parts = line.split() if len(parts) == 2: try: stat[parts[0]] = int(parts[1]) except ValueError: continue total = stat.get("available", 0) usable = stat.get("usable", 0) if not total or not usable: return 0, total return max(0, total - usable), total def _qemu_list_vms_advanced(self): """Tableau détaillé par VM : état, vCPU, RAM allouée, disque (virtuel + réel), plus l'espace total disponible du stockage des images.""" names = self._qemu_list_domains() if not names: print(f"\n{t('No VM found.')}") return g = 1 << 30 # Largeurs serrées pour que la ligne tienne en 80 colonnes AVEC le nom # entier : c'est lui qui distingue les machines, et « erplibre-ubuntu- # 2604-gno » tronqué ne distingue plus rien. header = ( f"\n{'VM':<26} {'État':<8} {'vCPU':>4} {'RAM':>10} " f"{'Disque':>7} {'Réel':>7} {'Uptime':>6}" ) print(header) print("─" * len(header.strip())) disk_dirs = set() for name in names: state = self._qemu_domstate(name) or "?" vcpus, mem_kib = self._qemu_dominfo(name) disk = self._qemu_main_disk(name) virt, actual = self._qemu_disk_sizes(disk) if disk else (0, 0) if disk: disk_dirs.add(os.path.dirname(disk)) ram_g = (mem_kib * 1024) / g if mem_kib else 0 # « RAM » dit désormais l'USAGE et non la seule allocation : sur un # hyperviseur, savoir qu'une VM de 32 Go n'en occupe que 4,7 décide # s'il reste de la place pour la suivante. Deux nombres dans une # colonne plutôt que deux colonnes — le tableau tient encore sur # une ligne de terminal. used_kib, _total_kib = self._qemu_dommemstat(name) # Le total sans décimale quand il est entier — une allocation # vaut 8, 12 ou 32 Go, jamais 32,0. alloc = f"{ram_g:.0f}" if ram_g == int(ram_g) else f"{ram_g:.1f}" ram = ( f"{used_kib * 1024 / g:.1f}G/{alloc}G" if used_kib else f"-/{alloc}G" ) # L'uptime vient de l'âge du processus QEMU : libvirt ne l'expose # nulle part, et ce processus est né avec le domaine. up = self._qemu_domain_uptime(name) print( f"{name:<26.26} {state:<8.8} {vcpus:>4} " f"{ram:>10} {virt / g:>6.1f}G {actual / g:>6.1f}G " f"{self._fmt_uptime(up) if up else '-':>6}" ) # Espace total disponible sur le(s) stockage(s) des disques. for d in sorted(disk_dirs) or ["/var/lib/libvirt/images"]: try: usage = shutil.disk_usage(d) except OSError: continue print( f"\n{t('Storage')} {d} : " f"{usage.free / g:.1f}G {t('free')} / " f"{usage.total / g:.1f}G {t('total')} " f"({usage.used / g:.1f}G {t('used')})" ) def _qemu_show_ip(self): # Affiche d'abord les VM (avec leur ID) pour que l'utilisateur sache # quel nom/ID saisir, puis demande lequel (ou « all » pour toutes). self._qemu_list_vms() print() name = input(t("VM name or ID (or 'all'): ")).strip() if not name: print(t("VM name is required!")) return if name.lower() in ("all", "tous", "*"): targets = self._qemu_list_domains() if not targets: print(t("No VM found.")) return else: targets = [name] for tgt in targets: cmd = ( f"{sudo_prefix()}virsh --connect {URI} " f"domifaddr {shlex.quote(tgt)}" " --source lease" ) print(f"\n{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) def _qemu_console(self): # Liste les VM, demande laquelle, rappelle comment quitter (Ctrl+]) # puis ouvre la console série interactive. self._qemu_list_vms() print() name = input(t("VM name or ID: ")).strip() if not name: print(t("VM name is required!")) return print(f"\n💡 {t('To leave the console, press Ctrl+] (then Enter).')}") print( f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}" ) cmd = ( f"{sudo_prefix()}virsh --connect {URI} console {shlex.quote(name)}" ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) def _qemu_test_vm(self): """Teste une VM : résout son IP puis ouvre Odoo (:8069) dans un navigateur web EN LIGNE DE COMMANDE choisi par l'utilisateur.""" self._qemu_list_vms() print() name = input(t("VM name or ID: ")).strip() if not name: print(t("VM name is required!")) return real = self._qemu_domname(name) if not self._qemu_domain_exists(real): print(f"{real}: {t('VM not found.')}") return print(f"\n{t('Resolving VM IP...')}") ip = self._qemu_vm_ip(real, timeout=120) if not ip: print(t("No IP found for this VM.")) return browser = self._qemu_choose_cli_browser() if not browser: return url = f"http://{ip}:8069" print(f"→ {browser} {url}") # os.system (et NON exec_command_live) : un navigateur texte a besoin # du VRAI TTY interactif. exec_command_live redirige la sortie dans un # tube -> le navigateur ne fait qu'imprimer sans réagir au clavier. rc = os.system(f"{browser} {shlex.quote(url)}") if rc != 0: msg = t( "Page may not have loaded: Odoo not started on :8069, " "or network/firewall." ) print(f"⚠ {msg}") def _qemu_reopen_monitor(self): """Rouvre le suivi d'installation (dashboard) sur un run PASSÉ : le dernier par défaut, ou un choix dans l'historique. Utile quand le dashboard s'est fermé et qu'on veut reprendre l'analyse.""" from script.todo import qemu_install_monitor as mon runs = mon.list_install_runs() if not runs: print(t("No install run found in history.")) return print(f"\n{t('Install runs (most recent first):')}") for i, r in enumerate(runs, 1): names = ", ".join(v.get("name", "?") for v in r["vms"]) star = " *" if i == 1 else "" print( f" [{i}] {r['label']} — {len(r['vms'])} VM{star}\n" f" {names}" ) sel = input(t("Choice (number, blank = last): ")).strip() run = runs[0] if sel: try: idx = int(sel) - 1 if 0 <= idx < len(runs): run = runs[idx] else: print(t("Invalid selection.")) return except ValueError: print(t("Invalid selection.")) return self._qemu_open_monitor(run["manifest"]) def _qemu_open_monitor(self, manifest): """Ouvre le dashboard sur un manifeste, en installant Textual au besoin. Deux entrées y mènent — l'historique et la reprise proposée avant un déploiement — d'où une seule définition.""" from script.todo import qemu_install_monitor as mon try: mon.run_monitor(manifest) except ImportError: from script.todo import textual_setup if textual_setup.ensure(): mon.run_monitor(manifest) except Exception as exc: print(f"{t('Command failed: ')}{exc}") def _qemu_active_install(self): """Propose de reprendre le suivi quand une installation tourne encore. Les installs partent détachées (`setsid -f`) : fermer le terminal ne les arrête pas, mais faisait perdre la seule vue dessus, et la seule issue connue était de tout effacer pour recommencer. True si l'on ne doit PAS enchaîner sur un déploiement.""" try: from script.todo import qemu_install_monitor as mon run = mon.active_run() except Exception: return False if not run: return False names = ", ".join(v.get("name", "?") for v in run["vms"]) print( f"\n⏳ {t('An install is still running:')} {run['label']} — " f"{run['active']}/{run['total']} {t('VM(s) in progress')}" ) print(f" {names}") if run.get("idle") is not None: # Un silence prolongé trahit un run mort dont le marqueur de sortie # ne viendra jamais : l'utilisateur tranche mieux que nous. print( f" {t('Last activity:')} {mon._fmt_secs(int(run['idle']))}" ) print(f"\n [1] {t('Reopen that monitoring')} *") print(f" [2] {t('Deploy anyway (new run)')}") print(f" [0] {t('Back')}") sel = input(t("Choice (number, blank = reopen): ")).strip() if sel == "2": return False if sel != "0": self._qemu_open_monitor(run["manifest"]) return True def _qemu_choose_cli_browser(self): """Offre la LISTE des navigateurs CLI installés, plus une option pour en INSTALLER un autre, et renvoie celui choisi, sinon None.""" from script.todo.qemu_install_monitor import CLI_BROWSERS available = [b for b in CLI_BROWSERS if shutil.which(b)] if not available: return self._qemu_install_cli_browser() print(f"\n{t('Which browser to view the page?')}") for i, b in enumerate(available, 1): print(f" [{i}] {b}{' *' if i == 1 else ''}") print(f" [i] {t('Install another browser')}") sel = input(t("Choice (number, blank = first): ")).strip().lower() if sel == "i": return self._qemu_install_cli_browser() if not sel: return available[0] try: idx = int(sel) - 1 if 0 <= idx < len(available): return available[idx] except ValueError: pass return available[0] def _qemu_install_cli_browser(self): """Demande QUEL navigateur CLI installer, affiche la commande adaptée à l'OS, l'exécute après validation. Renvoie le binaire installé ou None.""" from script.todo.qemu_install_monitor import ( INSTALLABLE_BROWSERS, browser_install_command, ) print(f"\n{t('Which browser to install?')}") for i, (b, desc) in enumerate(INSTALLABLE_BROWSERS, 1): print(f" [{i}] {desc}{' *' if i == 1 else ''}") sel = input(t("Choice (number, blank = w3m): ")).strip() browser = INSTALLABLE_BROWSERS[0][0] try: idx = int(sel) - 1 if 0 <= idx < len(INSTALLABLE_BROWSERS): browser = INSTALLABLE_BROWSERS[idx][0] except ValueError: pass cmd = browser_install_command(browser) if not cmd: print(t("Unknown package manager; install it manually.")) return None printable = " ".join(cmd) print(f"{t('Command:')} {printable}") if not self._is_yes(input(t("Install now? (y/N): "))): return None os.system(printable) return browser if shutil.which(browser) else None # ------------------------------------------------------------------ # # Redimensionnement du disque d'une VM # ------------------------------------------------------------------ # # « » couvre les disques, les cdrom ET les # « » d'une chaîne. Le nvram porte un attribut # « template » : l'attendre sans attribut le manquait, et c'est le # fichier le plus facile à perdre. _RE_SOURCE_FILE = re.compile(r"]*>([^<]+)") _RE_LIBVIRT_PATH = re.compile(r"""/var/lib/libvirt/[^,\0\s'"]+""") def _qemu_referenced_files(self, domains=None): """{chemin: domaine} — TOUT ce que les domaines référencent. L'autorité est libvirt, jamais le nom du fichier. Un domaine renommé garde le nom de fichier d'avant : juger sur le nom fait passer le disque d'une VM EN MARCHE pour un orphelin, et offre ses trois fichiers — disque, seed, nvram — au « rm -f ». Les deux vues, persistante et vivante, pour la raison dite dans `_qemu_dumpxml`. """ refs = {} for nom in ( domains if domains is not None else self._qemu_list_domains() ): for inactive in (True, False): xml = self._qemu_dumpxml(nom, inactive=inactive) if not xml: continue trouves = self._RE_SOURCE_FILE.findall( xml ) + self._RE_NVRAM.findall(xml) for chemin in trouves: refs.setdefault(chemin.strip(), nom) return refs @classmethod def _qemu_files_in_use(cls): """Chemins de /var/lib/libvirt cités par un processus EN COURS. Contrôle INDÉPENDANT de libvirt : un qemu lancé à la main, ou une définition que libvirt aurait perdue, tient son disque ouvert quand même. Devant un « rm -f » de 63 Go, deux sources valent mieux qu'une. Sans privilège : /proc//cmdline se lit, et le qemu d'une VM y porte ses disques, son seed et son nvram. """ vus = set() for entree in glob.glob("/proc/[0-9]*/cmdline"): try: with open(entree, "rb") as fh: brut = fh.read().decode("utf-8", "replace") except OSError: continue for chemin in cls._RE_LIBVIRT_PATH.findall(brut): # Un chemin qui n'existe pas n'est pas un fichier tenu # ouvert : la ligne de commande d'un processus quelconque # peut contenir n'importe quoi (ce script lui-même y met son # motif). On ne protège que du réel. if os.path.exists(chemin): vus.add(chemin) return vus def _qemu_vm_own_files(self, name): """Fichiers de CETTE VM, et d'elle seule : disques et seed. Demandés à libvirt, jamais déduits du nom. Une VM renommée garde le nom de fichier d'avant : « rm .qcow2 » ne trouvait alors rien et laissait 63 Go derrière lui — le même défaut que dans le nettoyage, pris par l'autre bout. Un fichier partagé avec un AUTRE domaine (image de fond d'une chaîne de qcow2) est écarté : l'effacer creverait la voisine. Le nvram aussi, parce que « virsh undefine --nvram » s'en charge déjà. """ miens = set(self._qemu_referenced_files([name])) voisins = set( self._qemu_referenced_files( [d for d in self._qemu_list_domains() if d != name] ) ) return sorted( chemin for chemin in miens - voisins if chemin.startswith("/var/lib/libvirt/") and not chemin.endswith(".fd") ) def _qemu_split_orphans(self, candidats): """(orphelins, protégés) — un fichier référencé n'est JAMAIS orphelin. `candidats` et le retour sont des (taille, chemin, motif). Les protégés portent, à la place du motif, ce qui les retient. """ refs = self._qemu_referenced_files() ouverts = self._qemu_files_in_use() orphelins, proteges = [], [] for taille, chemin, motif in candidats: porteur = refs.get(chemin) if not porteur and chemin in ouverts: porteur = t("a running process") if porteur: proteges.append((taille, chemin, porteur)) else: orphelins.append((taille, chemin, motif)) return orphelins, proteges @staticmethod def _qemu_c_env(): """Environnement forçant LC_ALL=C : la sortie des outils (virsh, sgdisk, resize2fs, dumpe2fs…) reste en ANGLAIS quelle que soit la locale de l'hôte. Sinon « running » devient « en cours d'exécution » (fr) et les comparaisons/parsing d'état cassent.""" env = dict(os.environ) env["LC_ALL"] = "C" env["LANG"] = "C" return env @staticmethod def _qemu_domstate(name): """État libvirt de la VM (« running », « shut off », …) ou ''.""" try: res = subprocess.run( virsh_argv("domstate", name), capture_output=True, text=True, timeout=15, env=QemuManageMixin._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): return "" return res.stdout.strip() if res.returncode == 0 else "" @staticmethod def _qemu_domname(name): """Nom canonique de la VM (si on a fourni un ID numérique, le résout ; sinon renvoie tel quel). Utile car un ID disparaît une fois la VM éteinte.""" if not str(name).isdigit(): return name try: res = subprocess.run( virsh_argv("domname", str(name)), capture_output=True, text=True, timeout=15, env=QemuManageMixin._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): return name out = res.stdout.strip() return out if res.returncode == 0 and out else name def _qemu_shutdown_wait(self, name, timeout=120): """Arrête la VM par SIGNAL (ACPI power-button, puis agent invité) et attend qu'elle soit « shut off » en affichant le temps restant du timeout. Si l'arrêt gracieux traîne, propose un arrêt forcé (destroy). Renvoie True si la VM est bien éteinte.""" name = self._qemu_domname(name) if self._qemu_domstate(name) == "shut off": return True # --mode acpi,agent : envoie le SIGNAL d'extinction (bouton ACPI) puis # tente l'agent invité si présent — plus fiable qu'un arrêt brutal. cmd = ( f"{sudo_prefix()}virsh --connect {URI} " f"shutdown {shlex.quote(name)}" " --mode acpi,agent" ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) print( f"{t('Waiting for the VM to shut down...')} " f"({t('timeout')}: {timeout} s)" ) deadline = time.time() + timeout while time.time() < deadline: if self._qemu_domstate(name) == "shut off": # Efface la ligne de compte à rebours puis confirme. print(f"\r{' ' * 40}\r✅ {name}: {t('VM is off.')}") return True remaining = int(deadline - time.time()) print( f"\r ⏳ {t('shutting down')}… " f"{remaining:>3d} s {t('remaining')}", end="", flush=True, ) time.sleep(2) print() # newline après le compte à rebours # Arrêt gracieux trop long : proposer un arrêt forcé. if self._is_yes( input( t("Graceful shutdown timed out. Force off (destroy)? (y/N): ") ) ): cmd = ( f"{sudo_prefix()}virsh --connect {URI} " f"destroy {shlex.quote(name)}" ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) time.sleep(2) return self._qemu_domstate(name) == "shut off" return False @staticmethod def _qemu_main_disk(name): """Chemin du disque PRINCIPAL (qcow2) de la VM via domblklist. On ignore le seed cloud-init (…-seed.iso, en lecture seule).""" try: res = subprocess.run( virsh_argv("domblklist", name, "--details"), capture_output=True, text=True, timeout=15, env=QemuManageMixin._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): return None disks = [] for line in res.stdout.splitlines(): parts = line.split() # Colonnes : Type Device Target Source if len(parts) >= 4 and parts[1] == "disk" and parts[3] != "-": disks.append(parts[3]) # Le disque de travail est le .qcow2 (le seed est un .iso). for d in disks: if d.endswith(".qcow2"): return d return disks[0] if disks else None @staticmethod def _qemu_disk_virtual_bytes(disk): """Taille VIRTUELLE (octets) du disque via « qemu-img info --json ».""" try: # -U (--force-share) : lit même si la VM tourne (libvirt tient le # lock d'écriture). Sans ça : « Failed to get shared write lock ». res = subprocess.run( ["sudo", "qemu-img", "info", "-U", "--output=json", disk], capture_output=True, text=True, timeout=20, ) data = json.loads(res.stdout) return int(data.get("virtual-size", 0)) except (OSError, subprocess.SubprocessError, ValueError): return 0 def _qemu_resize_disk(self): """Redimensionne le disque d'une VM : affiche l'espace actuel, demande +NG / -NG / taille cible, applique (à chaud si possible pour agrandir), puis propose d'étendre le système de fichiers invité.""" self._qemu_list_vms() print() name = input(t("VM name to resize: ")).strip() if not name: print(t("VM name is required!")) return if not self._qemu_domain_exists(name): print(f"{name}: {t('VM not found.')}") return # Résout tout de suite le NOM canonique (VM encore allumée -> l'ID est # résoluble). Après extinction, un ID numérique disparaît : « virsh # start 32 » échouerait. On travaille désormais avec le nom. name = self._qemu_domname(name) disk = self._qemu_main_disk(name) if not disk: print(t("Main disk not found for this VM.")) return # 1) Espace actuel (virtuel + réel) + df invité si joignable. print(f"\n{t('Current disk:')} {disk}") # -U : lecture sûre même VM allumée (sinon « shared write lock »). self.execute.exec_command_live( f"{sudo_prefix()}qemu-img info -U {shlex.quote(disk)}", source_erplibre=False, ) cur_bytes = self._qemu_disk_virtual_bytes(disk) cur_gb = cur_bytes / (1 << 30) state = self._qemu_domstate(name) if cur_bytes <= 0: print(t("Could not read current disk size; aborting.")) print(f"{t('VM state:')} {state or '?'}") return print(f"{t('Current virtual size:')} {cur_gb:.1f} G") print(f"{t('VM state:')} {state or '?'}") # Espace HÔTE : le qcow2 est creux (sparse), donc on PEUT fixer une # taille virtuelle plus grande que l'espace réel — mais si la VM la # remplit, l'hôte tombe à court. Max « soutenable » ≈ taille réelle # actuelle + espace libre de l'hôte. On l'AFFICHE (avertissement, pas # de blocage) pour guider le choix. g = 1 << 30 _virt, actual = self._qemu_disk_sizes(disk) try: free = shutil.disk_usage(os.path.dirname(disk)).free except OSError: free = 0 max_safe_gb = (actual + free) / g if free else 0 if max_safe_gb: print( f"{t('Host free space:')} {free / g:.1f} G · " f"{t('max sustainable total (before host full):')} " f"~{max_safe_gb:.1f} G" ) # 2) Nouvelle taille : +NG (agrandir), -NG (réduire) ou NG (cible). guide = t( "Enter +NG to grow, -NG to shrink, or NG for a target size " "(e.g. +20G, -10G, 60G)." ) print(f"\n{guide}") raw = input(t("Resize: ")).strip().upper().replace("G", "") try: if raw.startswith("+"): new_gb = cur_gb + float(raw[1:]) elif raw.startswith("-"): new_gb = cur_gb - float(raw[1:]) else: new_gb = float(raw) except ValueError: print(t("Invalid size.")) return if new_gb <= 0: print(t("Invalid size.")) return new_gb = round(new_gb, 1) if abs(new_gb - cur_gb) < 0.05: print(t("No change.")) return shrink = new_gb < cur_gb print(f"\n{t('New virtual size:')} {cur_gb:.1f} G -> {new_gb:.1f} G") # Avertissement (NON bloquant) : agrandir au-delà de ce que l'hôte # peut soutenir -> surallocation, l'hôte se remplira si la VM utilise # tout l'espace. if not shrink and max_safe_gb and new_gb > max_safe_gb: over = new_gb - max_safe_gb msg1 = t("Beyond host capacity by ~%.1f G — overcommit.") % over msg2 = ( t( "The qcow2 is thin: fine until the VM fills it, then the " "host disk runs out. Max sustainable: ~%.1f G." ) % max_safe_gb ) print(f"⚠ {msg1}") print(f" {msg2}") # 3) Application selon agrandir/réduire et l'état de la VM. was_shut_down = False # la VM a-t-elle été éteinte pour l'occasion ? cmd = ( None # commande d'AGRANDISSEMENT (la réduction a son propre flux) ) if shrink: # DANGER : qcow2 --shrink ne réduit PAS le FS invité -> perte de # données si le FS dépasse la cible. VM éteinte obligatoire. danger = t( "SHRINKING is DANGEROUS: the guest filesystem is NOT shrunk. " "Data beyond the new size is LOST. Shrink the guest FS FIRST, " "and only then shrink here." ) print(f"⚠ {danger}") if state != "shut off": if not self._is_yes( input( t( "The VM must be off. Shut it down and retry? " "(y/N): " ) ) ): print(t("Cancelled.")) return if not self._qemu_shutdown_wait(name): print(t("VM is still not off; aborting.")) return state = "shut off" was_shut_down = True if not self._is_yes( input(t("Type y to confirm you understand the risk (y/N): ")) ): print(t("Cancelled.")) return # Réduction SÛRE (qemu-nbd : réduit FS + partition + GPT, avec # sauvegarde optionnelle restaurée en cas d'échec). if not self._qemu_safe_shrink(name, disk, new_gb): self._qemu_offer_start(name, was_shut_down) return elif state == "running": # Agrandissement À CHAUD : le disque virtuel grossit, le FS invité # devra être étendu ensuite. cmd = ( f"{sudo_prefix()}virsh --connect {URI} " f"blockresize {shlex.quote(name)} " f"{shlex.quote(disk)} {new_gb:g}G" ) else: cmd = ( f"{sudo_prefix()}qemu-img resize" f" {shlex.quote(disk)} {new_gb:g}G" ) # 4) Agrandissement : exécuter la commande + proposer d'étendre le FS. if cmd is not None: print(f"{t('Will execute:')} {cmd}") if self.execute.exec_command_live(cmd, source_erplibre=False) != 0: print(f"❌ {t('Resize failed (see error above).')}") return print(f"✅ {t('Virtual disk resized.')}") if self._is_yes( input(t("Grow the guest filesystem now (over SSH)? (y/N): ")) ): self._qemu_grow_guest_fs(name) # 5) La VM a été éteinte pour l'opération : proposer de la redémarrer # (l'utilisateur peut ainsi TESTER avant de décider du backup). self._qemu_offer_start(name, was_shut_down) # 6) Réduction réussie AVEC sauvegarde : proposer de l'effacer une fois # la VM testée (défaut : NON -> on garde le backup par prudence). bak = getattr(self, "_shrink_backup", None) if shrink and bak and os.path.exists(bak): print(f"\n{t('A disk backup was kept:')} {bak}") if self._is_yes(input(t("Delete this backup now? (y/N): "))): subprocess.run(["sudo", "rm", "-f", bak], check=False) print(t("Backup deleted.")) else: print(t("Backup kept (delete later via Clean up QEMU).")) self._shrink_backup = None def _qemu_offer_start(self, name, was_shut_down): """Si la VM a été éteinte pour l'opération, le noter et proposer de la redémarrer (sinon ne rien demander).""" if not was_shut_down: return print(f"\nℹ {t('The VM was shut down for the resize.')}") if self._is_yes(input(t("Start the VM now? (y/N): "))): # `name` est déjà le nom canonique : « virsh start » # échouerait car l'ID disparaît quand la VM est éteinte. cmd = ( f"{sudo_prefix()}virsh --connect {URI} " f"start {shlex.quote(name)}" ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) # Le nom du binaire n'est presque jamais celui du paquet. Ces cinq-là # portent le même nom dans les quatre familles. _SHRINK_PKG = { "e2fsck": "e2fsprogs", "resize2fs": "e2fsprogs", "dumpe2fs": "e2fsprogs", "partprobe": "parted", "lsblk": "util-linux", "blockdev": "util-linux", } # Les deux qui changent de famille en famille : sgdisk vit dans « gdisk » # chez Debian et Fedora, dans « gptfdisk » chez Arch et openSUSE, et # qemu-nbd porte quatre noms de paquet différents. _SHRINK_PKG_FAMILY = { "apt-get": {"sgdisk": "gdisk", "qemu-nbd": "qemu-utils"}, "dnf": {"sgdisk": "gdisk", "qemu-nbd": "qemu-img"}, "pacman": {"sgdisk": "gptfdisk", "qemu-nbd": "qemu-img"}, "zypper": {"sgdisk": "gptfdisk", "qemu-nbd": "qemu-tools"}, } def _qemu_install_shrink_tools(self, manquants): """Poser les paquets qui fournissent les outils manquants. Rend la liste de ce qui manque ENCORE, relue sur le disque : vide si tout est là. Un refus, un gestionnaire de paquets inconnu ou une installation en échec la rendent non vide, et l'appelant renonce. """ paquets, inconnus = todo_install.resolve( manquants, commun=self._SHRINK_PKG, par_famille=self._SHRINK_PKG_FAMILY, ) if inconnus: print( f" ⚠ {t('No package known here for:')} {', '.join(inconnus)}" ) status = todo_install.ask_and_install( self.execute, todo_install.install_command(paquets), t("Install them? (y/N): "), self._is_yes, ) if status: print(f" {t('Error installing the tools: ')}{status}") reste = [b for b in self._SHRINK_TOOLS if not shutil.which(b)] if not reste: # Sans cette ligne, la sortie du gestionnaire de paquets est # suivie directement de la question suivante, qui porte sur tout # autre chose : rien ne dit que l'installation a abouti ni qu'on # a changé d'étape. print(f" ✅ {t('Tools installed; on with the shrink.')}") return reste @staticmethod def _qemu_backup_need_and_free(disk): """(besoin, libre) en octets pour la copie de sauvegarde du disque. Le besoin est la taille ALLOUÉE et non la taille apparente : « cp --sparse=always » ne recopie pas les trous d'un qcow2. C'est une borne haute — « --reflink=auto » rend la copie presque gratuite sur btrfs et XFS — mais rien ne garantit le reflink, et se tromper par excès est le bon sens ici : une copie qui manque de place s'arrête à mi-chemin et laisse un .bak tronqué. """ besoin = os.stat(disk).st_blocks * 512 libre = shutil.disk_usage(os.path.dirname(disk) or ".").free return besoin, libre def _qemu_ask_backup(self, disk): """Proposer la sauvegarde du disque, chiffres en main. True si oui. Les deux tailles passent AVANT la question : une copie qui ne tient pas s'arrête à mi-course et laisse un .bak tronqué sur un système de fichiers désormais plein. Quand la place manque, le défaut bascule à NON — une entrée distraite ne doit pas remplir le disque — sans pour autant décider à la place de l'opérateur, qui peut insister. """ besoin, libre = self._qemu_backup_need_and_free(disk) print( f"\n{t('A backup doubles the space used:')}" f" {self._human_size(besoin)} — {t('free here:')}" f" {self._human_size(libre)}" ) if libre > besoin * 1.05: return self._is_yes_default_yes( input(t("Back up the disk before shrinking? (Y/n): ")) ) print(f"⚠ {t('Not enough free space for a full backup.')}") return self._is_yes( input( t("Back up anyway, at the risk of filling the disk? (y/N): ") ) ) def _qemu_safe_shrink(self, name, disk, new_gb): """Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs + sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition, puis le conteneur qcow2, puis on répare la GPT de secours. Une COPIE .bak est faite AVANT ; en cas d'échec on RESTAURE -> jamais de corruption. ext2/3/4 uniquement. Renvoie True si réduit.""" import math missing = [b for b in self._SHRINK_TOOLS if not shutil.which(b)] if missing: print( f"{t('Missing tools for safe shrink:')} {', '.join(missing)}" ) missing = self._qemu_install_shrink_tools(missing) if missing: print( f"{t('Still missing, safe shrink cancelled:')}" f" {', '.join(missing)}" ) return False target = int(round(new_gb * (1 << 30))) # Sauvegarde OPTIONNELLE (défaut OUI) : permet de restaurer en cas # d'échec, et de tester la VM avant de la supprimer (proposé à la fin). self._shrink_backup = None bak = None if self._qemu_ask_backup(disk): bak = f"{disk}.bak" print(f"\n{t('Backing up the disk before shrinking…')}") if ( subprocess.run( [ "sudo", "cp", "--reflink=auto", "--sparse=always", disk, bak, ] ).returncode != 0 ): print(t("Backup failed; aborting.")) return False else: print( f"⚠ {t('No backup: a failure could leave the disk broken.')}" ) subprocess.run(["sudo", "modprobe", "nbd", "max_part=16"], check=False) dev = None try: dev = self._qemu_nbd_connect(disk) if not dev: print(t("Could not attach the disk (nbd); aborting.")) return self._qemu_shrink_revert(bak, disk, changed=False) part, start, fstype = self._qemu_root_part(dev) if not part: print(t("Could not detect the partition to shrink; aborting.")) return self._qemu_shrink_revert(bak, disk, changed=False) if not fstype.startswith("ext"): print( f"{t('Only ext2/3/4 can be shrunk safely; aborting.')}" f" ({fstype})" ) return self._qemu_shrink_revert(bak, disk, changed=False) n = self._qemu_part_number(dev, part) info = self._qemu_part_info(dev, n) # fsck AVANT toute opération. subprocess.run(["sudo", "e2fsck", "-f", "-y", part], check=False) bs = self._qemu_fs_blocksize(part) # Cibles (octets), en gardant 2 Mio pour la GPT de secours + marge. part_start_b = start * self._SECT max_fs_b = target - part_start_b - 4 * self._MiB if max_fs_b <= 0: print(t("Target size too small for this layout; aborting.")) return self._qemu_shrink_revert(bak, disk, changed=False) min_blocks = self._qemu_fs_min_blocks(part) if min_blocks and min_blocks * bs > max_fs_b: print(t("Not enough used-space margin to shrink; aborting.")) return self._qemu_shrink_revert(bak, disk, changed=False) fs_target_mib = max_fs_b // self._MiB print( f"\n{t('Shrinking guest ext filesystem')} {part} " f"-> {fs_target_mib} MiB…" ) if ( subprocess.run( ["sudo", "resize2fs", part, f"{fs_target_mib}M"] ).returncode != 0 ): print(t("resize2fs failed; reverting.")) return self._qemu_shrink_revert(bak, disk, changed=True) # Fin de partition = début + taille RÉELLE du FS + 1 Mio, alignée. fs_bytes = self._qemu_fs_blocks(part) * bs new_end = start + int( math.ceil((fs_bytes + self._MiB) / self._SECT) ) new_end = ((new_end + 2047) // 2048) * 2048 - 1 # align 2048 if (new_end + 34) * self._SECT > target: print(t("Internal size check failed; reverting.")) return self._qemu_shrink_revert(bak, disk, changed=True) # Réécrit la partition (mêmes type/UUID/nom -> PARTUUID préservé). print(f"{t('Shrinking the partition…')} ({part})") subprocess.run(["sudo", "sgdisk", "-d", n, dev], check=False) rc = subprocess.run( [ "sudo", "sgdisk", "-n", f"{n}:{start}:{new_end}", "-t", f"{n}:{info['type']}", "-u", f"{n}:{info['uuid']}", "-c", f"{n}:{info['name']}", dev, ] ).returncode if rc != 0: print(t("Partition rewrite failed; reverting.")) return self._qemu_shrink_revert(bak, disk, changed=True) subprocess.run( ["sudo", "partprobe", dev], check=False, capture_output=True ) # Détache puis tronque le conteneur qcow2. self._qemu_nbd_disconnect(dev) dev = None print(f"{t('Shrinking the qcow2 container…')} {new_gb:g}G") if ( subprocess.run( [ "sudo", "qemu-img", "resize", "--shrink", disk, f"{new_gb:g}G", ] ).returncode != 0 ): print(t("Container shrink failed; reverting.")) return self._qemu_shrink_revert(bak, disk, changed=True) # Répare la GPT de secours (fin du disque) + fsck final. dev = self._qemu_nbd_connect(disk) if dev: subprocess.run(["sudo", "sgdisk", "-e", dev], check=False) subprocess.run( ["sudo", "partprobe", dev], check=False, capture_output=True, ) p2 = self._qemu_root_part(dev)[0] if p2: subprocess.run( ["sudo", "e2fsck", "-f", "-y", p2], check=False ) self._qemu_nbd_disconnect(dev) dev = None self._shrink_backup = bak # proposé à la suppression après le boot if bak: print(f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}") else: print(f"✅ {t('Disk safely shrunk.')}") return True finally: if dev: self._qemu_nbd_disconnect(dev) def _qemu_shrink_revert(self, bak, disk, changed): """Restaure le disque depuis la sauvegarde si on l'a modifié (changed) et qu'une sauvegarde existe ; sinon retire la sauvegarde inutile. Renvoie False (la réduction a échoué).""" if changed and bak: print(t("Restoring the original disk from backup…")) subprocess.run(["sudo", "mv", "-f", bak, disk], check=False) elif changed and not bak: print( f"⚠ {t('No backup to restore; run fsck on the disk before use.')}" ) elif bak: subprocess.run(["sudo", "rm", "-f", bak], check=False) return False @staticmethod def _qemu_nbd_connect(disk): """Attache `disk` à un /dev/nbdN libre et renvoie le chemin, ou None. Attend que les sous-périphériques de partition (nbdNpM) APPARAISSENT (sinon lsblk/resize2fs ne voient rien juste après le connect).""" for i in range(16): dev = f"/dev/nbd{i}" # /sys/block/nbdN/pid absent => device libre. if os.path.exists(f"/sys/block/nbd{i}/pid"): continue rc = subprocess.run( ["sudo", "qemu-nbd", "-c", dev, disk], capture_output=True, text=True, ).returncode if rc != 0: continue base = f"nbd{i}" for _ in range(15): subprocess.run( ["sudo", "partprobe", dev], check=False, capture_output=True, ) time.sleep(1) if any( os.path.exists(f"/sys/class/block/{base}p{n}") for n in range(1, 32) ): break return dev return None @staticmethod def _qemu_nbd_disconnect(dev): subprocess.run(["sudo", "qemu-nbd", "-d", dev], check=False) time.sleep(1) @staticmethod def _qemu_root_part(dev): """(partition la plus grosse, secteur de début, type FS) du disque nbd. (None, 0, '') si introuvable. Format lsblk -P (paires) : robuste aux colonnes VIDES — juste après le connect, FSTYPE peut être vide, et un parsing positionnel décalait/ignorait alors toutes les partitions.""" import re try: res = subprocess.run( ["lsblk", "-Pbno", "NAME,SIZE,TYPE,FSTYPE", dev], capture_output=True, text=True, timeout=30, ) except (OSError, subprocess.SubprocessError): return None, 0, "" best, best_sz, best_fs = None, -1, "" for line in res.stdout.splitlines(): d = dict(re.findall(r'(\w+)="([^"]*)"', line)) if d.get("TYPE") != "part": continue try: size = int(d.get("SIZE") or 0) except ValueError: size = 0 if size > best_sz: best, best_sz, best_fs = ( d.get("NAME"), size, d.get("FSTYPE", ""), ) if not best: return None, 0, "" part = f"/dev/{best}" try: start = int(open(f"/sys/class/block/{best}/start").read().strip()) except OSError: start = 0 if not best_fs: # FSTYPE pas encore en cache : sonder directement avec blkid. best_fs = subprocess.run( ["sudo", "blkid", "-o", "value", "-s", "TYPE", part], capture_output=True, text=True, ).stdout.strip() return part, start, best_fs @staticmethod def _qemu_part_number(dev, part): """Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1.""" return part[len(dev) :].lstrip("p") @staticmethod def _qemu_part_info(dev, n): """{type, uuid, name} d'une partition via « sgdisk -i ».""" info = {"type": "", "uuid": "", "name": ""} res = subprocess.run( ["sudo", "sgdisk", "-i", n, dev], capture_output=True, text=True, env=QemuManageMixin._qemu_c_env(), ) for line in res.stdout.splitlines(): low = line.lower() if low.startswith("partition guid code"): info["type"] = line.split(":", 1)[1].split()[0] elif low.startswith("partition unique guid"): info["uuid"] = line.split(":", 1)[1].strip() elif low.startswith("partition name"): info["name"] = line.split(":", 1)[1].strip().strip("'") return info @staticmethod def _qemu_fs_blocksize(part): res = subprocess.run( ["sudo", "dumpe2fs", "-h", part], capture_output=True, text=True, env=QemuManageMixin._qemu_c_env(), ) for line in res.stdout.splitlines(): if line.startswith("Block size:"): try: return int(line.split(":", 1)[1].strip()) except ValueError: pass return 4096 @staticmethod def _qemu_fs_blocks(part): res = subprocess.run( ["sudo", "dumpe2fs", "-h", part], capture_output=True, text=True, env=QemuManageMixin._qemu_c_env(), ) for line in res.stdout.splitlines(): if line.startswith("Block count:"): try: return int(line.split(":", 1)[1].strip()) except ValueError: pass return 0 @staticmethod def _qemu_fs_min_blocks(part): """Taille minimale (blocs) du FS via « resize2fs -P ».""" res = subprocess.run( ["sudo", "resize2fs", "-P", part], capture_output=True, text=True, env=QemuManageMixin._qemu_c_env(), ) for tok in res.stdout.replace(":", " ").split(): if tok.isdigit(): return int(tok) return 0 def _qemu_grow_guest_fs(self, name): """Étend la partition racine + le FS invité. Essaie SSH (IP résolue avec BATTEMENT, le boot émulé étant lent) ; en cas d'absence d'IP ou d'échec SSH, propose le repli par CONSOLE SÉRIE (commande à coller).""" remote = self._GROW_FS_REMOTE real = self._qemu_domname(name) # 1) SSH : IP résolue avec BATTEMENT (parallèle, boot émulé lent) # plutôt qu'un simple timeout court qui abandonnait trop tôt. ip = self._qemu_resolve_ips([real], timeout=300).get(real) if ip: opts = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " "-o ConnectTimeout=15" ) cmd = f"ssh {opts} erplibre@{ip} {shlex.quote(remote)}" print(f"{t('Will execute:')} {cmd}") if self.execute.exec_command_live(cmd, source_erplibre=False) == 0: return print(f"⚠ {t('SSH grow failed; trying the guest agent.')}") else: print(t("No IP; trying the guest agent (no network).")) # 2) Agent invité (virtio, SANS réseau) — nécessite qemu-guest-agent # dans la VM (installé au déploiement) + guest-exec autorisé. res = self._qemu_guest_exec(real, remote) if res is not None: rc, out = res if out.strip(): print(out.rstrip()) if rc == 0: print(f"✅ {t('Guest filesystem grown via guest agent.')}") return print( f"⚠ {t('Guest agent grow failed; falling back to console.')}" ) else: print( t("Guest agent unavailable; falling back to serial console.") ) # 3) Console série (commande prête à coller, login interactif). self._qemu_grow_via_console(real, remote) def _qemu_guest_exec(self, name, script, wait=180): """Exécute `script` (sh -c) DANS la VM via l'AGENT INVITÉ (canal virtio, sans réseau). Renvoie (code_sortie, sortie) ou None si l'agent est indisponible / guest-exec refusé.""" import base64 def agent(payload): try: res = subprocess.run( virsh_argv( "qemu-agent-command", name, json.dumps(payload) ), capture_output=True, text=True, timeout=30, ) except (OSError, subprocess.SubprocessError): return None if res.returncode != 0: return None try: return json.loads(res.stdout).get("return") except ValueError: return None if agent({"execute": "guest-ping"}) is None: return None start = agent( { "execute": "guest-exec", "arguments": { "path": "/bin/sh", "arg": ["-c", script], "capture-output": True, }, } ) if not start or "pid" not in start: return None pid = start["pid"] deadline = time.time() + wait print(t("Running via guest agent (no network)…")) while time.time() < deadline: st = agent( {"execute": "guest-exec-status", "arguments": {"pid": pid}} ) if st and st.get("exited"): out = "" for k in ("out-data", "err-data"): if st.get(k): try: out += base64.b64decode(st[k]).decode( errors="replace" ) except Exception: pass return st.get("exitcode", 0), out time.sleep(2) return None def _qemu_grow_via_console(self, name, remote): """Repli console série : affiche la commande prête à coller puis ouvre la console (login interactif erplibre/erplibre — pas d'automatisation fiable de la saisie).""" print(f"\n{t('Serial console fallback. Log in, then paste:')}") print(f"\n {remote}\n") print(f"💡 {t('To leave the console, press Ctrl+] (then Enter).')}") print( f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}" ) if not self._is_yes(input(t("Open the serial console now? (y/N): "))): return cmd = ( f"{sudo_prefix()}virsh --connect {URI} console {shlex.quote(name)}" ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) def _qemu_list_domains(self): """Noms des VM libvirt définies (via virsh).""" try: res = subprocess.run( virsh_argv("list", "--all", "--name"), capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.SubprocessError): return [] return [n for n in res.stdout.split() if n.strip()] def _qemu_delete_vm(self): """Efface une ou plusieurs VM (arrêt + undefine), disques en option.""" self._qemu_list_vms() print() names = self._qemu_list_domains() if not names: print(t("No VM found.")) return print(f"\n{t('Select VMs to delete:')}") for i, n in enumerate(names, 1): print(f" [{i}] {n}") print(f" [all] {t('select all')}") raw = input(t("Selection (numbers, or 'all'): ")).strip() if not raw: print(t("Nothing selected.")) return if raw.lower() in ("all", "*"): chosen = list(names) else: chosen = self._parse_index_selection(raw.lower(), names) if not chosen: print(t("Nothing selected.")) return del_disks = self._is_yes( input(t("Also delete disk images (qcow2 + seed ISO)? (y/N): ")) ) print(f"\n{t('Will delete:')} {', '.join(chosen)}") if del_disks: print(f" + {t('disk images and seed ISOs')}") else: print(f" ({t('disks kept')})") if not self._is_yes(input(t("Confirm deletion? (y/N): "))): print(t("Cancelled.")) return for name in chosen: q = shlex.quote(name) # Les fichiers AVANT l'undefine : après, plus de XML à lire. fichiers = self._qemu_vm_own_files(name) if del_disks else [] # Éteindre si en cours, puis retirer la définition (+ nvram si # UEFI ; repli sans l'option pour les vieilles versions de virsh). cmd = ( f"{sudo_prefix()}virsh --connect {URI} " f"destroy {q} 2>/dev/null; " f"{sudo_prefix()}virsh --connect {URI} " f"undefine {q} --nvram 2>/dev/null " f"|| {sudo_prefix()}virsh --connect {URI} undefine {q}" ) if del_disks and fichiers: cmd += "; sudo rm -f " + " ".join( shlex.quote(f) for f in fichiers ) elif del_disks: # Rien à effacer : le dire, plutôt que de laisser croire que # la place a été rendue. print(f" ⚠ {name} : {t('no disk file found for this VM')}") print(f"\n▶ {name}: {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) # Une exception du cache survit à la VM qu'elle nommait, et une MAC # libérée se réattribue : l'exception soustrairait alors au cache une # machine neuve, sans que personne l'ait demandé et sans que rien ne le # dise. Le ménage est fait ICI parce que c'est le seul endroit qui SAIT # que la VM vient de disparaître. retirees = bypass_menage(self.execute) if retirees: print(f" {t('Cache exceptions removed:')} {retirees}") print(f"\n✅ {t('Deletion done.')}") @staticmethod def _qemu_find_files(directory, pattern): """(taille, chemin) des fichiers du répertoire (via sudo find).""" try: res = subprocess.run( [ "sudo", "find", directory, "-maxdepth", "1", "-type", "f", "-name", pattern, "-printf", "%s\t%p\n", ], capture_output=True, text=True, timeout=20, ) except (OSError, subprocess.SubprocessError): return [] out = [] for line in res.stdout.splitlines(): if "\t" in line: size, path = line.split("\t", 1) out.append((int(size), path)) return out def _cleanup_delete_files(self, title, items, prompt): """items : [(taille, chemin)]. Liste, confirme, puis « sudo rm -f ».""" if not items: return total = sum(s for s, _ in items) print( f"\n{title} — {self._human_size(total)}, " f"{len(items)} {t('files')} :" ) for size, path in sorted(items, key=lambda o: -o[0]): print(f" {self._human_size(size):>9} {path}") if not self._is_yes(input(prompt)): print(t("Cancelled.")) return paths = " ".join(shlex.quote(p) for _, p in items) cmd = f"sudo rm -f {paths}" print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) print(f"✅ {t('Cleanup done.')}") def _qemu_domain_macs(self): """MACs de toutes les VM définies (pour repérer les baux périmés).""" macs = set() for name in self._qemu_list_domains(): try: res = subprocess.run( virsh_argv("domiflist", name), capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.SubprocessError): continue macs.update( m.lower() for m in re.findall( r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}", res.stdout ) ) return macs def _qemu_cleanup(self): """Repère les restes QEMU orphelins et propose de les effacer.""" print(f"🧹 {t('Scanning for orphan QEMU files...')}") disk_dir = "/var/lib/libvirt/images" seed_dir = "/var/lib/libvirt/images/iso" nvram_dir = "/var/lib/libvirt/qemu/nvram" domains = set(self._qemu_list_domains()) # 1) Fichiers orphelins : disques / seeds / .part / nvram. orphans = [] # (taille, chemin, motif) for size, path in self._qemu_find_files(disk_dir, "*.qcow2"): if os.path.basename(path)[: -len(".qcow2")] not in domains: orphans.append((size, path, t("orphan disk"))) for size, path in self._qemu_find_files(seed_dir, "*-seed.iso"): if os.path.basename(path)[: -len("-seed.iso")] not in domains: orphans.append((size, path, t("orphan seed"))) for size, path in self._qemu_find_files(seed_dir, "*.part"): orphans.append((size, path, t("partial download"))) for size, path in self._qemu_find_files(nvram_dir, "*"): stem = re.sub(r"(_VARS)?\.fd$", "", os.path.basename(path)) if stem not in domains: orphans.append((size, path, t("orphan UEFI nvram"))) # Sauvegardes de disque laissées par un redimensionnement (.qcow2.bak). for size, path in self._qemu_find_files(disk_dir, "*.qcow2.bak"): orphans.append((size, path, t("disk backup (resize)"))) # Le nom d'un fichier ne dit RIEN de son usage : c'est libvirt qui # sait. Une VM renommée garde le nom de fichier d'avant, et le # nettoyage offrait alors son disque de 63 Go au « rm -f » — rapporté. orphans, proteges = self._qemu_split_orphans(orphans) if proteges: print(f"\n{t('Kept (still attached to a VM):')}") for size, path, porteur in sorted(proteges, key=lambda o: -o[0]): print(f" {self._human_size(size):>9} {path} ← {porteur}") if orphans: total = sum(o[0] for o in orphans) print(f"\n{t('Orphan files:')}") for size, path, reason in sorted(orphans, key=lambda o: -o[0]): print(f" {self._human_size(size):>9} {path} [{reason}]") print( f"\n {t('Total:')} {self._human_size(total)} " f"({len(orphans)} {t('files')})" ) if self._is_yes(input(t("Delete these orphan files? (y/N): "))): paths = " ".join(shlex.quote(o[1]) for o in orphans) cmd = f"sudo rm -f {paths}" print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) print(f"✅ {t('Cleanup done.')}") else: print(t("Cancelled.")) else: print(f"✅ {t('No orphan files found.')}") # 2) Domaines fantômes (définis mais disque manquant). self._cleanup_ghost_domains() # 3) Doublons d'images nommées par codename (avant /releases/). dups = [ (s, p) for s, p, _m in self._qemu_split_orphans( [ (s, p, "") for s, p in self._qemu_find_files( seed_dir, "*-server-cloudimg-*.img" ) if not os.path.basename(p).startswith("ubuntu-") ] )[0] ] self._cleanup_delete_files( t("Stale codename-named Ubuntu images (duplicates):"), dups, t("Delete these duplicate images? (y/N): "), ) # 4) Entrées ~/.ssh/config orphelines (erplibre-* sans VM). self._cleanup_ssh_config(domains) # 5) Baux DHCP périmés. self._cleanup_stale_leases() # 6) Tout le cache d'images de base (option lourde : re-téléchargement). # Une image de base peut servir de FOND à un disque (backingStore) : # elle est alors référencée, et l'effacer creverait la VM. cached = [ (s, p) for s, p, _m in self._qemu_split_orphans( [ (s, p, "") for s, p in self._qemu_find_files(seed_dir, "*") if not p.endswith("-seed.iso") and not p.endswith(".part") ] )[0] ] self._cleanup_delete_files( t("All cached base images (reusable):"), cached, t("Delete ALL cached base images? (y/N): "), ) def _cleanup_ghost_domains(self): """VM définies dont plus aucun disque n'existe -> propose undefine.""" ghosts = [] for name in self._qemu_list_domains(): try: res = subprocess.run( virsh_argv("domblklist", name, "--details"), capture_output=True, text=True, timeout=15, env=self._qemu_c_env(), ) except (OSError, subprocess.SubprocessError): continue srcs = [] for line in res.stdout.splitlines(): p = line.split() if len(p) >= 4 and p[1] == "disk" and p[3] not in ("-", ""): srcs.append(p[3]) if srcs and all( subprocess.run( ["sudo", "test", "-e", s], timeout=10 ).returncode != 0 for s in srcs ): ghosts.append(name) if not ghosts: return print( f"\n{t('Ghost domains (defined but disk missing):')} " f"{', '.join(ghosts)}" ) if not self._is_yes(input(t("Undefine these ghost domains? (y/N): "))): print(t("Cancelled.")) return for name in ghosts: q = shlex.quote(name) cmd = ( f"{sudo_prefix()}virsh --connect {URI} " f"destroy {q} 2>/dev/null; " f"{sudo_prefix()}virsh --connect {URI} " f"undefine {q} --nvram 2>/dev/null " f"|| {sudo_prefix()}virsh --connect {URI} undefine {q}" ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) print(f"✅ {t('Cleanup done.')}") def _ssh_entry_alive(self, content, nom, domains, adresses, distantes): """Cette entrée mène-t-elle encore quelque part ? (raison, ou '') Le NOM ne suffit pas à en juger — c'est le défaut qui a coûté cher : après un renommage de VM, l'alias garde l'ancien nom et se faisait effacer alors qu'il menait à une machine EN MARCHE. Trois autres preuves valent mieux : * son adresse est celle d'un domaine vivant ; * son nom est celui d'une VM de l'hôte Proxmox retenu. Le ProxyJump n'en fait PAS partie, et c'est le second défaut de cette fonction : il valait preuve à lui seul, sans qu'on regarde jamais si le rebond existait encore. Cette question-là se traite dans `ssh_orphans`, qui seule peut la poser — la réponse dépend des autres entrées, et de celles qu'on s'apprête à retirer. """ if nom in domains: return nom bloc = re.search( rf"(?ms)^[ \t]*Host[ \t]+{re.escape(nom)}[ \t]*\n" r"((?:[ \t]+[^\n]*\n?)*)", content, ) corps = bloc.group(1) if bloc else "" ip = re.search(r"(?mi)^[ \t]*HostName[ \t]+(\S+)", corps) if ip and ip.group(1) in adresses: return adresses[ip.group(1)] if nom in distantes: return t("a VM of the Proxmox host") return "" def _cleanup_ssh_config(self, domains): """Retire les blocs « Host erplibre-* » qui ne mènent plus à rien (on ne touche jamais aux autres hôtes SSH personnels).""" cfg = os.path.expanduser("~/.ssh/config") if not os.path.exists(cfg): return with open(cfg, encoding="utf-8") as fh: content = fh.read() # Les adresses des domaines vivants, et les VM de l'hôte Proxmox # retenu : deux preuves qu'une entrée sert encore, que le nom ignore. adresses = {} for nom in domains: ip = self._qemu_vm_ip_now(nom) if ip: adresses[ip] = nom distantes = set() try: hote = self._pve_host(ask=False) if hote: distantes = { v["name"] for v in self._pve_vms() if v.get("name") } except Exception: pass gardes, orphelines = ssh_orphans( parse_ssh_blocks(content), lambda h: self._ssh_entry_alive( content, h, domains, adresses, distantes ), ) if gardes: print(f"\n{t('Kept (still leads somewhere):')}") for h, raison in gardes: print(f" {h} ← {raison}") if not orphelines: return print(f"\n{t('Orphan ~/.ssh/config entries:')}") # Avec la RAISON : « son rebond n'existe plus » explique pourquoi une # entrée qu'on croyait bonne s'en va, et c'est la seule chose qui # permet de répondre non en connaissance de cause. for h, raison in orphelines: print(f" {h}" + (f" ← {raison}" if raison else "")) orphans = [h for h, _r in orphelines] if not self._is_yes( input(t("Remove these ~/.ssh/config entries? (y/N): ")) ): print(t("Cancelled.")) return for h in orphans: pat = re.compile( rf"(?m)^[ \t]*Host[ \t]+{re.escape(h)}[ \t]*\n" r"(?:[ \t]+[^\n]*\n?)*" ) content = pat.sub("", content) content = content.strip("\n") content = content + "\n" if content else "" with open(cfg, "w", encoding="utf-8") as fh: fh.write(content) os.chmod(cfg, 0o600) print(f"✅ {t('Cleanup done.')}") def _cleanup_stale_leases(self): """Baux DHCP libvirt dont la MAC n'appartient à aucune VM (best-effort : les baux expirent d'eux-mêmes).""" status = "/var/lib/libvirt/dnsmasq/virbr0.status" try: res = subprocess.run( ["sudo", "cat", status], capture_output=True, text=True, timeout=15, ) leases = json.loads(res.stdout or "[]") except (OSError, subprocess.SubprocessError, ValueError): return if not isinstance(leases, list) or not leases: return macs = self._qemu_domain_macs() stale = [ ln for ln in leases if str(ln.get("mac-address", "")).lower() not in macs ] if not stale: return print(f"\n{t('Stale DHCP leases (no matching VM):')}") for ln in stale: print( f" {ln.get('ip-address', '?'):<16} " f"{ln.get('mac-address', '?')} {ln.get('hostname', '')}" ) if not self._is_yes(input(t("Clear these stale leases? (y/N): "))): print(t("Cancelled.")) return kept = [ln for ln in leases if ln not in stale] tmp = os.path.join("/tmp", f"virbr0.status.{os.getpid()}.json") with open(tmp, "w", encoding="utf-8") as fh: json.dump(kept, fh) cmd = ( f"sudo cp {shlex.quote(tmp)} {status} && " "sudo pkill -HUP -F /var/lib/libvirt/dnsmasq/virbr0.pid " "2>/dev/null || true" ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) try: os.unlink(tmp) except OSError: pass print(f"✅ {t('Cleanup done.')}") def _qemu_import_module(self): """Importe deploy_qemu.py comme module (source de vérité des specs). Mémorisé : le catalogue interroge cette source une fois par couple (distro, version), et réexécuter un fichier de 2 700 lignes à chaque passage se voyait à l'écran. """ cached = getattr(self, "_qemu_mod_cache", None) if cached is not None: return cached import importlib.util path = self._qemu_script_path() spec = importlib.util.spec_from_file_location("deploy_qemu", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) self._qemu_mod_cache = mod return mod @classmethod def _qemu_infra_name(cls, distro, version, arch=None): """Nom de VM stable pour le parc, ex. erplibre-ubuntu-2404. Ajoute un suffixe d'architecture quand elle diffère de la native de l'hôte (ex. erplibre-ubuntu-2604-s390x sur un hôte amd64) pour éviter les collisions de noms entre archis et rendre l'archi visible. La version « latest » ne figure pas dans le nom : une distribution en publication continue n'en a qu'une, si bien que le segment ne distingue aucune VM d'une autre. Une version nommée qui coexiste avec d'autres au catalogue reste dans le nom, tumbleweed comprise.""" if version == "latest": base = f"erplibre-{distro}" else: base = f"erplibre-{distro}-{version.replace('.', '')}" if arch and arch != cls._native_arch(): base += f"-{arch}" return base def _qemu_domain_exists(self, name): """Vrai si une VM libvirt de ce nom est déjà définie.""" try: res = subprocess.run( virsh_argv("dominfo", name), capture_output=True, text=True, timeout=15, ) return res.returncode == 0 except (OSError, subprocess.SubprocessError): return False @staticmethod def _qemu_host_addresses(): """Adresses IPv4 de L'HÔTE, à écarter des candidates d'une VM. « virsh domifaddr --source arp » remonte la table ARP, où figurent les passerelles des ponts libvirt. Une VM n'a jamais l'adresse de son hôte : sans ce filtre, une VM RENOMMÉE — dont le bail porte encore l'ancien nom d'hôte, donc sans correspondance — se voit attribuer la passerelle. """ try: res = subprocess.run( ["ip", "-4", "-o", "addr", "show"], capture_output=True, text=True, timeout=10, ) except (OSError, subprocess.SubprocessError): return set() return set(re.findall(r"inet (\d+\.\d+\.\d+\.\d+)", res.stdout)) @staticmethod def _qemu_candidates_by_source(name): """{source: [ip]} — les candidates SANS mélanger les sources. Le bail dit ce que dnsmasq a donné, l'agent ce que la VM voit, l'ARP ce qui a parlé sur le réseau (passerelles comprises). Les garder séparées permet de choisir la plus sûre quand le nom d'hôte ne tranche pas. """ siennes = QemuManageMixin._qemu_host_addresses() out = {} for source in ("lease", "agent", "arp"): try: res = subprocess.run( virsh_argv("domifaddr", name, "--source", source), capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.SubprocessError): continue vues = [] for ip in re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout): if ip != "127.0.0.1" and ip not in siennes and ip not in vues: vues.append(ip) if vues: out[source] = vues return out @staticmethod def _qemu_lease_candidates(name): """Toutes les IPv4 candidates de la VM, agrégées de PLUSIEURS sources : - lease : base DHCP de dnsmasq (peut manquer sous forte charge, ou contenir plusieurs baux : bail précoce « ubuntu » périmé + bail définitif) ; - agent : qemu-guest-agent DANS la VM (voit l'IP réelle même quand le bail dnsmasq est absent) ; - arp : table ARP de l'hôte (VM active sur le réseau). On combine pour ne jamais rater une IP que le bail seul manquerait : sous forte charge, le bail dnsmasq reste vide alors que la VM a bien une adresse.""" ips = [] siennes = QemuManageMixin._qemu_host_addresses() for source in ("lease", "agent", "arp"): try: res = subprocess.run( virsh_argv("domifaddr", name, "--source", source), capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.SubprocessError): continue for ip in re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout): # Ignore la loopback (remontée par --source agent) et les # adresses de l'HÔTE (passerelles des ponts, remontées par # --source arp) : une VM n'a jamais celles-là. if ip != "127.0.0.1" and ip not in ips and ip not in siennes: ips.append(ip) return ips @staticmethod def _qemu_ip_reachable(ip, port=22, timeout=2): """Vrai si la VM répond sur cette IP (bail ACTIF, pas périmé). On teste le PING d'abord : il répond dès que le réseau de la VM est up, BIEN AVANT sshd — sinon on attendait le sshd (lent en émulation) et la résolution semblait « bloquée » alors que la VM a déjà son IP. Repli TCP:port si l'ICMP est filtré.""" try: res = subprocess.run( ["ping", "-c", "1", "-W", str(int(timeout)), ip], capture_output=True, timeout=timeout + 1, ) if res.returncode == 0: return True except (OSError, subprocess.SubprocessError): pass import socket try: with socket.create_connection((ip, port), timeout=timeout): return True except OSError: return False @staticmethod def _qemu_lease_ip_for_host(name, candidates): """Parmi `candidates`, l'IP dont le bail dnsmasq porte le hostname de la VM (le bail DÉFINITIF, pas le bail précoce « ubuntu »). None sinon. La lecture ne demande JAMAIS de mot de passe : `lease_status_text` lit en direct puis tente « sudo -n », et rend la chaîne vide plutôt que d'ouvrir une invite. Les deux appelants se replient sur une autre source quand ceci rend None.""" # Plusieurs tableaux JSON concaténés : on parse chaque objet {...}. for obj in re.findall(r"\{[^{}]*\}", lease_status_text()): if re.search(rf'"hostname":\s*"{re.escape(name)}"', obj): m = re.search(r'"ip-address":\s*"([\d.]+)"', obj) if m and m.group(1) in candidates: return m.group(1) return None def _qemu_vm_ip_now(self, name): """IP de la VM d'après le bail DHCP, SANS attendre. `_qemu_vm_ip` patiente jusqu'à dix minutes par VM : c'est ce qu'il faut après un déploiement, et exactement ce qu'il ne faut pas pour AFFICHER une liste — trois VM figeaient le menu une demi-heure. Ici on lit le bail une fois, en préférant celui dont le hostname est le nom de la VM. """ par_source = self._qemu_candidates_by_source(name) cands = [ip for ips in par_source.values() for ip in ips] if not cands: return None trouve = self._qemu_lease_ip_for_host(name, cands) if trouve: return trouve # Sans correspondance de nom d'hôte — le cas d'une VM RENOMMÉE, dont # le bail porte encore l'ancien nom — on prend la source la plus # sûre : le bail, puis l'agent, puis la table ARP. Celle-ci contient # les passerelles des ponts, où « la dernière candidate » tombe : la # VM se voit alors annoncée avec l'adresse de sa passerelle. for source in ("lease", "agent", "arp"): if par_source.get(source): return par_source[source][-1] return cands[-1] def _qemu_vm_ip(self, name, timeout=600): """IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname changé au boot) : renvoie en priorité le bail dont le hostname == nom de la VM, sinon une IP JOIGNABLE (sshd up), pour ne jamais retenir le bail précoce périmé. Attend jusqu'à `timeout` (boot émulé lent).""" deadline = time.time() + timeout cands = [] while time.time() < deadline: cands = self._qemu_lease_candidates(name) if cands: # 1) bail définitif (hostname == nom de la VM) host_ip = self._qemu_lease_ip_for_host(name, cands) if host_ip: return host_ip # 2) sinon, une IP déjà joignable (sshd up) for ip in cands: if self._qemu_ip_reachable(ip): return ip time.sleep(3) # Meilleur effort : le dernier bail (le plus récent) plutôt que le 1er. return cands[-1] if cands else None def _qemu_resolve_ips(self, names, labels=None, timeout=300): """Résout les IP de plusieurs VM EN PARALLÈLE (le boot émulé est lent), en affichant la progression au fur et à mesure. Renvoie {nom: ip|None}. `labels` : {nom: « k/N »} pour préfixer chaque ligne d'un ID de suivi. `timeout` : délai max PAR VM (borne l'attente d'une VM sans IP). Un BATTEMENT toutes les 30 s liste les VM encore en attente -> jamais de silence prolongé qui donne l'impression d'un blocage.""" from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import TimeoutError as _FTimeout labels = labels or {} print( f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}" ) result = {} t0 = time.time() starts = {} workers = min(len(names), (os.cpu_count() or 4)) or 1 with ThreadPoolExecutor(max_workers=workers) as pool: futs = {} for n in names: starts[n] = time.time() futs[pool.submit(self._qemu_vm_ip, n, timeout)] = n pending = set(futs) done = 0 while pending: try: for fut in as_completed(list(pending), timeout=30): pending.discard(fut) n = futs[fut] try: ip = fut.result() except Exception: ip = None result[n] = ip done += 1 tag = f"[{labels[n]}] " if n in labels else "" dur = self._fmt_dur(time.time() - starts[n]) print( f" [{done}/{len(names)}] {tag}{n}: " f"{ip or t('no IP')} ({dur})" ) except _FTimeout: # Battement : VM encore en attente (boot/DHCP lent). waiting = [futs[f] for f in pending] shown = ", ".join(waiting[:5]) if len(waiting) > 5: shown += "…" print( f" ⏳ {t('still waiting for')} {len(waiting)} VM " f"({self._fmt_dur(time.time() - t0)}): {shown}" ) got = sum(1 for ip in result.values() if ip) print( f" {t('IPs resolved:')} {got}/{len(names)} " f"({self._fmt_dur(time.time() - t0)})" ) return result def _qemu_vm_arch(self, name): """Architecture d'une VM (jeton amd64/arm64/s390x) via virsh dumpxml.""" try: res = subprocess.run( virsh_argv("dumpxml", name), capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.SubprocessError): return None m = re.search(r"]*\barch='([^']+)'", res.stdout) if not m: return None return { "x86_64": "amd64", "aarch64": "arm64", "s390x": "s390x", }.get(m.group(1), m.group(1)) def _qemu_vm_meta(self, name, mod): """(distro, version, arch) d'une VM déduits de son nom + son arch. Le nom suit _qemu_infra_name(distro, version, arch) : on retrouve donc (distro, version) en testant les combinaisons du catalogue.""" arch = self._qemu_vm_arch(name) or "amd64" try: for d, (versions, _default) in mod.DISTROS.items(): for v in versions: if self._qemu_infra_name(d, v, arch) == name: return d, v, arch except Exception: pass return None, None, arch @staticmethod def _qemu_repo_branch(): """Branche du dépôt COURANT, ou '' — le défaut des formulaires. On déploie le plus souvent ce qu'on a sous les yeux ; le premier nom de la liste alphabétique, lui, n'a aucune raison d'être bon.""" try: res = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], capture_output=True, text=True, timeout=10, ) except (OSError, subprocess.SubprocessError): return "" nom = (res.stdout or "").strip() return "" if res.returncode or nom == "HEAD" else nom @staticmethod def _qemu_branch_gap(branche): """Combien de commits LOCAUX manquent à origin/, et lesquels. Rend (nombre, [sujets]) — (0, []) quand il n'y a rien à dire, ou quand la question ne se pose pas (pas de dépôt, pas de distant). Pourquoi le déploiement s'en soucie : la VM ne reçoit PAS le checkout d'ici, elle CLONE la branche depuis le dépôt distant. Tout ce qui tourne dans la VM — install_proxmox.sh, les scripts d'installation, le Makefile — vient donc de là. Un correctif commité ici mais pas poussé ne part donc pas : chaque VM déployée ensuite reçoit l'ancien script, et le défaut « revient » alors qu'il est corrigé. Rien ne le signale, d'où ce décompte. """ if not branche: return 0, [] try: res = subprocess.run( [ "git", "log", "--oneline", "--no-decorate", f"origin/{branche}..HEAD", ], capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.SubprocessError): return 0, [] # Une branche inconnue du distant, ou aucun distant : ce n'est pas un # écart à signaler, c'est une question qui ne se pose pas. if res.returncode: return 0, [] sujets = [ ligne.strip() for ligne in (res.stdout or "").splitlines() if ligne.strip() ] return len(sujets), sujets def _qemu_miroir_erplibre(self): """Le miroir du cache qui sert le clone d'ERPLibre, ou "" s'il manque. Même chemin que celui que le cache calcule : //.git. Absent sur un hôte sans cache, ou tant qu'aucune VM n'a cloné ERPLibre à travers lui. """ from urllib.parse import urlparse from script.todo.qemu_cache_menu import CACHE_MIROIR_GIT url = urlparse(self.ERPLIBRE_GIT_URL) chemin = url.path.strip("/") if chemin.endswith(".git"): chemin = chemin[: -len(".git")] if not url.netloc or not chemin: return "" miroir = os.path.join(CACHE_MIROIR_GIT, url.netloc, chemin + ".git") return miroir if os.path.isdir(miroir) else "" def _qemu_ecart_miroir(self, branche): """Ce qu'une VM hors ligne recevra, face à ce que l'hôte connaît. Hors ligne, le clone est servi par le miroir du cache TEL QUEL : son rafraîchissement échoue, l'amont étant coupé. Ce que le miroir porte est donc ce que la VM exécute, poussé ou non depuis. Rend None quand on ne sait pas — pas de miroir, miroir illisible — et l'appelant retombe alors sur l'écart avec origin. Sinon (sha_miroir, sha_ici, nombre) : `sha_miroir` vide si le miroir n'a pas la branche ; `sha_ici` celui de la branche locale, à défaut d'origin/, vide si l'hôte n'a ni l'une ni l'autre ; `nombre` les commits de `sha_ici` absents du miroir, -1 si incalculable. Tout est lu, rien n'est écrit : le miroir appartient au compte du service. « ls-remote --exit-code » rend 2 quand aucune référence ne répond au motif, et seul ce code vaut absence : une requête qui échoue autrement rend None. Le motif se compare à la FIN des références, d'où la recherche de la référence exacte dans ce qui revient. """ miroir = self._qemu_miroir_erplibre() if branche else "" if not miroir: return None def lancer(*args): try: return subprocess.run( ["git", *args], capture_output=True, text=True, timeout=15 ) except (OSError, subprocess.SubprocessError): return None def git(*args): res = lancer(*args) if res is None or res.returncode: return None return (res.stdout or "").strip() ref = f"refs/heads/{branche}" vu = lancer("ls-remote", "--exit-code", miroir, ref) if vu is None or vu.returncode not in (0, 2): return None sha_miroir = "" if vu.returncode == 0: for ligne in (vu.stdout or "").splitlines(): champs = ligne.split() if len(champs) == 2 and champs[1] == ref: sha_miroir = champs[0] break sha_ici = "" for ref in (f"refs/heads/{branche}", f"refs/remotes/origin/{branche}"): sha_ici = git("rev-parse", "--verify", "-q", f"{ref}^{{commit}}") if sha_ici: break sha_ici = sha_ici or "" nombre = -1 if sha_miroir and sha_ici and sha_miroir != sha_ici: compte = git("rev-list", "--count", f"{sha_miroir}..{sha_ici}") nombre = int(compte) if (compte or "").isdigit() else -1 return sha_miroir, sha_ici, nombre @staticmethod def _qemu_lignes_miroir(ecarts): """Les lignes à dire face au miroir, pour [(branche, écart)]. `écart` est ce que rend `_qemu_ecart_miroir`, None compris. Chaque ligne nomme sa branche. Une branche absente du miroir a sa ligne ; une branche à jour, ou dont on ne sait rien, n'en a aucune. Le conseil sur « git push » ne vient qu'une fois, et seulement si le miroir porte, pour une branche au moins, un autre commit que l'hôte. """ lignes, en_retard = [], False for branche, ecart in ecarts: if ecart is None: continue sha_miroir, sha_ici, nombre = ecart if not sha_miroir: lignes.append( f"⚠ {t('The cache mirror has no branch')} {branche}: " f"{t('an offline clone will fail.')}" ) continue if sha_miroir == sha_ici: continue en_retard = True lignes.append( f"⚠ {t('Offline, the VM clones the cache mirror of')} " f"{branche}: {sha_miroir[:12]}" ) if sha_ici: lignes.append( f" {t('this checkout has')} {sha_ici[:12]}" + ( f", {nombre} {t('commit(s) missing from the mirror')}" if nombre > 0 else "" ) ) if en_retard: lignes.append( f" → {t('A push alone changes nothing: the mirror is')}" ) lignes.append( f" {t('refreshed only when a VM clones it online.')}" ) return lignes def _qemu_branch_gap_lines(self, branche, limite=3, hors_ligne=False): """Les lignes à dire avant de déployer, ou []. `branche` : un nom, ou la collection des branches que les VM clonent réellement. Plusieurs branches ne sont comparées qu'au miroir, une par une, chaque ligne nommant la sienne : l'écart avec origin se mesure contre HEAD, qui n'est qu'une seule branche, et lui attribuerait les commits d'une autre. Une branche dont le miroir ne dit rien reste alors muette. `hors_ligne` : la VM clonera depuis le miroir du cache. L'écart qui compte est alors celui du miroir, et « git push » seul n'y change rien : le miroir ne se rafraîchit que quand une VM le clone amont branché. """ if isinstance(branche, str): branches = [branche] else: branches = sorted({b for b in branche if b}) if len(branches) != 1: if not hors_ligne: return [] return self._qemu_lignes_miroir( [(b, self._qemu_ecart_miroir(b)) for b in branches] ) branche = branches[0] ecart = self._qemu_ecart_miroir(branche) if hors_ligne else None if ecart is not None: return self._qemu_lignes_miroir([(branche, ecart)]) nombre, sujets = self._qemu_branch_gap(branche) if not nombre: return [] lignes = [ f"⚠ {t('The VM clones')} origin/{branche}, " f"{t('not this checkout.')}", f" {nombre} {t('local commit(s) are missing there:')}", ] lignes += [f" {s}" for s in sujets[:limite]] if nombre > limite: lignes.append(f" … {nombre - limite} {t('more')}") lignes.append(f" → git push {t('to deploy your own work.')}") return lignes def _qemu_branch_list(self): """Branches distantes d'ERPLibre, triées. Vide si le réseau manque. Séparé de l'invite : le formulaire TUI a besoin de la LISTE, et cet appel réseau (jusqu'à 30 s) doit être fait avant que Textual prenne le terminal.""" branches = [] try: res = subprocess.run( ["git", "ls-remote", "--heads", self.ERPLIBRE_GIT_URL], capture_output=True, text=True, timeout=30, ) for line in res.stdout.splitlines(): ref = line.split("\t")[-1] if ref.startswith("refs/heads/"): branches.append(ref[len("refs/heads/") :]) except (OSError, subprocess.SubprocessError): pass branches.sort() return branches def _qemu_pick_branch(self): """Liste les branches distantes d'ERPLibre et en fait choisir une.""" print(f"\n{t('Fetching ERPLibre branch list...')}") branches = self._qemu_branch_list() default = ( "master" if "master" in branches else (branches[0] if branches else "master") ) if not branches: return ( input(f"{t('Branch (default:')} {default}): ").strip() or default ) print(f"{t('Branches:')}") for i, b in enumerate(branches, 1): star = " *" if b == default else "" print(f" [{i}] {b}{star}") sel = input(f"{t('Choice (number or name, default:')} {default}): ") sel = sel.strip() if not sel: return default try: idx = int(sel) - 1 if 0 <= idx < len(branches): return branches[idx] except ValueError: if sel in branches: return sel return default @staticmethod def _qemu_wait_ssh(ip, user="erplibre", timeout=1200): """Attend que sshd réponde ET que cloud-init soit TERMINÉ, via des connexions COURTES successives. Au 1er boot, cloud-init régénère les clés d'hôte et REDÉMARRE sshd : attendre la fin de cloud-init AVANT de lancer l'install évite qu'une session longue soit tuée (« Connection closed by remote host », exit 255 — cas Fedora). True si prête.""" opts = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " "-o ConnectTimeout=8 -o BatchMode=yes" ) # On imprime toujours l'état (|| true) pour matcher sur le TEXTE : # « status: running » n'a pas de code de sortie fiable selon la version. probe = ( "if command -v cloud-init >/dev/null 2>&1; then " "cloud-init status 2>/dev/null || true; else echo nocloudinit; fi" ) ready = ("done", "disabled", "error", "degraded", "nocloudinit") deadline = time.time() + timeout ssh_up = False while time.time() < deadline: try: res = subprocess.run( f"ssh {opts} {user}@{ip} {shlex.quote(probe)}", shell=True, capture_output=True, timeout=20, text=True, ) out = res.stdout or "" except (OSError, subprocess.SubprocessError): out = "" if out.strip(): ssh_up = True # sshd a répondu if any(k in out for k in ready): return True time.sleep(5) # cloud-init pas confirmé fini dans le délai : on tente quand même si # sshd répondait au moins (mieux qu'un abandon silencieux). return ssh_up