erplibre/LongTest/deep_proxmox.py
Mathieu Benoit 7199a7cbb2 [ADD] LongTest : jusqu'à quel étage un Proxmox imbriqué tient-il
La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une
mesure à la main a trouvé, au quatrième étage, un invité 36 fois plus lent que
le temps réel — 583 secondes d'horloge pour 16 secondes de temps invité,
chaque ligne d'ACPI prenant une seconde — puis un noyau gelé au MÊME octet
quelles que soient les ressources. Un chiffre obtenu une fois, sur une
machine, n'est pas un chiffre.

D'où trois choses.

L'algorithme, en fonctions pures. Deux ressources s'épuisent en descendant :
la mémoire, chaque étage gardant de quoi faire tourner ses propres démons, et
le disque, celui de l'enfant vivant DANS celui du parent. Une troisième se
dégrade, et elle borne le vCPU à deux au-delà du premier étage : douze ont
gelé le noyau invité, les mêmes deux avançaient. La mémoire n'est PAS bornée —
la même VM gelait au même octet avec 9 Go et avec 2 Go, donc la rogner ne
gagnerait rien et priverait l'étage du dessous. Le plan est annoncé avant
toute création, et jamais au-delà de ce qui tient.

Le garde-fou dans l'écran. Il lisait la capacité de l'HÔTE et l'offrait en
entier : sur un troisième étage à 14 cœurs, il a proposé 12 vCPU à une VM qui
n'a jamais démarré. Le nombre n'était pas absurde pour la machine ; il l'était
pour sa profondeur, que l'écran ignorait. Elle se compte maintenant sur la
chaîne de ProxyJump — un rebond par étage, et c'est nous qui écrivons ces
entrées.

Le test long, dans LongTest/ et non dans test/ : le lanceur unitaire doit
rester lançable en quelques secondes, partout, y compris sans virtualisation.
La descente est uniforme — créer, attendre le ssh, installer, redémarrer et
vérifier le noyau, remettre pmxcfs debout, contrôler le stockage — et s'arrête
au premier étage qui échoue en NOMMANT l'étape. Il envoie notre
install_proxmox.sh par scp plutôt que de laisser la VM cloner le dépôt : c'est
notre code qu'on éprouve, et un correctif absent du distant a fait revenir le
même défaut sur trois VM.

--- EN ---

The practicable nesting depth cannot be deduced, only measured. A manual
measurement found, at the fourth level, a guest 36 times slower than real time
— 583 seconds of wall clock for 16 seconds of guest time, each ACPI line
taking a second — then a kernel frozen at the SAME byte whatever the
resources. A number obtained once, on one machine, is not a number.

Hence three things.

The algorithm, in pure functions. Two resources run out going down: memory,
each level keeping what its own daemons need, and disk, the child's living
INSIDE the parent's. A third degrades, and it caps the vCPU at two beyond the
first level: twelve froze the guest kernel, the same two progressed. Memory is
NOT capped — the same VM froze at the same byte with 9 GB and with 2 GB, so
trimming it would gain nothing and starve the level below. The plan is
announced before anything is created, and never beyond what fits.

The guard in the screen. It read the HOST's capacity and offered all of it: on
a third level with 14 cores it proposed 12 vCPU to a VM that never booted. The
number was not absurd for the machine; it was for its depth, which the screen
did not know. It is now counted on the ProxyJump chain — one hop per level,
and we are the ones writing those entries.

The long test, in LongTest/ and not test/: the unit runner must stay runnable
in seconds, anywhere, including without virtualisation. The descent is uniform
— create, wait for ssh, install, reboot and check the kernel, bring pmxcfs
back, check the storage — and stops at the first level that fails, NAMING the
step. It sends our install_proxmox.sh over scp instead of letting the VM clone
the repository: it is our code being exercised, and a fix absent from the
remote made the same defect return on three VMs.

Assisted-by: Claude Opus 5
(cherry picked from commit 4f70c461330cac6f46783a60e0f33052a979fa23)
2026-08-29 01:53:03 -04:00

609 lines
22 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. Une mesure à la
main a montré, au quatrième étage, un invité 36 fois plus lent que le temps
réel — 583 secondes d'horloge pour 16 secondes de temps invité — puis un noyau
gelé au MÊME octet quelles que soient les ressources. Un chiffre obtenu une
fois, sur une machine, n'est pas un chiffre : ce script le refait à la demande
et dit exactement OÙ ça casse.
La descente est UNIFORME. Chaque étage, le premier compris, passe par les
mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et
vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la
création diffère — libvirt en local, « qm » ensuite.
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.
./LongTest/deep_proxmox.py --depth 10 --dry-run
./LongTest/deep_proxmox.py --depth 10
./LongTest/deep_proxmox.py --detruire # défait ce que la descente a posé
"""
import argparse
import json
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)
from script.proxmox import nesting # noqa: E402
from script.proxmox import proxmox_deploy as pve # noqa: E402
# 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"
# Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le
# journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent
# que le précédent — c'est précisément ce qu'on mesure.
DELAIS = {
"creation": 1200,
"ssh": 2400,
"install": 7200,
"reboot": 2400,
"reparation": 600,
"controle": 180,
}
def dire(msg, journal=None):
ligne = f"[{time.strftime('%H:%M:%S')}] {msg}"
print(ligne, flush=True)
if journal:
with open(journal, "a", encoding="utf-8") as fh:
fh.write(ligne + "\n")
def capacite_hote():
"""(cœurs, RAM disponible en Mo, disque libre en Go) de la machine réelle.
« available » et non « free » : c'est ce que le noyau promet de rendre sans
mettre la machine à genoux.
"""
coeurs = os.cpu_count() or 2
ram = 0
try:
with open("/proc/meminfo", encoding="utf-8") as fh:
for ligne in fh:
if ligne.startswith("MemAvailable:"):
ram = int(ligne.split()[1]) // 1024
break
except OSError:
pass
disque = 0
try:
st = os.statvfs("/var/lib/libvirt/images")
disque = (st.f_bavail * st.f_frsize) // (1024**3)
except OSError:
pass
return coeurs, ram, disque
def module_qemu():
"""deploy_qemu.py chargé comme module : il porte le catalogue d'images."""
import importlib.util
chemin = os.path.join(RACINE, "script/qemu/deploy_qemu.py")
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def cle_publique():
for nom in ("id_ed25519.pub", "id_rsa.pub"):
chemin = os.path.expanduser(f"~/.ssh/{nom}")
if os.path.exists(chemin):
return chemin
return ""
def nom_etage(niveau):
return f"{NOM_BASE}-{niveau}"
def alias_etage(niveau, parent_alias):
"""« parent+enfant », la convention du dépôt : elle dit où la machine vit
et ne peut rien voler à un homonyme."""
if not parent_alias:
return nom_etage(niveau)
court = re.sub(r"[^A-Za-z0-9._-]", "-", parent_alias)
return f"{court}+{nom_etage(niveau)}"
class Descente:
"""Un étage après l'autre, et ce qu'on en sait."""
def __init__(self, plan, journal, dry_run=False):
self.plan = plan
self.journal = journal
self.dry_run = dry_run
self.etages = []
def dire(self, msg):
dire(msg, self.journal)
# ---------------------------------------------------------------- #
# Parler aux machines
# ---------------------------------------------------------------- #
def executer(self, hote, remote, delai, etiquette, montrer=False):
if self.dry_run:
argv = pve.ssh_argv(
hote, pve.wrap_privilege(remote, hote.get("sudo") or "")
)
print(" " + " ".join(shlex.quote(a) for a in argv)[:200])
return 0, ""
debut = time.time()
code, sortie = pve.run(hote, remote, delai)
if code or montrer:
self.dire(
f" {etiquette} : code {code}"
f" en {int(time.time() - debut)} s"
)
if code:
for ligne in pve.strip_ssh_noise(sortie).strip().splitlines()[-5:]:
self.dire(f" {ligne}")
return code, sortie
def attendre_ssh(self, hote, delai):
"""Attend que la machine réponde. Rend les secondes, ou None.
Des connexions COURTES successives : cloud-init régénère les clés
d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une
session longue.
"""
if self.dry_run:
return 0
debut = time.time()
while time.time() - debut < delai:
code, _o = pve.run(hote, "true", 30)
if code == 0:
return int(time.time() - debut)
time.sleep(15)
return None
# ---------------------------------------------------------------- #
# Les six étapes, les mêmes à chaque étage
# ---------------------------------------------------------------- #
def installer_proxmox(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" sh {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
code, _o = self.executer(
dict(hote, sudo=""),
f"sh {distant}",
DELAIS["install"],
"install_proxmox.sh",
montrer=True,
)
return code == 0
def redemarrer_et_verifier(self, hote):
"""Redémarre, attend le retour, exige le noyau Proxmox.
Le script pose le noyau sans redémarrer — lancé par ssh, un reboot
couperait sa session et ferait passer l'installation pour un échec.
Sans ce redémarrage, la machine reste sur le noyau cloud de Debian,
dépouillé de tout netfilter : ni pont NAT, ni invité.
"""
if self.dry_run:
print(" reboot puis attente de *-pve dans uname -r")
return True
pve.run(hote, "systemctl reboot", 60)
debut = time.time()
while time.time() - debut < DELAIS["reboot"]:
time.sleep(20)
code, out = pve.run(dict(hote, sudo=""), "uname -r", 30)
noyau = pve.strip_ssh_noise(out).strip()
if code == 0 and "-pve" in noyau:
self.dire(
f" noyau {noyau} après {int(time.time() - debut)} s"
)
return True
self.dire(" ✗ pas revenue sur un noyau -pve")
return False
def reparer_pmxcfs(self, hote):
"""Gel de cloud-init, /etc/hosts, unités, constat du montage."""
if self.dry_run:
print(" gel cloud-init + /etc/hosts + unités + montage")
return True
_c, out = pve.run(
dict(hote, sudo=""), 'printf %s "$SSH_CONNECTION"', 30
)
ip = pve.ssh_server_ip(out)
if not ip:
self.dire(" ✗ adresse d'accès inconnue")
return False
for cmd, etiquette in (
(pve.cloud_hosts_freeze_cmd(), "gel cloud-init"),
(pve.hosts_repair_cmd(ip), "/etc/hosts"),
):
code, sortie = self.executer(
hote, cmd, DELAIS["reparation"], etiquette
)
if code or "-KO" in pve.strip_ssh_noise(sortie):
self.dire(f" ✗ {etiquette}")
return False
for unite in pve.PVE_UNITS:
self.executer(
hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite
)
_c, out = self.executer(
hote, pve.mount_wait_cmd(), DELAIS["reparation"], "montage"
)
vu = pve.parse_mount_wait(out)
self.dire(f" /etc/pve : {vu['verdict']}")
return vu["verdict"] == "MONTE"
def preparer_parent(self, parent):
"""Stockage, pont et réseau interne du parent, ou None."""
_c, out = self.executer(
parent,
"pvesm status --content images",
DELAIS["controle"],
"pvesm",
)
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
_c, out = self.executer(
parent, "ip -o link show type bridge", DELAIS["controle"], "ponts"
)
ponts = pve.parse_bridges(out)
if not ponts:
_c, nets = self.executer(
parent, pve.USED_NETS_CMD, DELAIS["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",
)
return (
stockage or "local",
ponts[0],
pve.parse_bridge_config(cfg).get(ponts[0], {}),
)
def creer_etage1(self, res):
"""Une VM locale, par la CLI QEMU/KVM."""
nom = nom_etage(1)
argv = [
os.path.join(RACINE, ".venv.erplibre/bin/python"),
os.path.join(RACINE, "script/qemu/deploy_qemu.py"),
"--distro",
DISTRO,
"--name",
nom,
"--vcpus",
str(res["vcpu"]),
"--memory",
str(res["ram"]),
"--disk-size",
f"{res['disque']}G",
]
pub = cle_publique()
if pub:
argv += ["--ssh-key", pub]
if self.dry_run:
print(" " + " ".join(shlex.quote(a) for a in argv))
return nom
res_proc = subprocess.run(argv, timeout=DELAIS["creation"] * 3)
if res_proc.returncode:
self.dire(" ✗ la CLI QEMU/KVM a échoué")
return None
return nom
def creer_enfant(self, parent, niveau, res, prepare):
"""« qm create » sur le parent. Rend (vmid, adresse) ou (None, None)."""
stockage, pont, info_pont = 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", DELAIS["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)
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",
"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é",
)
for cmd in [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
vmid, spec
):
code, _o = self.executer(
parent, cmd, DELAIS["creation"], "qm create"
)
if code and not self.dry_run:
return None, None
return vmid, adresse
# ---------------------------------------------------------------- #
# La descente
# ---------------------------------------------------------------- #
def parcourir(self):
parent = None
parent_alias = ""
for res in self.plan["niveaux"]:
niveau = res["niveau"]
debut = time.time()
etage = {
"niveau": niveau,
"ressources": res,
"etape": "creation",
"ok": False,
}
self.dire(
f" ── étage {niveau} : {res['vcpu']} vCPU,"
f" {res['ram']} Mo, {res['disque']} Go"
)
if niveau == 1:
nom = self.creer_etage1(res)
if not nom:
self.etages.append(etage)
break
alias = nom
else:
prepare = self.preparer_parent(parent)
if not prepare:
etage["etape"] = "parent"
self.etages.append(etage)
break
vmid, adresse = self.creer_enfant(parent, niveau, res, prepare)
if vmid is None:
self.etages.append(etage)
break
etage["vmid"] = vmid
alias = alias_etage(niveau, parent_alias)
if not self.dry_run:
if not adresse:
# Sur un pont interne l'adresse est FIXE et dérivée du
# VMID. Vide, c'est que le parent n'a pas de pont
# interne — écrire un alias sans HostName donnerait
# une entrée qui ne mène nulle part.
self.dire(" ✗ pas d'adresse fixe pour l'enfant")
etage["etape"] = "adresse"
self.etages.append(etage)
break
self.ecrire_alias(alias, adresse, parent_alias)
cible = {"target": alias, "sudo": "sudo ", "jump": ""}
etage["alias"] = alias
etage["etape"] = "ssh"
attente = self.attendre_ssh(cible, DELAIS["ssh"])
if attente is None:
self.dire(" ✗ jamais joignable en ssh")
self.etages.append(etage)
break
etage["ssh_secondes"] = attente
self.dire(f" ssh après {attente} s")
for etape, action in (
("install", lambda: self.installer_proxmox(cible)),
("reboot", lambda: self.redemarrer_et_verifier(cible)),
("pmxcfs", lambda: self.reparer_pmxcfs(cible)),
):
etage["etape"] = etape
if not action():
self.etages.append(etage)
return self.rapport(interrompu=True)
etage["etape"] = "termine"
etage["ok"] = True
etage["secondes"] = int(time.time() - debut)
self.etages.append(etage)
self.dire(f" ✓ étage {niveau} en {etage['secondes']} s")
parent, parent_alias = cible, alias
return self.rapport()
def ecrire_alias(self, alias, adresse, parent_alias):
"""Une entrée ~/.ssh/config pour joindre l'enfant à travers le parent."""
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
prive = cle_publique()[:-4] if cle_publique() else None
todo._write_ssh_config_entry(
[alias],
"erplibre",
adresse,
proxy_jump=parent_alias or None,
identity_file=prive,
)
def rapport(self, interrompu=False):
atteint = sum(1 for e in self.etages if e["ok"])
print("")
self.dire(
f" profondeur atteinte : {atteint} / {self.plan['demandee']}"
)
for e in self.etages:
marque = "✓" if e["ok"] else "✗"
detail = f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"]
self.dire(f" {marque} étage {e['niveau']:2d} {detail}")
return {
"demandee": self.plan["demandee"],
"atteignable": self.plan["atteignable"],
"atteinte": atteint,
"interrompu": interrompu,
"etages": self.etages,
}
def detruire(journal=None):
"""Défait ce que la descente a posé, du plus profond au plus haut.
Du plus profond : détruire un parent d'abord emporterait ses enfants sans
qu'on ait pu les nommer, et laisserait des entrées ssh vers rien.
"""
from script.todo.todo import TODO
hosts = [h for h in TODO._ssh_config_hosts() if NOM_BASE in h]
hosts.sort(key=lambda h: -h.count("+"))
dire(f" {len(hosts)} entrée(s) ssh à défaire", journal)
for alias in hosts:
bloc = TODO._ssh_config_block(alias)
saut = (bloc or {}).get("proxyjump")
if saut:
parent = {"target": saut, "sudo": "sudo ", "jump": ""}
_c, out = pve.run(parent, "qm list", 120)
for vm in pve.parse_qm_list(out):
if NOM_BASE in (vm.get("name") or ""):
dire(f" qm destroy {vm['vmid']} sur {saut}", journal)
pve.run(
parent,
f"qm stop {vm['vmid']} --skiplock 1 || true;"
f" qm destroy {vm['vmid']} --purge 1",
300,
)
for niveau in range(1, 30):
nom = nom_etage(niveau)
if nom in hosts or niveau == 1:
subprocess.run(
["sudo", "virsh", "destroy", nom],
capture_output=True,
)
subprocess.run(
[
"sudo",
"virsh",
"undefine",
nom,
"--nvram",
"--remove-all-storage",
],
capture_output=True,
)
dire(
" ✓ défait. Les entrées ssh orphelines : menu de nettoyage.", journal
)
def principal(argv=None):
parseur = argparse.ArgumentParser(
description="Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?"
)
parseur.add_argument("--depth", type=int, default=10)
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:
detruire(journal)
return 0
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"]:
print(
f"\n ⚠ demandée {plan['demandee']}, atteignable"
f" {plan['atteignable']} — manque de {plan['arret']}"
)
if not plan["niveaux"]:
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")
descente = Descente(plan, journal, args.dry_run)
rapport = descente.parcourir()
chemin = journal[:-4] + ".json"
with open(chemin, "w", encoding="utf-8") as fh:
json.dump(rapport, fh, indent=2)
print(f"\n rapport : {chemin}\n")
return 0 if rapport["atteinte"] else 1
if __name__ == "__main__":
sys.exit(principal())