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)
412 lines
16 KiB
Python
Executable file
412 lines
16 KiB
Python
Executable file
#!/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 un Proxmox dans un Proxmox tient-il ?
|
|
|
|
Ce n'est pas un test unitaire : il crée de vraies machines et prend des
|
|
HEURES. Il vit donc hors de `test/`, que le lanceur unitaire balaie.
|
|
|
|
Ce qu'il établit, et pourquoi cela valait un script : la profondeur
|
|
d'imbrication praticable ne se déduit pas, elle se mesure. Mesuré ici, sur une
|
|
machine à 28 cœurs : trois étages coûtent 34 minutes, et le quatrième 4 h 20
|
|
d'amorçage plus 7 h 18 d'installation. Tout y est 15 à 30 fois plus lent — et
|
|
c'est là que les fabricants cessent de documenter l'imbrication.
|
|
|
|
La descente et ce qu'elle sait sont dans `descente.py`, partagés avec
|
|
`deep_qemu.py`. Ce fichier-ci n'a que les VERBES de Proxmox : « qm create »
|
|
chez le parent, install_proxmox.sh, le noyau -pve, pmxcfs debout, un stockage
|
|
capable d'accueillir l'étage suivant.
|
|
|
|
Il envoie NOTRE install_proxmox.sh par scp au lieu de laisser la VM cloner le
|
|
dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant est souvent
|
|
en retard sur le checkout — un correctif absent du distant a fait « revenir »
|
|
le même défaut sur trois VM de suite.
|
|
|
|
./long_test/deep_proxmox.py # trois étages, ~34 minutes
|
|
./long_test/deep_proxmox.py --depth 5 # en demander plus, sciemment
|
|
./long_test/deep_proxmox.py --dry-run # le plan, rien de créé
|
|
./long_test/deep_proxmox.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"))
|
|
|
|
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,
|
|
alias_etage as _alias_etage,
|
|
autre_descente,
|
|
capacite_hote,
|
|
cle_publique,
|
|
dernier_rapport,
|
|
descente_vivante,
|
|
detruire,
|
|
detruire_etage1,
|
|
dire,
|
|
identite_de,
|
|
module_qemu,
|
|
nom_etage as _nom_etage,
|
|
retirer_alias,
|
|
)
|
|
|
|
# L'image des étages imbriqués. Debian parce que install_proxmox.sh s'installe
|
|
# SUR une Debian — Proxmox ne publie pas d'image cloud.
|
|
DISTRO = "proxmox"
|
|
NOM_BASE = "deep-pve"
|
|
OUTIL = "deep_proxmox"
|
|
|
|
|
|
def nom_etage(niveau):
|
|
return _nom_etage(niveau, NOM_BASE)
|
|
|
|
|
|
def alias_etage(niveau, parent_alias):
|
|
return _alias_etage(niveau, parent_alias, NOM_BASE)
|
|
|
|
|
|
class Descente(descente.Descente):
|
|
"""Les verbes de Proxmox. Le reste est dans `descente.Descente`."""
|
|
|
|
OUTIL = OUTIL
|
|
NOM_BASE = NOM_BASE
|
|
DISTRO = DISTRO
|
|
|
|
def noyau_convient(self, noyau):
|
|
"""Le noyau Proxmox, et pas celui de Debian.
|
|
|
|
Sans lui la machine reste sur le noyau cloud, dépouillé de tout
|
|
netfilter : ni pont NAT, ni invité.
|
|
"""
|
|
return "-pve" in noyau
|
|
|
|
def installer(self, hote):
|
|
"""Envoie NOTRE script et l'exécute. Rend True si Proxmox est posé."""
|
|
local = os.path.join(RACINE, "script/proxmox/install_proxmox.sh")
|
|
distant = "/tmp/install_proxmox.sh"
|
|
if self.dry_run:
|
|
print(f" scp {local} <hôte>:{distant}")
|
|
print(f" bash {distant}")
|
|
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}"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300,
|
|
)
|
|
if res.returncode:
|
|
self.dire(f" ✗ scp : {res.stderr.strip()[:200]}")
|
|
return False
|
|
# « bash » et non « sh » : le script porte « set -euo pipefail » et un
|
|
# shebang bash. Sur Debian /bin/sh est dash, qui répond « set: Illegal
|
|
# option -o pipefail » et sort à la PREMIÈRE ligne — vérifié. Chaque
|
|
# étage aurait échoué sur l'installation, à tous les coups.
|
|
code, _o = self.executer(
|
|
dict(hote, sudo=""),
|
|
f"bash {distant}",
|
|
self.delai("install"),
|
|
"install_proxmox.sh",
|
|
montrer=True,
|
|
)
|
|
return code == 0
|
|
|
|
def preparer_parent(self, parent):
|
|
"""Stockage, pont et réseau interne du parent, ou None.
|
|
|
|
Les codes de retour des LECTURES sont regardés, et c'est tout le
|
|
sujet ici. Ailleurs dans ce dépôt un code de retour ne prouve rien —
|
|
celui d'une commande distante composée est celui du dernier maillon.
|
|
Mais pour une lecture, il est la SEULE chose qui distingue « j'ai lu,
|
|
il n'y a rien » de « je n'ai pas pu lire ».
|
|
|
|
La différence n'est pas académique : de l'absence de pont on
|
|
RECONFIGURE le réseau du parent. Un « ip link show » qui échoue — un
|
|
hoquet ssh, un sudo pas encore prêt — se lisait « pas de pont », et on
|
|
posait un pont et un NAT sur une machine qui en avait déjà un.
|
|
"""
|
|
code, out = self.executer(
|
|
parent,
|
|
"pvesm status --content images",
|
|
DELAIS["controle"],
|
|
"pvesm",
|
|
)
|
|
if code and not self.dry_run:
|
|
self.dire(" ✗ « pvesm status » a échoué : rien conclu")
|
|
return None
|
|
stockage = pve.pick_storage(pve.parse_storages(out))
|
|
if not stockage and not self.dry_run:
|
|
self.dire(" ✗ aucun stockage sur le parent")
|
|
return None
|
|
code, out = self.executer(
|
|
parent,
|
|
"ip -o link show type bridge",
|
|
self.delai("controle"),
|
|
"ponts",
|
|
)
|
|
if code and not self.dry_run:
|
|
self.dire(
|
|
" ✗ liste des ponts illisible : on ne touche PAS au"
|
|
" réseau du parent"
|
|
)
|
|
return None
|
|
ponts = pve.parse_bridges(out)
|
|
if not ponts:
|
|
_c, nets = self.executer(
|
|
parent, pve.USED_NETS_CMD, self.delai("controle"), "réseaux"
|
|
)
|
|
cidr = pve.pick_internal_cidr(nets) or pve.INTERNAL_CIDR
|
|
_c, rt = self.executer(
|
|
parent,
|
|
"ip -o -4 route show default",
|
|
DELAIS["controle"],
|
|
"uplink",
|
|
)
|
|
trouve = re.search(r"dev\s+(\S+)", rt or "")
|
|
uplink = trouve.group(1) if trouve else ""
|
|
self.dire(f" pont {cidr}, NAT par {uplink or '—'}")
|
|
for cmd in pve.bridge_setup_cmds(cidr=cidr, uplink=uplink):
|
|
code, _o = self.executer(
|
|
parent, cmd, DELAIS["reparation"], "pont"
|
|
)
|
|
if code and not self.dry_run:
|
|
return None
|
|
ponts = [pve.INTERNAL_BRIDGE]
|
|
_c, cfg = self.executer(
|
|
parent,
|
|
"cat /etc/network/interfaces",
|
|
DELAIS["controle"],
|
|
"interfaces",
|
|
)
|
|
# Le DNS de l'hôte. « --ipconfig0 » ne le porte PAS : une VM en
|
|
# adresse fixe route mais ne résout rien, et install_proxmox.sh meurt
|
|
# sur « apt update » sans que rien ne l'explique. Le rapport imputerait
|
|
# à l'installation ce qui est un défaut de résolveur.
|
|
_c, resolv = self.executer(
|
|
parent, pve.RESOLV_CMD, self.delai("controle"), "resolv"
|
|
)
|
|
return (
|
|
stockage or "local",
|
|
ponts[0],
|
|
pve.parse_bridge_config(cfg).get(ponts[0], {}),
|
|
pve.parse_nameservers(resolv),
|
|
)
|
|
|
|
def creer_enfant(self, parent, niveau, res, prepare, noter=None):
|
|
"""« qm create » sur le parent. Rend (vmid, adresse) ou (None, None).
|
|
|
|
`noter` reçoit le VMID AVANT la première commande qui peut créer la
|
|
VM. Sans lui, le VMID ne remontait qu'au RETOUR : une création qui
|
|
échouait à la quatrième de ses six commandes — « qm resize » sur un
|
|
stockage plein, par exemple — laissait une VM allumée et un disque
|
|
alloué que le rapport ne nommait nulle part, donc que « --detruire »
|
|
ne pouvait pas défaire.
|
|
"""
|
|
stockage, pont, info_pont, dns = prepare
|
|
mod = module_qemu()
|
|
version = mod.DISTROS[DISTRO][1]
|
|
code_img = mod.DISTROS[DISTRO][0][version][0]
|
|
url = mod.image_url(DISTRO, code_img, "amd64", version)
|
|
image = mod.default_image_name(DISTRO, code_img, "amd64", version)
|
|
_c, out = self.executer(
|
|
parent, "qm list", self.delai("controle"), "qm list"
|
|
)
|
|
vmid = pve.next_vmid(pve.parse_qm_list(out))
|
|
ipconfig = pve.ipconfig_for(info_pont, vmid)
|
|
adresse = pve.ip_from_ipconfig(ipconfig)
|
|
# AVANT de télécharger l'image et de démarrer quoi que ce soit : sur un
|
|
# pont relié au LAN, ipconfig_for rend « ip=dhcp » et l'adresse est
|
|
# vide. Le contrôle venait après la création : on laissait une VM
|
|
# allumée, un disque alloué, et une machine que --detruire ne
|
|
# connaissait pas.
|
|
if not adresse and self.dry_run:
|
|
# En essai à blanc on n'a rien lu du parent : conclure « pas de
|
|
# pont interne » serait une affirmation tirée d'une mesure qui
|
|
# n'a pas eu lieu. On prend une adresse plausible pour dérouler
|
|
# le plan jusqu'au bout.
|
|
adresse = "10.10.10.150"
|
|
ipconfig = f"ip={adresse}/24,gw=10.10.10.1"
|
|
if not adresse:
|
|
self.dire(" ✗ pas d'adresse fixe : le parent n'a pas de")
|
|
self.dire(" pont interne, et l'enfant serait injoignable")
|
|
return None, None
|
|
spec = {
|
|
"name": nom_etage(niveau),
|
|
"storage": stockage,
|
|
"image": image,
|
|
"memory": res["ram"],
|
|
"vcpus": res["vcpu"],
|
|
"bridge": pont,
|
|
"disk": f"{res['disque']}G",
|
|
"user": "erplibre",
|
|
"ipconfig": ipconfig,
|
|
"sshkey_path": "/root/.ssh/longtest.pub",
|
|
"nameservers": dns,
|
|
"start": True,
|
|
}
|
|
# La clé publique doit être un FICHIER sur le parent : « --sshkeys »
|
|
# n'accepte pas la clé en ligne.
|
|
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)} > /root/.ssh/longtest.pub",
|
|
DELAIS["controle"],
|
|
"clé",
|
|
)
|
|
# Le VMID est annoncé MAINTENANT. Un numéro noté pour une VM qui
|
|
# n'existera jamais ne coûte rien — « --detruire » lit « qm list » et
|
|
# la dit absente — alors qu'une VM créée et non notée reste sur le
|
|
# parent, invisible.
|
|
if noter:
|
|
noter(vmid)
|
|
for cmd in [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
|
|
vmid, spec
|
|
):
|
|
code, _o = self.executer(
|
|
parent, cmd, self.delai("creation"), "qm create"
|
|
)
|
|
if code and not self.dry_run:
|
|
return None, None
|
|
return vmid, adresse
|
|
|
|
# ---------------------------------------------------------------- #
|
|
# La descente
|
|
def remettre_debout(self, hote):
|
|
"""Les unités PVE, puis le CONSTAT que /etc/pve est monté."""
|
|
if self.dry_run:
|
|
print(" unités PVE + montage de /etc/pve")
|
|
return True
|
|
# « pve_unit_cmd » joint le journal de l'unité à un échec — « la seule
|
|
# façon de dire la cause à quelqu'un dont le seul accès à l'hôte est
|
|
# cet outil », dit son propre commentaire. On le JETAIT : quand le
|
|
# montage échouait ensuite, il ne restait qu'un « /etc/pve : ABSENT »
|
|
# sans cause, et il fallait retourner sur la machine pour la chercher.
|
|
echecs = []
|
|
for unite in pve.PVE_UNITS:
|
|
code, sortie = self.executer(
|
|
hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite
|
|
)
|
|
propre = pve.strip_ssh_noise(sortie)
|
|
if code or "-KO" in propre:
|
|
echecs.append((unite, propre))
|
|
_c, out = self.executer(
|
|
hote, pve.mount_wait_cmd(), self.delai("reparation"), "montage"
|
|
)
|
|
vu = pve.parse_mount_wait(out)
|
|
self.dire(f" /etc/pve : {vu['verdict']}")
|
|
if vu["verdict"] != "MONTE":
|
|
for unite, propre in echecs:
|
|
self.dire(f" ↳ {unite} : {propre.strip()[-400:]}")
|
|
if not echecs:
|
|
# Toutes debout et le montage absent : le dire, plutôt que de
|
|
# laisser croire qu'on n'a pas regardé.
|
|
self.dire(" ↳ toutes les unités PVE sont debout")
|
|
return vu["verdict"] == "MONTE"
|
|
|
|
def controler(self, hote):
|
|
"""Ce parent peut-il héberger l'étage suivant ?
|
|
|
|
Sixième étape, et elle manquait : le contrôle du stockage était celui
|
|
du DÉBUT de l'étage suivant, si bien qu'un étage marqué « terminé »
|
|
pouvait n'avoir aucun stockage capable d'accueillir une image — et le
|
|
compteur d'étages atteints mentait d'autant.
|
|
"""
|
|
if self.dry_run:
|
|
print(" pvesm status : un stockage pour les images")
|
|
return True
|
|
code, out = self.executer(
|
|
hote,
|
|
"pvesm status --content images",
|
|
DELAIS["controle"],
|
|
"pvesm",
|
|
)
|
|
if code:
|
|
self.dire(" ✗ « pvesm status » a échoué : rien conclu")
|
|
return False
|
|
stockage = pve.pick_storage(pve.parse_storages(out))
|
|
if not stockage:
|
|
self.dire(" ✗ aucun stockage pour les images")
|
|
return False
|
|
self.dire(f" stockage : {stockage}")
|
|
return True
|
|
|
|
|
|
def detruire_une(parent_alias, vmid, nom, journal):
|
|
"""Arrête puis détruit UNE VM, par son VMID. Rend True si elle a disparu.
|
|
|
|
Par le VMID et par égalité stricte du nom : un filtre par sous-chaîne
|
|
aurait pris une « deep-pve-lab » de production, et « --purge » emporte les
|
|
disques ET les entrées de sauvegarde.
|
|
|
|
L'arrêt est CONSTATÉ avant la destruction : « qm stop » rend la main dès
|
|
que la tâche est lancée, et sur un hyperviseur imbriqué mesuré 36 fois
|
|
plus lent, « qm destroy » arrivait alors que la VM tournait encore et
|
|
refusait avec « VM is running ».
|
|
"""
|
|
parent = {"target": parent_alias, "sudo": "sudo ", "jump": ""}
|
|
code, out = pve.run(parent, "qm list", 180)
|
|
if code:
|
|
dire(f" ✗ {parent_alias} injoignable : rien touché", journal)
|
|
return False
|
|
presentes = {
|
|
int(v["vmid"]): (v.get("name") or "") for v in pve.parse_qm_list(out)
|
|
}
|
|
if vmid not in presentes:
|
|
dire(f" — {vmid} déjà absente de {parent_alias}", journal)
|
|
return True
|
|
if presentes[vmid] != nom:
|
|
dire(
|
|
f" ✗ {vmid} sur {parent_alias} s'appelle"
|
|
f" « {presentes[vmid]} », pas « {nom} » : rien touché",
|
|
journal,
|
|
)
|
|
return False
|
|
pve.run(parent, f"qm stop {vmid} --skiplock 1 || true", 300)
|
|
for _ in range(20):
|
|
_c, etat = pve.run(parent, f"qm status {vmid}", 120)
|
|
if "stopped" in pve.strip_ssh_noise(etat):
|
|
break
|
|
time.sleep(6)
|
|
code, out = pve.run(parent, f"qm destroy {vmid} --purge 1", 600)
|
|
if code:
|
|
dire(f" ✗ qm destroy {vmid} : code {code}", journal)
|
|
for ligne in pve.strip_ssh_noise(out).strip().splitlines()[-3:]:
|
|
dire(f" {ligne}", journal)
|
|
return False
|
|
dire(f" ✓ {nom} ({vmid}) sur {parent_alias}", journal)
|
|
return True
|
|
|
|
|
|
FAMILLE = Famille(OUTIL, NOM_BASE, detruire_une)
|
|
|
|
|
|
def principal(argv=None):
|
|
return mener(
|
|
argv,
|
|
"Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?",
|
|
FAMILLE,
|
|
Descente,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(principal())
|