#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Jusqu'à quel étage une QEMU dans une QEMU tient-elle ? Le pendant de `deep_proxmox.py`, et sa raison d'être : le ralentissement du quatrième étage vient du PROCESSEUR — de ce que coûte une sortie de VM sous pagination imbriquée — mais le coût par étage, lui, vient de ce qu'on installe. Un nœud Proxmox pose un noyau, corosync, ceph et une interface web ; un hôte libvirt nu pose libvirtd et qemu-kvm. Les deux mesures ensemble séparent ce qui tient au matériel de ce qui tient à la pile, deux choses que la seule mesure Proxmox confond. CE QUE CE TEST DOIT PROUVER AVANT DE MESURER `deploy_qemu.py` ne passe jamais « --cpu host-passthrough » : il s'en remet au défaut de virt-install. Et quand /dev/kvm manque, il n'échoue pas — il pose « --virt-type qemu », avertit sur une ligne, et crée une VM ENTIÈREMENT ÉMULÉE. Un étage émulé démarre en sept minutes et demie au lieu de quelques secondes, et rien dans le code de retour ne le dit. Sans garde, ce script mesurerait donc de la TCG empilée en croyant mesurer de la virtualisation imbriquée — et rendrait un chiffre plus flatteur, et faux. D'où le contrôle de chaque étage : /dev/kvm lisible, « nested » à Y, et le domaine de l'enfant en type='kvm' avec un CPU host-passthrough. Un étage qui échoue à cela arrête la descente au lieu de la prolonger dans le vide. ./long_test/deep_qemu.py # trois étages ./long_test/deep_qemu.py --depth 5 # en demander plus, sciemment ./long_test/deep_qemu.py --dry-run # le plan, rien de créé ./long_test/deep_qemu.py --detruire # défaire ce qui a été posé """ import os import re import shlex import subprocess import sys import time RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, RACINE) sys.path.insert(0, os.path.join(RACINE, "long_test")) import descente # noqa: E402 from descente import ( # noqa: E402,F401 DELAIS, Famille, a_defaire, autre_descente, capacite_hote, cle_publique, dernier_rapport, descente_vivante, detruire, detruire_etage1, dire, identite_de, mener, module_qemu, retirer_alias, ) from descente import ( alias_etage as _alias_etage, ) from descente import ( nom_etage as _nom_etage, ) from script.proxmox import nesting # noqa: E402 from script.proxmox import proxmox_deploy as pve # noqa: E402 # Une Debian nue : c'est elle qui recevra libvirt et qemu-kvm. DISTRO = "debian" NOM_BASE = "deep-qemu" OUTIL = "deep_qemu" # Le script est envoyé par scp, comme install_proxmox.sh l'est pour Proxmox : # c'est NOTRE code qu'on veut éprouver, et il n'importe que la bibliothèque # standard, donc il tourne dans un invité nu sans rien d'autre. LOCAL_CLI = "script/qemu/deploy_qemu.py" DISTANT_CLI = "/tmp/deploy_qemu.py" CLE_DISTANTE = "/root/.ssh/longtest.pub" # Trois faits, trois lignes, et l'ABSENCE d'une ligne vaut « non ». Écrit pour # dash : /bin/sh sur Debian n'est pas bash. CONTROLE_CMD = ( "if [ -r /dev/kvm ]; then echo KVM=oui; else echo KVM=non; fi; " "cat /sys/module/kvm_amd/parameters/nested 2>/dev/null" " | sed s/^/NESTED=/; " "cat /sys/module/kvm_intel/parameters/nested 2>/dev/null" " | sed s/^/NESTED=/; " "df --output=avail -BG /var/lib/libvirt/images 2>/dev/null" " | tail -1 | sed s/^/DISQUE=/" ) # Les listes apt AVANT toute installation. Constaté au premier lancement # réel : « --setup-host » a échoué en ZÉRO seconde sur « Unable to locate # package qemu-system-x86 », alors que le paquet existe. La VM venait de # démarrer, ses listes ne portaient que « bookworm-security », et un # apt-get update les a complétées d'un coup. # # Deux causes, une seule parade : cloud-init n'a pas fini de composer # /etc/apt, et apt-daily tient le verrou des listes au premier démarrage. # install_proxmox.sh a la même parade, et pour la même raison — arrêter les # minuteries, puis réessayer. PREPARE_APT_CMD = ( "systemctl stop apt-daily.service apt-daily-upgrade.service" " apt-daily.timer apt-daily-upgrade.timer >/dev/null 2>&1;" " i=1; while [ $i -le 12 ]; do" " DEBIAN_FRONTEND=noninteractive apt-get update && exit 0;" " echo APT-RETRY=$i; sleep 15; i=$((i+1)); done; exit 1" ) # Le réseau « default » de libvirt sert 192.168.122.0/24, à TOUS les étages. # Un étage dont l'adresse vient du « default » de son parent voit donc son # propre « net-start default » refusé net : # # error: internal error: Network is already in use by interface enp1s0 # # Un invité qui vit DANS un réseau ne peut pas servir le même. Chaque étage # reçoit donc son propre sous-réseau, déduit de sa PROFONDEUR : deux étages ne # peuvent pas tomber sur le même, et rien n'est à deviner. # # 131 et au-delà : 122 est le sous-réseau de libvirt, et 123 celui qu'une # machine de développement porte souvent. Les éviter coûte un octet. RESEAU_BASE = 131 def cidr_pour(profondeur): """Le troisième octet du sous-réseau d'un étage. Déterminé, jamais tiré.""" return f"192.168.{RESEAU_BASE + max(0, int(profondeur) - 1)}" def reseau_xml(prefixe, nom="default"): """Le réseau NAT d'un étage, en une ligne — pas de heredoc. Une seule ligne parce qu'elle traverse deux couches de quoting pour atterrir dans dash : un heredoc n'y survivrait pas. """ return ( f"{nom}" f"" f"" f"" f"" ) RESEAU_CMD = ( "virsh -c qemu:///system net-info default 2>&1 | sed s/^/NET:/; " "systemctl is-active libvirtd 2>/dev/null | sed s/^/UNITE:/" ) def nom_etage(niveau): return _nom_etage(niveau, NOM_BASE) def alias_etage(niveau, parent_alias): return _alias_etage(niveau, parent_alias, NOM_BASE) def parse_controle(texte): """Ce que le contrôle a VU. Ce qui n'a pas été lu vaut « non ». L'absence d'une ligne n'est jamais un oui : si /sys/module/kvm_amd/parameters/nested n'existe pas, c'est que le module n'est pas chargé, et l'étage suivant serait émulé. """ propre = pve.strip_ssh_noise(texte or "") nested = re.search(r"^NESTED=(\S+)", propre, re.M) disque = re.search(r"^DISQUE=\s*(\d+)", propre, re.M) return { "kvm": bool(re.search(r"^KVM=oui\s*$", propre, re.M)), # « Y » ou « 1 » selon les versions du module. "nested": bool(nested and nested.group(1).strip() in ("Y", "1")), "disque_go": int(disque.group(1)) if disque else 0, } def parse_reseau(texte): """Le réseau libvirt « default » est-il actif, et libvirtd debout ?""" propre = pve.strip_ssh_noise(texte or "") actif = re.search(r"^NET:\s*Active:\s*(\S+)", propre, re.M | re.I) unite = re.search(r"^UNITE:(\S+)", propre, re.M) return { "reseau": bool(actif and actif.group(1).lower() == "yes"), "libvirtd": bool(unite and unite.group(1).strip() == "active"), } def parse_domifaddr(texte): """La première adresse IPv4 d'un domaine, sans son masque, ou "". virsh écrit un tableau ; on ne prend que les lignes qui annoncent « ipv4 », et jamais la ligne d'en-tête ni les tirets. """ for ligne in pve.strip_ssh_noise(texte or "").splitlines(): champs = ligne.split() if len(champs) >= 4 and champs[2].lower() == "ipv4": return champs[3].split("/")[0] return "" def parse_domaine(xml): """Le domaine tourne-t-il sous KVM, et son CPU passe-t-il l'hôte ? Les deux comptent, et pour la même raison : un domaine type='qemu' est ÉMULÉ, et un CPU qui ne passe pas les drapeaux de l'hôte ne porte pas la virtualisation — l'étage suivant serait émulé à son tour, sept minutes et demie de démarrage, sans qu'aucun code de retour ne le dise. """ propre = pve.strip_ssh_noise(xml or "") domaine = re.search(r"]*\btype=['\"](\w+)['\"]", propre) cpu = re.search(r"]*\bmode=['\"]([\w-]+)['\"]", propre) return { "type": domaine.group(1) if domaine else "", "cpu": cpu.group(1) if cpu else "", } class Descente(descente.Descente): """Les verbes de QEMU/KVM. Le reste est dans `descente.Descente`.""" OUTIL = OUTIL NOM_BASE = NOM_BASE DISTRO = DISTRO # ------------------------------------------------------------------ # def _envoyer_cli(self, hote): """Pose NOTRE deploy_qemu.py sur l'hôte. Rend True s'il y est. Refait à chaque besoin plutôt qu'une fois : /tmp est vidé au démarrage sur bien des systèmes, et l'installation redémarre. """ local = os.path.join(RACINE, LOCAL_CLI) if self.dry_run: print(f" scp {local} :{DISTANT_CLI}") return True argv = pve.ssh_argv(hote, "")[:-1] # les options, sans la commande cible = argv[-1] options = argv[1:-1] res = subprocess.run( ["scp", "-q"] + options + [local, f"{cible}:{DISTANT_CLI}"], capture_output=True, text=True, timeout=300, ) if res.returncode: self.dire(f" ✗ scp : {res.stderr.strip()[:200]}") return False return True def installer(self, hote): """libvirt et qemu-kvm, par « --setup-host ». Le code de retour ne prouve RIEN ici : « --setup-host » rend 0 même quand il s'est contenté de PROGRAMMER un redémarrage. C'est l'étape suivante — redémarrer et constater que la machine est revenue — qui prouve quelque chose, et le contrôle de fin d'étage qui prouve que KVM est là. """ if self.dry_run: print(f" {DISTANT_CLI} --setup-host") return True if not self._envoyer_cli(hote): return False # Les listes d'abord. Sans elles, « --setup-host » échoue en zéro # seconde sur des paquets qui existent — et le message parle de # paquets introuvables, pas de listes vides. code, _o = self.executer( hote, PREPARE_APT_CMD, self.delai("install"), "apt-get update", ) if code: self.dire(" ✗ listes apt : le verrou reste tenu") return False code, _o = self.executer( hote, f"python3 {DISTANT_CLI} --setup-host --assume-yes", self.delai("install"), "deploy_qemu --setup-host", montrer=True, ) return code == 0 def noyau_convient(self, noyau): """Tout noyau convient : c'est le REDÉMARRAGE qui compte, pas ce qu'on redémarre. Il charge les modules KVM et applique l'appartenance au groupe libvirt, qui ne prend effet qu'à la SESSION SUIVANTE — sans lui, virt-install retombe sur qemu:///session, où le réseau « default » n'existe pas. Le moteur prouve déjà que la machine a vraiment redémarré en comparant l'instant de démarrage. """ return bool(noyau) def remettre_debout(self, hote): """libvirtd actif et le réseau « default » démarré.""" if self.dry_run: print(" libvirtd + réseau default") return True # Son sous-réseau à LUI, sinon « net-start default » se heurte à # l'adresse que son parent lui a servie. profondeur = self.profondeur_racine + max(1, self.niveau_courant) xml = reseau_xml(cidr_pour(profondeur)) self.executer( hote, "systemctl enable --now libvirtd 2>/dev/null;" " virsh -c qemu:///system net-destroy default 2>/dev/null;" " virsh -c qemu:///system net-undefine default 2>/dev/null;" f" printf '%s' {shlex.quote(xml)} > /tmp/reseau.xml;" " virsh -c qemu:///system net-define /tmp/reseau.xml;" " virsh -c qemu:///system net-start default;" " virsh -c qemu:///system net-autostart default; true", self.delai("reparation"), "libvirtd", ) self.dire(f" réseau {cidr_pour(profondeur)}.0/24") _c, out = self.executer( hote, RESEAU_CMD, self.delai("controle"), "réseau" ) vu = parse_reseau(out) self.dire( f" libvirtd {'actif' if vu['libvirtd'] else 'ABSENT'}," f" réseau default {'actif' if vu['reseau'] else 'ABSENT'}" ) return vu["libvirtd"] and vu["reseau"] def rallumer_a_froid(self, parent, nom): """« virsh destroy » puis « start » : un processus QEMU neuf. Mesuré sur la machine bloquée : à chaud elle restait 46 minutes au même pointeur d'instruction, dans son micrologiciel ; à froid elle a chargé son noyau en 60 à 90 secondes, trois fois de suite, à 2, 3 et 4 Go. Ce n'est donc pas la taille de la mémoire — c'est la façon de redémarrer. """ if self.dry_run or not parent: return False self.executer( parent, f"virsh -c qemu:///system destroy {nom} 2>/dev/null; true", self.delai("controle"), "extinction", ) code, _o = self.executer( parent, f"virsh -c qemu:///system start {nom}", self.delai("controle"), "rallumage", ) return code == 0 def controler(self, hote): """CET étage peut-il héberger le suivant SANS l'émuler ? Le contrôle qui donne son sens à la mesure. Sans lui, un étage sans KVM ne casse pas : il bascule en émulation et continue. La descente irait plus « profond » en mesurant tout autre chose — de la TCG empilée, pas de la virtualisation imbriquée. """ if self.dry_run: print(" /dev/kvm + nested=Y + place disque") return True code, out = self.executer( hote, CONTROLE_CMD, self.delai("controle"), "kvm" ) if code: self.dire(" ✗ contrôle KVM illisible : rien conclu") return False vu = parse_controle(out) self.dire( f" /dev/kvm {'oui' if vu['kvm'] else 'NON'}," f" nested {'oui' if vu['nested'] else 'NON'}," f" {vu['disque_go']} Go libres" ) if not vu["kvm"]: self.dire(" ✗ pas de /dev/kvm : l'étage suivant serait ÉMULÉ") return False if not vu["nested"]: self.dire( " ✗ virtualisation imbriquée absente :" " l'étage suivant serait ÉMULÉ" ) return False return True def preparer_parent(self, parent): """Ce qu'il faut du parent : son réseau. C'est tout. Rien à construire, contrairement à Proxmox, où il faut poser un pont et un NAT dans /etc/network/interfaces : libvirt fournit déjà « default », avec NAT, bail DHCP ET résolveur dnsmasq. Le défaut qui a coûté cher là-bas — une VM en adresse fixe qui route mais ne résout rien, et une installation qui meurt sur « apt update » sans que rien ne l'explique — ne peut pas se produire ici. """ if self.dry_run: print(" réseau default du parent") return ("default",) code, out = self.executer( parent, RESEAU_CMD, self.delai("controle"), "réseau" ) if code: self.dire(" ✗ état du réseau illisible : rien conclu") return None vu = parse_reseau(out) if not vu["reseau"]: self.dire( " ✗ le réseau « default » du parent n'est pas actif" ) return None return ("default",) def attendre_adresse(self, parent, nom): """Le bail DHCP de l'enfant, attendu. Rend l'adresse, ou "". ATTENDU, et non lu une fois. Constaté au troisième étage : le domaine était créé, en type='kvm', et « domifaddr » ne rendait rien — l'invité n'avait pas encore demandé son bail. Plus l'étage est profond, plus il démarre lentement, et c'est justement ce qu'on mesure. `deploy_qemu` attend lui-même l'adresse — 90 secondes par défaut — puis rend 0 quand il ne l'a pas trouvée. Son code de sortie ne prouve donc rien ici non plus. """ debut = time.time() delai = self.delai("ssh") while time.time() - debut < delai: _c, sortie = self.executer( parent, f"virsh -c qemu:///system domifaddr {nom} --source lease", DELAIS["controle"], "domifaddr", ) adresse = parse_domifaddr(sortie) if adresse: if time.time() - debut > 20: self.dire(f" bail après {int(time.time() - debut)} s") return adresse time.sleep(15) return "" def creer_enfant(self, parent, niveau, res, prepare, noter=None): """Une VM dans le parent, par NOTRE deploy_qemu.py. L'ordre compte, et il n'est pas celui de Proxmox. Là-bas l'adresse est exigée AVANT la création (« --ipconfig0 ») ; ici libvirt ne la donne qu'APRÈS le démarrage. On note donc l'identité — le nom du domaine, qui est déterminé — avant la première commande qui peut créer quoi que ce soit, faute de quoi une création échouée à mi-chemin laisserait une machine que le rapport ne nomme nulle part. """ (reseau,) = prepare nom = self.nom_etage(niveau) if noter: noter(nom) if not self._envoyer_cli(parent): return None, None pub = cle_publique() if pub and not self.dry_run: with open(pub, encoding="utf-8") as fh: contenu = fh.read().strip() self.executer( parent, f"mkdir -p /root/.ssh && printf '%s\\n'" f" {shlex.quote(contenu)} > {CLE_DISTANTE}", DELAIS["controle"], "clé", ) creation = ( f"python3 {DISTANT_CLI} --distro {DISTRO} --name {nom}" f" --vcpus {res['vcpu']} --memory {res['ram']}" f" --disk-size {res['disque']}G --network network={reseau}" f" --ssh-key {CLE_DISTANTE} --assume-yes" ) code, _o = self.executer( parent, creation, self.delai("creation"), "deploy_qemu", montrer=True, ) if code and not self.dry_run: return None, None if self.dry_run: return nom, "10.10.10.150" # Le domaine est-il vraiment accéléré ? « deploy_qemu » n'échoue PAS # quand KVM manque : il pose --virt-type qemu et continue. Un étage # émulé fausserait toute la mesure sans rien dire. _c, xml = self.executer( parent, f"virsh -c qemu:///system dumpxml {nom}", self.delai("controle"), "dumpxml", ) vu = parse_domaine(xml) if vu["type"] != "kvm": self.dire( f" ✗ domaine type='{vu['type'] or '?'}' : cette VM est" " ÉMULÉE, la mesure ne voudrait rien dire" ) return None, None self.dire(f" domaine kvm, cpu {vu['cpu'] or '?'}") adresse = self.attendre_adresse(parent, nom) if not adresse: self.dire(" ✗ créée, mais sans adresse : rien à joindre") return None, None self.dire(f" {nom} : {adresse}") return nom, adresse def detruire_une(parent_alias, identite, nom, journal): """Arrête puis détruit UNE VM chez son parent, par son NOM et son UUID. Par égalité STRICTE du nom : un filtre par sous-chaîne aurait pris une « deep-qemu-lab » de production, et « --remove-all-storage » efface un disque pour de bon. """ parent = {"target": parent_alias, "sudo": "sudo ", "jump": ""} code, out = pve.run( parent, "virsh -c qemu:///system list --all --name", 180 ) if code: dire(f" ✗ {parent_alias} injoignable : rien touché", journal) return False presents = [ ligne.strip() for ligne in pve.strip_ssh_noise(out).splitlines() if ligne.strip() ] if nom not in presents: dire(f" — {nom} : absent de {parent_alias}", journal) return True pve.run(parent, f"virsh -c qemu:///system destroy {nom}", 300) code, _o = pve.run( parent, f"virsh -c qemu:///system undefine {nom} --nvram --remove-all-storage", 600, ) if code: dire(f" ✗ {nom} sur {parent_alias} : undefine a échoué", journal) return False dire(f" ✓ {nom} ({identite}) sur {parent_alias}", journal) return True FAMILLE = Famille(OUTIL, NOM_BASE, detruire_une) def principal(argv=None): return mener( argv, "Jusqu'à quel étage une QEMU dans une QEMU tient-elle ?", FAMILLE, Descente, nesting.COUTS_QEMU, ) if __name__ == "__main__": sys.exit(principal())