[ADD] long_test : deep_qemu, et la preuve que KVM est bien là

Le pendant de deep_proxmox : des QEMU dans des QEMU. Le ralentissement du
quatrième étage vient du PROCESSEUR, mais le coût par étage vient de ce qu'on
installe — un nœud Proxmox pose un noyau, corosync, ceph et une interface web
là où un hôte libvirt pose libvirtd. Les deux mesures ensemble séparent ce qui
tient au matériel de ce qui tient à la pile.

Ce test ne peut pas se contenter de descendre. deploy_qemu.py ne passe jamais
« --cpu host-passthrough » 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 —
sept minutes et demie de démarrage, aucun code de retour pour le dire. Sans
garde, la descente mesurerait de la TCG empilée en croyant mesurer de
l'imbrication, et rendrait un chiffre plus flatteur et faux.

Chaque étage doit donc PROUVER : /dev/kvm lisible, « nested » à Y, et le
domaine de l'enfant en type='kvm'. Ce qui n'a pas été lu vaut NON — un
/sys/module absent, c'est un module non chargé, pas une permission.

nesting_plan reçoit ses coûts : les constantes vCPU décrivent la physique de
l'imbrication et valent pour les deux piles, les six nombres qui chiffrent un
Proxmox non. Un étage QEMU demande 2 Go et 20 Go, contre 4 et 25.

26 tests, six garde-fous morts sous mutation.

--- EN ---

The counterpart to deep_proxmox: QEMU inside QEMU. The fourth level's slowdown
comes from the PROCESSOR, but the per-level cost comes from what you install —
a Proxmox node lays down a kernel, corosync, ceph and a web UI where a libvirt
host lays down libvirtd. Together the two measurements separate what is due to
the hardware from what is due to the stack.

This test cannot merely descend. deploy_qemu.py never passes "--cpu
host-passthrough" and, when /dev/kvm is missing, it does NOT fail: it sets
"--virt-type qemu", warns on one line and creates a fully EMULATED VM — seven
and a half minutes to boot, no exit code to say so. Unguarded, the descent
would measure stacked TCG while believing it measured nesting, and return a
more flattering, false number.

Every level must therefore PROVE: /dev/kvm readable, "nested" at Y, and the
child's domain type='kvm'. What was not read counts as NO — an absent
/sys/module means an unloaded module, not a permission problem.

nesting_plan takes its costs: the vCPU constants describe the physics of
nesting and hold for both stacks, the six numbers that price a Proxmox do not.
A QEMU level asks 2 GB and 20 GB against 4 and 25.

26 tests, six guards die under mutation.

Assisted-by: claude-opus-5
(cherry picked from commit 39682cb1ce9648db91261387cae88c40c2a67837)
This commit is contained in:
Mathieu Benoit 2026-08-28 02:43:53 -04:00
parent b46615f3cf
commit 8ff88f03d2
6 changed files with 926 additions and 81 deletions

View file

@ -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__":

442
long_test/deep_qemu.py Normal file
View file

@ -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"<domain[^>]*\btype=['\"](\w+)['\"]", propre)
cpu = re.search(r"<cpu[^>]*\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} <hôte>:{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())

View file

@ -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.

View file

@ -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)
]

333
test/test_deep_qemu.py Normal file
View file

@ -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 = "<domain type='qemu' id='3'>\n <name>deep-qemu-2</name>\n"
self.assertEqual(deep_qemu.parse_domaine(emule)["type"], "qemu")
def test_an_accelerated_domain_and_its_cpu_mode(self):
vrai = (
"<domain type='kvm' id='3'>\n"
" <name>deep-qemu-2</name>\n"
" <cpu mode='host-passthrough' check='none' migratable='on'/>\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("<domain type='qemu' id='1'><name>x</name>")
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(
"<domain type='kvm' id='1'><name>x</name>"
"<cpu mode='host-passthrough'/>"
)
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("<domain type='qemu'>")
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("<domain type='kvm'>", 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()

View file

@ -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):