diff --git a/long_test/deep_proxmox.py b/long_test/deep_proxmox.py index 3569855..c440314 100755 --- a/long_test/deep_proxmox.py +++ b/long_test/deep_proxmox.py @@ -28,8 +28,6 @@ le même défaut sur trois VM de suite. ./long_test/deep_proxmox.py --detruire # défaire ce qui a été posé """ -import argparse -import json import os import re import shlex @@ -41,12 +39,12 @@ 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")) -from script.proxmox import nesting # noqa: E402 from script.proxmox import proxmox_deploy as pve # noqa: E402 import descente # noqa: E402 from descente import ( # noqa: E402,F401 DELAIS, + mener, _lance_une_descente, Famille, a_defaire, @@ -402,74 +400,12 @@ FAMILLE = Famille(OUTIL, NOM_BASE, detruire_une) def principal(argv=None): - parseur = argparse.ArgumentParser( - description="Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?" + return mener( + argv, + "Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?", + FAMILLE, + Descente, ) - # Trois par défaut, et c'est une MESURE, pas une prudence : les trois - # premiers étages coûtent 280, 495 et 1 064 secondes — une demi-heure en - # tout. Le quatrième en a coûté 7 h 18 d'installation et 4 h 20 d'amorçage - # sur la même machine. Un défaut à dix promettait ce qu'aucune machine ne - # peut tenir ; la profondeur reste un paramètre, et c'est à qui la demande - # de savoir ce qu'il demande. - parseur.add_argument("--depth", type=int, default=3) - parseur.add_argument("--dry-run", action="store_true") - parseur.add_argument("--detruire", action="store_true") - args = parseur.parse_args(argv) - - journal = os.path.expanduser( - f"~/.erplibre/longtest/deep-pve-{time.strftime('%Y%m%d-%H%M%S')}.log" - ) - os.makedirs(os.path.dirname(journal), exist_ok=True) - if args.detruire: - # « --dry-run » était ignoré ici : la prudence naturelle avant une - # destruction détruisait pour de vrai. - return detruire(FAMILLE, journal, dry_run=args.dry_run) - - coeurs, ram, disque = capacite_hote() - print( - f"\n machine : {coeurs} cœurs, {ram} Mo disponibles," - f" {disque} Go de disque" - ) - plan = nesting.nesting_plan(args.depth, coeurs, ram, disque) - print(f"\n {'étage':>5} {'vCPU':>4} {'RAM':>9} {'disque':>8}") - for n in plan["niveaux"]: - print( - f" {n['niveau']:>5} {n['vcpu']:>4} {n['ram']:>6} Mo" - f" {n['disque']:>5} Go" - ) - if plan["arret"]: - # Les TROIS plafonds, pas seulement celui qui borne : sans eux on - # ajoute la ressource nommée sans savoir de combien, ni laquelle - # bornera ensuite. - plafonds = " · ".join( - f"{nom} {valeur}" for nom, valeur in plan["plafonds"].items() - ) - print( - f"\n ⚠ demandée {plan['demandee']}, atteignable" - f" {plan['atteignable']} — c'est le {plan['arret']} qui borne" - f"\n profondeur permise par chaque ressource : {plafonds}" - ) - if not plan["niveaux"]: - if args.depth < 1: - print(f"\n profondeur demandée : {args.depth} — rien à faire.\n") - return 0 - print("\n ✗ pas même un étage ne tient sur cette machine.\n") - return 1 - print(f"\n journal : {journal}") - if args.dry_run: - print(" --dry-run : rien ne sera créé.\n") - chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json") - descente = Descente(plan, journal, args.dry_run, chemin) - rapport = descente.parcourir() - with open(chemin, "w", encoding="utf-8") as fh: - json.dump(rapport, fh, indent=2) - print(f"\n rapport : {chemin}\n") - # En essai à blanc, c'est le PLAN qui est complet ou non — aucune - # profondeur n'a été atteinte. Hors essai, « non nul » ne suffisait pas : - # une descente morte au deuxième étage sur dix rendait 0. - if args.dry_run: - return 0 if rapport["atteignable"] == rapport["demandee"] else 1 - return 0 if rapport["atteinte"] == rapport["demandee"] else 1 if __name__ == "__main__": diff --git a/long_test/deep_qemu.py b/long_test/deep_qemu.py new file mode 100644 index 0000000..a7b2466 --- /dev/null +++ b/long_test/deep_qemu.py @@ -0,0 +1,442 @@ +#!/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 + +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")) + +from script.proxmox import nesting # noqa: E402 +from script.proxmox import proxmox_deploy as pve # noqa: E402 + +import descente # noqa: E402 +from descente import ( # noqa: E402,F401 + DELAIS, + Famille, + a_defaire, + alias_etage as _alias_etage, + autre_descente, + capacite_hote, + cle_publique, + dernier_rapport, + descente_vivante, + detruire, + detruire_etage1, + dire, + identite_de, + mener, + module_qemu, + nom_etage as _nom_etage, + retirer_alias, +) + +# 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=/" +) + +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 + 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 + # Idempotent : sur un hôte déjà en ordre, les deux commandes ne font + # rien et rendent 0. + self.executer( + hote, + "systemctl enable --now libvirtd 2>/dev/null;" + " virsh -c qemu:///system net-start default 2>/dev/null;" + " virsh -c qemu:///system net-autostart default 2>/dev/null; true", + self.delai("reparation"), + "libvirtd", + ) + _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 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 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 '?'}") + _c, sortie = self.executer( + parent, + f"virsh -c qemu:///system domifaddr {nom} --source lease", + self.delai("ssh"), + "domifaddr", + ) + adresse = parse_domifaddr(sortie) + 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()) diff --git a/long_test/descente.py b/long_test/descente.py index 7ea17fe..827c8a5 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -19,6 +19,7 @@ remettre les services debout, comment contrôler qu'un étage peut héberger le suivant. Le reste est ici, écrit une fois. """ +import argparse import json import os import re @@ -30,6 +31,7 @@ import time RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, RACINE) +from script.proxmox import nesting # noqa: E402 from script.proxmox import proxmox_deploy as pve # noqa: E402 # Les scripts qui lancent une descente. Le verrou les cherche TOUS : deux @@ -684,6 +686,82 @@ def autre_descente(): return vivants +def mener(argv, description, famille, classe, couts=None): + """La ligne de commande, le plan, la descente et le rapport. + + Identique d'une pile à l'autre à quatre choses près : ce qu'on annonce, la + famille — qui nomme les fichiers et filtre les rapports —, la classe qui + porte les verbes, et ce que coûte un étage. + """ + parseur = argparse.ArgumentParser(description=description) + # Trois par défaut, et c'est une MESURE, pas une prudence : les trois + # premiers étages coûtent 280, 495 et 1 064 secondes — une demi-heure en + # tout. Le quatrième en a coûté 7 h 18 d'installation et 4 h 20 d'amorçage + # sur la même machine. Un défaut à dix promettait ce qu'aucune machine ne + # peut tenir ; la profondeur reste un paramètre, et c'est à qui la demande + # de savoir ce qu'il demande. + parseur.add_argument("--depth", type=int, default=3) + parseur.add_argument("--dry-run", action="store_true") + parseur.add_argument("--detruire", action="store_true") + args = parseur.parse_args(argv) + + journal = os.path.expanduser( + f"~/.erplibre/longtest/{famille.nom_base}" + f"-{time.strftime('%Y%m%d-%H%M%S')}.log" + ) + os.makedirs(os.path.dirname(journal), exist_ok=True) + if args.detruire: + # « --dry-run » était ignoré ici : la prudence naturelle avant une + # destruction détruisait pour de vrai. + return detruire(famille, journal, dry_run=args.dry_run) + + coeurs, ram, disque = capacite_hote() + print( + f"\n machine : {coeurs} cœurs, {ram} Mo disponibles," + f" {disque} Go de disque" + ) + plan = nesting.nesting_plan(args.depth, coeurs, ram, disque, couts) + print(f"\n {'étage':>5} {'vCPU':>4} {'RAM':>9} {'disque':>8}") + for n in plan["niveaux"]: + print( + f" {n['niveau']:>5} {n['vcpu']:>4} {n['ram']:>6} Mo" + f" {n['disque']:>5} Go" + ) + if plan["arret"]: + # Les TROIS plafonds, pas seulement celui qui borne : sans eux on + # ajoute la ressource nommée sans savoir de combien, ni laquelle + # bornera ensuite. + plafonds = " · ".join( + f"{nom} {valeur}" for nom, valeur in plan["plafonds"].items() + ) + print( + f"\n ⚠ demandée {plan['demandee']}, atteignable" + f" {plan['atteignable']} — c'est le {plan['arret']} qui borne" + f"\n profondeur permise par chaque ressource : {plafonds}" + ) + if not plan["niveaux"]: + if args.depth < 1: + print(f"\n profondeur demandée : {args.depth} — rien à faire.\n") + return 0 + print("\n ✗ pas même un étage ne tient sur cette machine.\n") + return 1 + print(f"\n journal : {journal}") + if args.dry_run: + print(" --dry-run : rien ne sera créé.\n") + chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json") + descente = classe(plan, journal, args.dry_run, chemin) + rapport = descente.parcourir() + with open(chemin, "w", encoding="utf-8") as fh: + json.dump(rapport, fh, indent=2) + print(f"\n rapport : {chemin}\n") + # En essai à blanc, c'est le PLAN qui est complet ou non — aucune + # profondeur n'a été atteinte. Hors essai, « non nul » ne suffisait pas : + # une descente morte au deuxième étage sur dix rendait 0. + if args.dry_run: + return 0 if rapport["atteignable"] == rapport["demandee"] else 1 + return 0 if rapport["atteinte"] == rapport["demandee"] else 1 + + class Famille: """Ce qu'une pile doit dire d'elle aux fonctions qui détruisent. diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index 4c2c979..14cab13 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -144,11 +144,61 @@ HOTE_RESERVE_VCPU = 2 PROFONDEUR_SURE = 2 +class Couts: + """Ce que coûte UN étage de la pile qu'on empile. + + Les constantes vCPU décrivent la physique de l'imbrication — la pagination + imbriquée frappe un QEMU nu comme un nœud Proxmox — et valent donc pour + toutes les piles. Ces six nombres-ci, non : cinq démons PVE demandent + 2 Gio quand libvirtd seul tient dans un, et un nœud Proxmox occupe 5,6 Go + de disque contre 3 pour une Debian et qemu-kvm. + """ + + def __init__( + self, + ram_par_etage, + disque_par_etage, + ram_cible, + disque_cible, + ram_min, + disque_min, + ): + self.ram_par_etage = ram_par_etage + self.disque_par_etage = disque_par_etage + self.ram_cible = ram_cible + self.disque_cible = disque_cible + self.ram_min = ram_min + self.disque_min = disque_min + + +COUTS_PVE = Couts( + ram_par_etage=PVE_RAM_MO, + disque_par_etage=PVE_DISQUE_GO, + ram_cible=PVE_RAM_CIBLE_MO, + disque_cible=PVE_DISQUE_CIBLE_GO, + ram_min=RAM_MIN_MO, + disque_min=DISQUE_MIN_GO, +) + +# Un hôte libvirt nu : libvirtd et qemu-kvm, rien d'autre. Le poste qui domine +# n'est plus le système mais l'IMAGE CLOUD que l'étage télécharge pour créer +# son enfant — d'où un disque cible qui n'est pas si petit. +COUTS_QEMU = Couts( + ram_par_etage=1024, + disque_par_etage=6, + ram_cible=2048, + disque_cible=20, + ram_min=1024, + disque_min=12, +) + + def nesting_plan( profondeur: int, cpu_hote: int, ram_dispo_mo: int, disque_libre_go: int, + couts: "Couts" = None, ) -> dict: """Les ressources de chaque étage, dimensionnées DEPUIS LE BAS. @@ -180,6 +230,7 @@ def nesting_plan( étages et en réussir six que d'en promettre dix et mourir au septième sans savoir pourquoi. """ + couts = couts or COUTS_PVE reserve = max(HOTE_RESERVE_RAM_MO, int(ram_dispo_mo) // HOTE_RESERVE_PART) budget_ram = ((int(ram_dispo_mo) - reserve) // 1024) * 1024 budget_disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO @@ -194,8 +245,8 @@ def nesting_plan( quelle que soit la profondeur. """ return ( - PVE_RAM_CIBLE_MO + (d - 1) * PVE_RAM_MO, - PVE_DISQUE_CIBLE_GO + (d - 1) * PVE_DISQUE_GO, + couts.ram_cible + (d - 1) * couts.ram_par_etage, + couts.disque_cible + (d - 1) * couts.disque_par_etage, VCPU_METAL if d > 1 else VCPU_IMBRIQUE, ) @@ -205,8 +256,10 @@ def nesting_plan( # coût suivait la profondeur demandée — nesting_plan(10**6, …) tournait un # million de tours pour rendre le même plan. plafonds = { - "ram": (budget_ram - PVE_RAM_CIBLE_MO) // PVE_RAM_MO + 1, - "disque": (budget_disque - PVE_DISQUE_CIBLE_GO) // PVE_DISQUE_GO + 1, + "ram": (budget_ram - couts.ram_cible) // couts.ram_par_etage + 1, + "disque": (budget_disque - couts.disque_cible) + // couts.disque_par_etage + + 1, # Le processeur ne borne plus la profondeur : les étages imbriqués # gardent une largeur fixe, seul le premier compte sur le métal. Il # borne encore à ZÉRO une machine trop petite pour ce premier étage. @@ -246,10 +299,10 @@ def nesting_plan( # quelqu'un baisse une CIBLE un jour. Sans eux, ils n'étaient plus # lus par personne et les tests qui les vérifiaient passaient # d'eux-mêmes. - "ram": max(RAM_MIN_MO, PVE_RAM_CIBLE_MO) - + (atteignable - niveau) * PVE_RAM_MO, - "disque": max(DISQUE_MIN_GO, PVE_DISQUE_CIBLE_GO) - + (atteignable - niveau) * PVE_DISQUE_GO, + "ram": max(couts.ram_min, couts.ram_cible) + + (atteignable - niveau) * couts.ram_par_etage, + "disque": max(couts.disque_min, couts.disque_cible) + + (atteignable - niveau) * couts.disque_par_etage, } for niveau in range(1, atteignable + 1) ] diff --git a/test/test_deep_qemu.py b/test/test_deep_qemu.py new file mode 100644 index 0000000..859453b --- /dev/null +++ b/test/test_deep_qemu.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le test long QEMU-dans-QEMU, et le garde qui donne un sens à sa mesure. + +`deploy_qemu.py` n'échoue PAS quand KVM manque : il pose « --virt-type qemu » +et crée une VM entièrement émulée, sept minutes et demie de démarrage, sans +qu'aucun code de retour ne le dise. Une descente qui ne le vérifierait pas +irait plus « profond » en mesurant de la TCG empilée — un chiffre plus +flatteur, et faux. + +Ces tests-ci ne créent aucune machine : ils lisent des sorties de `virsh` et de +`/sys` telles qu'elles arrivent vraiment, et vérifient ce qu'on en conclut. +""" + +import contextlib +import io +import os +import sys +import unittest + +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")) +sys.argv = ["todo.py"] + +import deep_qemu # noqa: E402 + + +class TestLireCeQueVirshEcrit(unittest.TestCase): + """Des sorties RÉELLES, prises sur la machine, pas inventées.""" + + def test_the_address_table_has_a_header_and_a_mask(self): + vrai = ( + " Name MAC address Protocol Address\n" + "----------------------------------------------------------\n" + " vnet3 52:54:00:79:78:a4 ipv4 192.168.123.118/24\n" + ) + self.assertEqual(deep_qemu.parse_domifaddr(vrai), "192.168.123.118") + + def test_a_domain_without_a_lease_yet_gives_nothing(self): + vide = ( + " Name MAC address Protocol Address\n" + "----------------------------------------------------------\n" + " vnet0 52:54:00:aa:bb:cc N/A N/A\n" + ) + self.assertEqual(deep_qemu.parse_domifaddr(vide), "") + self.assertEqual(deep_qemu.parse_domifaddr(""), "") + self.assertEqual(deep_qemu.parse_domifaddr(None), "") + + def test_an_emulated_domain_is_recognised(self): + emule = "\n deep-qemu-2\n" + self.assertEqual(deep_qemu.parse_domaine(emule)["type"], "qemu") + + def test_an_accelerated_domain_and_its_cpu_mode(self): + vrai = ( + "\n" + " deep-qemu-2\n" + " \n" + ) + vu = deep_qemu.parse_domaine(vrai) + self.assertEqual(vu["type"], "kvm") + self.assertEqual(vu["cpu"], "host-passthrough") + + def test_an_unreadable_dumpxml_claims_nothing(self): + vu = deep_qemu.parse_domaine("error: failed to get domain") + self.assertEqual(vu["type"], "") + self.assertEqual(vu["cpu"], "") + + +class TestUnEtageQuiNeSaitPasHeberger(unittest.TestCase): + """Ce qui n'a pas été lu vaut NON. + + 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é. Traiter l'absence + comme un oui rendrait le contrôle décoratif.""" + + def test_a_missing_nested_line_is_not_a_yes(self): + vu = deep_qemu.parse_controle("KVM=oui\nDISQUE=120G\n") + self.assertTrue(vu["kvm"]) + self.assertFalse(vu["nested"]) + self.assertEqual(vu["disque_go"], 120) + + def test_both_spellings_of_yes_are_accepted(self): + for valeur in ("Y", "1"): + with self.subTest(valeur=valeur): + vu = deep_qemu.parse_controle(f"KVM=oui\nNESTED={valeur}\n") + self.assertTrue(vu["nested"]) + + def test_nested_off_is_read_as_off(self): + for valeur in ("N", "0"): + with self.subTest(valeur=valeur): + vu = deep_qemu.parse_controle(f"KVM=oui\nNESTED={valeur}\n") + self.assertFalse(vu["nested"]) + + def test_no_kvm_device_at_all(self): + vu = deep_qemu.parse_controle("KVM=non\nNESTED=Y\nDISQUE=50G\n") + self.assertFalse(vu["kvm"]) + + def test_an_empty_answer_asserts_nothing(self): + vu = deep_qemu.parse_controle("") + self.assertFalse(vu["kvm"]) + self.assertFalse(vu["nested"]) + self.assertEqual(vu["disque_go"], 0) + + def test_the_probe_is_written_for_dash(self): + """/bin/sh est dash sur Debian : « set -o pipefail » y répond + « Illegal option » et sort à la première ligne.""" + for interdit in ("[[", "pipefail", "$(", "&&\n"): + self.assertNotIn(interdit, deep_qemu.CONTROLE_CMD, interdit) + + +class TestLeControleArreteLaDescente(unittest.TestCase): + """Un étage sans KVM ne casse pas : il bascule en émulation et continue. + C'est ce silence-là que le contrôle doit rompre.""" + + def setUp(self): + self.d = deep_qemu.Descente.__new__(deep_qemu.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 2 + + def _repond(self, sortie, code=0): + self.d.executer = lambda h, c, delai, etiquette="", **k: (code, sortie) + + def test_a_level_without_kvm_stops_the_descent(self): + self._repond("KVM=non\nNESTED=Y\nDISQUE=90G\n") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse(self.d.controler({"target": "h"})) + self.assertIn("serait ÉMULÉ", sortie.getvalue()) + + def test_a_level_without_nesting_stops_the_descent(self): + self._repond("KVM=oui\nNESTED=N\nDISQUE=90G\n") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse(self.d.controler({"target": "h"})) + self.assertIn("imbriquée absente", sortie.getvalue()) + + def test_an_unreadable_probe_concludes_nothing(self): + # Une lecture qui échoue ne dit pas « pas de KVM » : elle ne dit rien. + self._repond("", code=255) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse(self.d.controler({"target": "h"})) + self.assertIn("illisible", sortie.getvalue()) + + def test_a_healthy_level_passes(self): + """Le contrôle NÉGATIF : sans lui, ce garde interdirait toute + descente.""" + self._repond("KVM=oui\nNESTED=Y\nDISQUE=90G\n") + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(self.d.controler({"target": "h"})) + + +class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase): + """« deploy_qemu » rend 0 en créant une VM émulée. La descente doit s'en + apercevoir à la création, pas après sept minutes de démarrage.""" + + def setUp(self): + self.d = deep_qemu.Descente.__new__(deep_qemu.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 2 + self.d._envoyer_cli = lambda hote: True + + def _machine(self, xml, adresse=" x y ipv4 10.0.0.9/24"): + def executer(hote, cmd, delai, etiquette="", **k): + if "dumpxml" in cmd: + return 0, xml + if "domifaddr" in cmd: + return 0, adresse + return 0, "" + + self.d.executer = executer + + def test_an_emulated_child_is_refused(self): + self._machine("x") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + identite, adresse = self.d.creer_enfant( + {"target": "p"}, + 2, + {"vcpu": 2, "ram": 2048, "disque": 20}, + ("default",), + ) + self.assertIsNone(identite) + self.assertIsNone(adresse) + self.assertIn("ÉMULÉE", sortie.getvalue()) + + def test_an_accelerated_child_is_kept(self): + self._machine( + "x" + "" + ) + with contextlib.redirect_stdout(io.StringIO()): + identite, adresse = self.d.creer_enfant( + {"target": "p"}, + 2, + {"vcpu": 2, "ram": 2048, "disque": 20}, + ("default",), + ) + self.assertEqual(identite, "deep-qemu-2") + self.assertEqual(adresse, "10.0.0.9") + + def test_the_identity_is_noted_before_anything_is_created(self): + """Une création échouée à mi-chemin laisserait sinon une machine que + le rapport ne nomme nulle part — et que --detruire ne peut pas + défaire.""" + vus = [] + self._machine("") + with contextlib.redirect_stdout(io.StringIO()): + self.d.creer_enfant( + {"target": "p"}, + 4, + {"vcpu": 2, "ram": 2048, "disque": 20}, + ("default",), + noter=vus.append, + ) + # Notée, alors même que la création a été REFUSÉE ensuite. + self.assertEqual(vus, ["deep-qemu-4"]) + + def test_a_child_without_an_address_is_refused(self): + self._machine("", adresse=" x y N/A N/A") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + identite, _a = self.d.creer_enfant( + {"target": "p"}, + 2, + {"vcpu": 2, "ram": 2048, "disque": 20}, + ("default",), + ) + self.assertIsNone(identite) + self.assertIn("sans adresse", sortie.getvalue()) + + +class TestNeDetruireQueLeSien(unittest.TestCase): + """« virsh undefine --remove-all-storage » efface un disque pour de bon.""" + + def setUp(self): + self.vrai = deep_qemu.pve.run + self.addCleanup(setattr, deep_qemu.pve, "run", self.vrai) + self.lances = [] + + def _parent_avec(self, noms): + def faux(hote, remote, timeout=120): + self.lances.append(remote) + if "list --all --name" in remote: + return 0, "\n".join(noms) + "\n" + return 0, "" + + deep_qemu.pve.run = faux + + def test_a_name_that_merely_contains_ours_is_left_alone(self): + self._parent_avec(["deep-qemu-lab", "autre"]) + with contextlib.redirect_stdout(io.StringIO()): + res = deep_qemu.detruire_une( + "p", "deep-qemu-2", "deep-qemu-2", None + ) + self.assertTrue(res) # absente, donc rien à faire + self.assertFalse([c for c in self.lances if "undefine" in c]) + + def test_our_own_machine_is_stopped_then_undefined(self): + self._parent_avec(["deep-qemu-2"]) + with contextlib.redirect_stdout(io.StringIO()): + res = deep_qemu.detruire_une( + "p", "deep-qemu-2", "deep-qemu-2", None + ) + self.assertTrue(res) + ordre = [c for c in self.lances if "destroy" in c or "undefine" in c] + self.assertEqual(len(ordre), 2) + self.assertIn("destroy", ordre[0]) + self.assertIn("--remove-all-storage", ordre[1]) + + def test_an_unreachable_parent_touches_nothing(self): + deep_qemu.pve.run = lambda h, r, t=120: (255, "") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse( + deep_qemu.detruire_une("p", "deep-qemu-2", "deep-qemu-2", None) + ) + self.assertIn("rien touché", sortie.getvalue()) + + +class TestLesDeuxTestsLongsSeRessemblent(unittest.TestCase): + """Ce qui doit être identique doit l'être, et ce qui doit différer aussi.""" + + def setUp(self): + import deep_proxmox + + self.pve, self.qemu = deep_proxmox, deep_qemu + + def test_they_share_one_engine(self): + import descente + + for module in (self.pve, self.qemu): + self.assertTrue(issubclass(module.Descente, descente.Descente)) + + def test_every_hook_is_implemented_by_both(self): + import descente + + crochets = ( + "preparer_parent", + "creer_enfant", + "installer", + "noyau_convient", + "remettre_debout", + "controler", + ) + for module in (self.pve, self.qemu): + for crochet in crochets: + self.assertIsNot( + getattr(module.Descente, crochet), + getattr(descente.Descente, crochet), + f"{module.OUTIL} n'implémente pas {crochet}", + ) + + def test_they_never_share_a_name_a_tool_or_a_report(self): + self.assertNotEqual(self.pve.OUTIL, self.qemu.OUTIL) + self.assertNotEqual(self.pve.NOM_BASE, self.qemu.NOM_BASE) + self.assertNotEqual( + self.pve.FAMILLE.detruire_une, self.qemu.FAMILLE.detruire_une + ) + + def test_the_qemu_stack_asks_for_less(self): + """libvirtd seul tient dans un gibioctet là où cinq démons PVE en + demandent deux.""" + from script.proxmox import nesting + + pve = nesting.nesting_plan(3, 28, 39000, 150) + qemu = nesting.nesting_plan(3, 28, 39000, 150, nesting.COUTS_QEMU) + self.assertLess(qemu["niveaux"][0]["ram"], pve["niveaux"][0]["ram"]) + self.assertLess( + qemu["niveaux"][0]["disque"], pve["niveaux"][0]["disque"] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 03cc362..cc6fd5b 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -248,8 +248,10 @@ class TestLaProfondeurParDefaut(unittest.TestCase): _sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox - src = inspect.getsource(deep_proxmox.principal) + src = inspect.getsource(moteur.mener) self.assertIn('"--depth", type=int, default=3', src) + # Et les deux piles passent bien par là. + self.assertIn("mener(", inspect.getsource(deep_proxmox.principal)) def test_the_menu_defaults_to_three(self): import inspect @@ -352,8 +354,9 @@ class TestDefaireSansEffacerAutreChose(unittest.TestCase): # Une confirmation explicite, pas un « o/N » : le menu lançait cette # option d'une seule touche. self.assertIn("OUI", src) - principal = inspect.getsource(self.dp.principal) - self.assertIn("dry_run=args.dry_run", principal) + # La ligne de commande vit dans le moteur depuis qu'elle est + # identique d'une pile à l'autre. + self.assertIn("dry_run=args.dry_run", inspect.getsource(moteur.mener)) class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase):