[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)
This commit is contained in:
Mathieu Benoit 2026-08-26 06:20:52 -04:00
parent 4bc2fa6097
commit 7199a7cbb2
11 changed files with 1555 additions and 0 deletions

125
LongTest/README.base.md Normal file
View file

@ -0,0 +1,125 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# LongTest — tests that create real machines
These are not unit tests. They create virtual machines, install systems on
them, and take hours. They live here and **not** in `test/`, which the unit
runner sweeps: `./script/test/run_unit_test.sh` must stay runnable in seconds
on any machine, including one without virtualisation.
Run them from the menu — `TODO › Execute › Test › Long tests` — or directly.
## deep_proxmox.py — how deep does Proxmox-in-Proxmox go?
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 guest kernel frozen at the **same byte**
whatever the resources. A number obtained once, on one machine, is not a
number: this script redoes it on demand and says exactly where it breaks.
```
./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created
./LongTest/deep_proxmox.py --depth 10 # hours
./LongTest/deep_proxmox.py --detruire # undo it
```
The descent is **uniform**. Every level, the first included, goes through the
same six steps: create, wait for ssh, install Proxmox, reboot and check the
kernel, bring pmxcfs back up, check the storage. Only creation differs —
libvirt locally, `qm` afterwards.
It sends **our** `install_proxmox.sh` over scp instead of letting the VM clone
the repository: it is our code we want to exercise, and the remote is often
behind the checkout — a fix absent from the remote made the same defect "come
back" on three VMs in a row.
### The resource algorithm
Two things run out going down, and a third degrades. What runs out is
arithmetic, and `script/proxmox/nesting.py` computes it:
* **memory** — each level keeps what its own daemons need (`pve-cluster`,
`pvestatd`, `pvedaemon`, `pveproxy`) before handing the rest down;
* **disk** — the child's disk lives *inside* the parent's, which must also
hold its own system.
What degrades is measured, not assumed: past the second level, vendors
document nothing. Hence one capped number — **2 vCPU** for every nested
level. Twelve vCPU at the fourth level froze the guest kernel in early boot;
the same two progressed. Bringing twelve processors online costs as many
round trips through the whole stack.
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 printed **before** anything is created, and the script never
promises a depth it knows will not fit — better to announce six levels and
reach six than to promise ten and die at the seventh without knowing why.
<!-- [fr] -->
# LongTest — des tests qui créent de vraies machines
Ce ne sont pas des tests unitaires. Ils créent des machines virtuelles, y
installent des systèmes, et durent des heures. Ils vivent ici et **non** dans
`test/`, que le lanceur unitaire balaie : `./script/test/run_unit_test.sh`
doit rester lançable en quelques secondes, sur n'importe quelle machine, y
compris sans virtualisation.
Ils se lancent depuis le menu — `TODO › Execute › Test › Tests longs` — ou
directement.
## deep_proxmox.py — jusqu'à quel étage un Proxmox dans un Proxmox 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 invité 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.
```
./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé
./LongTest/deep_proxmox.py --depth 10 # des heures
./LongTest/deep_proxmox.py --detruire # défaire
```
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.
### L'algorithme de ressources
Deux choses s'épuisent en descendant, et une troisième se dégrade. Ce qui
s'épuise est de l'arithmétique, et `script/proxmox/nesting.py` la calcule :
* **la mémoire** — chaque étage garde de quoi faire tourner ses propres démons
(`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) avant de céder le
reste ;
* **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit
aussi contenir son propre système.
Ce qui se dégrade est mesuré, pas supposé : au-delà du deuxième étage, les
fabricants ne documentent rien. D'où un seul nombre borné — **2 vCPU** pour
tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invité en
tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs
en ligne coûte autant d'allers-retours à travers toute la pile.
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 affiché **avant** que quoi que ce soit ne soit créé, et le script
ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer
six étages et en réussir six que d'en promettre dix et mourir au septième sans
savoir pourquoi.

62
LongTest/README.fr.md Normal file
View file

@ -0,0 +1,62 @@
# LongTest — des tests qui créent de vraies machines
Ce ne sont pas des tests unitaires. Ils créent des machines virtuelles, y
installent des systèmes, et durent des heures. Ils vivent ici et **non** dans
`test/`, que le lanceur unitaire balaie : `./script/test/run_unit_test.sh`
doit rester lançable en quelques secondes, sur n'importe quelle machine, y
compris sans virtualisation.
Ils se lancent depuis le menu — `TODO › Execute › Test › Tests longs` — ou
directement.
## deep_proxmox.py — jusqu'à quel étage un Proxmox dans un Proxmox 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 invité 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.
```
./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé
./LongTest/deep_proxmox.py --depth 10 # des heures
./LongTest/deep_proxmox.py --detruire # défaire
```
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.
### L'algorithme de ressources
Deux choses s'épuisent en descendant, et une troisième se dégrade. Ce qui
s'épuise est de l'arithmétique, et `script/proxmox/nesting.py` la calcule :
* **la mémoire** — chaque étage garde de quoi faire tourner ses propres démons
(`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) avant de céder le
reste ;
* **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit
aussi contenir son propre système.
Ce qui se dégrade est mesuré, pas supposé : au-delà du deuxième étage, les
fabricants ne documentent rien. D'où un seul nombre borné — **2 vCPU** pour
tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invité en
tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs
en ligne coûte autant d'allers-retours à travers toute la pile.
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 affiché **avant** que quoi que ce soit ne soit créé, et le script
ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer
six étages et en réussir six que d'en promettre dix et mourir au septième sans
savoir pourquoi.

57
LongTest/README.md Normal file
View file

@ -0,0 +1,57 @@
# LongTest — tests that create real machines
These are not unit tests. They create virtual machines, install systems on
them, and take hours. They live here and **not** in `test/`, which the unit
runner sweeps: `./script/test/run_unit_test.sh` must stay runnable in seconds
on any machine, including one without virtualisation.
Run them from the menu — `TODO › Execute › Test › Long tests` — or directly.
## deep_proxmox.py — how deep does Proxmox-in-Proxmox go?
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 guest kernel frozen at the **same byte**
whatever the resources. A number obtained once, on one machine, is not a
number: this script redoes it on demand and says exactly where it breaks.
```
./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created
./LongTest/deep_proxmox.py --depth 10 # hours
./LongTest/deep_proxmox.py --detruire # undo it
```
The descent is **uniform**. Every level, the first included, goes through the
same six steps: create, wait for ssh, install Proxmox, reboot and check the
kernel, bring pmxcfs back up, check the storage. Only creation differs —
libvirt locally, `qm` afterwards.
It sends **our** `install_proxmox.sh` over scp instead of letting the VM clone
the repository: it is our code we want to exercise, and the remote is often
behind the checkout — a fix absent from the remote made the same defect "come
back" on three VMs in a row.
### The resource algorithm
Two things run out going down, and a third degrades. What runs out is
arithmetic, and `script/proxmox/nesting.py` computes it:
* **memory** — each level keeps what its own daemons need (`pve-cluster`,
`pvestatd`, `pvedaemon`, `pveproxy`) before handing the rest down;
* **disk** — the child's disk lives *inside* the parent's, which must also
hold its own system.
What degrades is measured, not assumed: past the second level, vendors
document nothing. Hence one capped number — **2 vCPU** for every nested
level. Twelve vCPU at the fourth level froze the guest kernel in early boot;
the same two progressed. Bringing twelve processors online costs as many
round trips through the whole stack.
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 printed **before** anything is created, and the script never
promises a depth it knows will not fit — better to announce six levels and
reach six than to promise ten and die at the seventh without knowing why.

609
LongTest/deep_proxmox.py Executable file
View file

@ -0,0 +1,609 @@
#!/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())

148
script/proxmox/nesting.py Normal file
View file

@ -0,0 +1,148 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Combien d'étages de Proxmox tiennent, et avec quelles ressources.
Un Proxmox dans un Proxmox dans un Proxmox : chaque étage est un hyperviseur
qui héberge le suivant. Deux choses s'épuisent en descendant, et une troisième
se dégrade.
Ce qui s'ÉPUISE — et c'est de l'arithmétique :
* la mémoire. Chaque étage garde de quoi faire tourner ses propres démons
(pve-cluster, pvestatd, pvedaemon, pveproxy) avant de céder le reste ;
* le disque. Le disque de l'enfant vit DANS celui du parent, qui doit aussi
contenir son propre système.
Ce qui se DÉGRADE — et c'est mesuré, pas supposé. Au quatrième étage, sur un
hôte AMD, une VM tournait 36 fois moins vite que le temps réel : 583 secondes
d'horloge pour 16 secondes de temps invité, chaque ligne d'ACPI prenant une
seconde. Chaque sortie de VM traverse tous les hyperviseurs empilés, et AMD ne
documente l'imbrication qu'à DEUX niveaux.
Deux nombres viennent de la même mesure, et méritent d'être dits :
* 12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début de
démarrage — même RIP à trois relevés, deux minutes d'écart, pas un octet lu
de plus. Les mêmes 2 vCPU avançaient. D'où VCPU_IMBRIQUE = 2 : amener douze
processeurs en ligne demande autant d'allers-retours à travers la pile ;
* la même VM s'arrêtait ensuite au MÊME octet — 33 682 432 — quelles que
soient les ressources, dans la réservation des tables ACPI. Ce mur-là n'est
pas une question de taille, et aucun réglage ici ne le déplacera. La
profondeur RÉELLEMENT atteignable se mesure ; ce module ne calcule que ce
qui est arithmétiquement possible.
"""
# Ce qu'on laisse à la machine physique : elle fait tourner l'orchestrateur,
# le menu TODO, et le premier QEMU.
HOTE_RESERVE_RAM_MO = 4096
HOTE_RESERVE_DISQUE_GO = 20
# Ce qu'un étage garde pour lui avant de céder le reste. La RAM vient de
# l'observation d'un Proxmox imbriqué au repos ; le disque, de la mesure d'un
# système installé (5,6 Go) plus de la place pour écrire.
PVE_RAM_MO = 2048
PVE_DISQUE_GO = 10
# En dessous, un Proxmox ne démarre pas ses démons ou n'a plus la place
# d'importer une image cloud.
RAM_MIN_MO = 2048
DISQUE_MIN_GO = 15
# Le premier étage tourne sur la machine physique : il peut être large. Les
# suivants non — voir la mesure dans l'en-tête.
VCPU_NIVEAU1_MAX = 4
VCPU_IMBRIQUE = 2
# Au-delà, l'imbrication n'est pas un terrain documenté par les fabricants.
# On ne refuse pas — on le DIT.
PROFONDEUR_SURE = 2
def nesting_plan(
profondeur: int,
cpu_hote: int,
ram_dispo_mo: int,
disque_libre_go: int,
) -> dict:
"""Les ressources de chaque étage, et jusqu'où l'arithmétique va.
Rend {"demandee", "atteignable", "niveaux": [...], "arret"}. `arret`
nomme ce qui a manqué — « ram » ou « disque » — quand la profondeur
demandée n'est pas atteinte, sinon "".
On ne rend jamais un plan qu'on sait impossible : mieux vaut annoncer six
étages et en réussir six que d'en promettre dix et mourir au septième
sans savoir pourquoi.
"""
# Arrondi au gibioctet inférieur : « --memory 25203 » marche, mais un
# nombre rond se relit, se compare d'un étage à l'autre, et évite de
# traîner les kibioctets du hasard de la mesure jusqu'au dixième étage.
ram = ((int(ram_dispo_mo) - HOTE_RESERVE_RAM_MO) // 1024) * 1024
disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO
niveaux, arret = [], ""
for niveau in range(1, max(1, int(profondeur)) + 1):
if niveau > 1:
ram -= PVE_RAM_MO
disque -= PVE_DISQUE_GO
if ram < RAM_MIN_MO:
arret = "ram"
break
if disque < DISQUE_MIN_GO:
arret = "disque"
break
niveaux.append(
{
"niveau": niveau,
"vcpu": (
max(1, min(VCPU_NIVEAU1_MAX, int(cpu_hote) // 4))
if niveau == 1
else VCPU_IMBRIQUE
),
"ram": ram,
"disque": disque,
}
)
return {
"demandee": int(profondeur),
"atteignable": len(niveaux),
"niveaux": niveaux,
"arret": arret,
}
def depth_from_jumps(jumps: int) -> int:
"""Profondeur d'un hôte, comptée depuis sa chaîne de rebonds.
Un hôte joint sans rebond est au niveau 1 ; chaque ProxyJump ajoute un
étage. C'est la seule mesure dont on dispose de l'extérieur, et elle est
exacte pour les hôtes que nous avons nous-mêmes déployés — c'est nous qui
écrivons ces entrées.
"""
return max(1, int(jumps) + 1)
def capped_for_depth(profondeur: int, vcpu: int, ram_mo: int) -> tuple:
"""Ressources bornées pour cette profondeur, et pourquoi.
Rend (vcpu, ram, raison). `raison` vide quand rien n'a été touché.
Seul le vCPU est borné, et la mesure le dit : la même VM au quatrième
étage gelait au MÊME octet avec 9 Go et avec 2 Go — la mémoire n'est pas
le levier. Douze vCPU, en revanche, gelaient plus tôt et plus dur que
deux. La RAM passe donc telle quelle : la rogner ne gagnerait rien et
priverait l'étage suivant.
Pourquoi borner au lieu d'avertir seulement : l'écran lit la capacité de
l'HÔTE et l'offre en entier. Sur un troisième étage à 14 cœurs et 9 Go, il
a proposé 12 vCPU — et la VM n'a jamais démarré. Le nombre n'était pas
absurde pour la machine ; il l'était pour sa profondeur.
"""
vcpu, ram_mo = int(vcpu), int(ram_mo)
if profondeur <= PROFONDEUR_SURE or vcpu <= VCPU_IMBRIQUE:
return vcpu, ram_mo, ""
return (
VCPU_IMBRIQUE,
ram_mo,
f"niveau {int(profondeur)} : {vcpu} vCPU -> {VCPU_IMBRIQUE}",
)

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les tests LONGS : de vraies machines, des heures.
Ils vivent dans `LongTest/` et non dans `test/`, et ce n'est pas un rangement
de confort : le lanceur unitaire balaie `test/test_*.py` et doit rester
lançable en quelques secondes, partout. Un test qui crée dix VM n'a rien à y
faire — il le ferait échouer sur toute machine sans virtualisation, et
personne ne l'attendrait.
Ce menu ne fait que les lancer, en montrant leur sortie en direct : ces
scripts durent des heures, et une sortie capturée jusqu'à la fin ne dirait
rien pendant tout ce temps.
"""
import os
import click
from script.todo.todo_i18n import t
# Le répertoire des tests longs, à la racine du dépôt.
LONGTEST_DIR = "LongTest"
class LongTestMenuMixin:
def _longtest_script(self, nom):
"""Chemin d'un test long, ou "" s'il n'est pas là."""
chemin = os.path.join(os.getcwd(), LONGTEST_DIR, nom)
return chemin if os.path.exists(chemin) else ""
def _longtest_run(self, nom, args=""):
"""Lance un test long, sortie en DIRECT.
En direct parce qu'il dure des heures : capturer sa sortie pour
l'afficher à la fin, c'est ne rien montrer pendant tout ce temps —
et c'est justement la progression étage par étage qui intéresse.
"""
chemin = self._longtest_script(nom)
if not chemin:
print(f" ✗ {t('Script not found:')} {LONGTEST_DIR}/{nom}")
return
cmd = f"./.venv.erplibre/bin/python {chemin}"
if args:
cmd += f" {args}"
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def prompt_execute_longtest(self):
print(f"⏳ {t('Long tests: real VMs, hours. Not the unit suite.')}")
choices = [
{
"prompt_description": t(
"Nested Proxmox depth: plan only (dry-run)"
)
},
{"prompt_description": t("Nested Proxmox depth: run it")},
{"prompt_description": t("Undo what the descent created")},
]
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
if status == "1":
self._longtest_run(
"deep_proxmox.py",
f"--depth {self._longtest_depth()} --dry-run",
)
elif status == "2":
# La profondeur est DEMANDÉE : c'est le seul réglage du test,
# et il décide de sa durée — dix étages, c'est une nuit.
self._longtest_run(
"deep_proxmox.py", f"--depth {self._longtest_depth()}"
)
elif status == "3":
self._longtest_run("deep_proxmox.py", "--detruire")
else:
print(t("Command not found !"))
def _longtest_depth(self):
"""Profondeur demandée. Dix par défaut : c'est ce qu'on veut mesurer."""
brut = input(f"{t('Depth (default 10): ')}").strip()
return int(brut) if brut.isdigit() and int(brut) > 0 else 10

View file

@ -720,6 +720,46 @@ class ProxmoxMenuMixin:
lignes.append(f"→ {t('This screen can repair it (see below).')}")
return lignes
def _pve_depth(self, host):
"""À quel étage d'imbrication se trouve cet hôte. 1 = machine réelle.
Comptée sur la chaîne de ProxyJump : un rebond par étage. C'est nous
qui écrivons ces entrées, donc la mesure est exacte pour notre parc.
"""
from script.proxmox import nesting
return nesting.depth_from_jumps(
self._ssh_jump_depth(host.get("target") or "")
)
def _pve_depth_note(self, host, cpu):
"""(cpu borné, lignes à dire). Ce que la profondeur impose.
L'écran 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 — et la VM n'a jamais
démarré : même RIP à trois relevés deux minutes d'écart, pas un octet
lu de plus. Le nombre n'était pas absurde pour la machine ; il l'était
pour sa profondeur.
Un seul levier, le vCPU : la même VM gelait au MÊME octet avec 9 Go et
avec 2 Go, donc rogner la mémoire ne gagnerait rien et priverait
l'étage suivant.
"""
from script.proxmox import nesting
profondeur = self._pve_depth(host)
borne, _ram, raison = nesting.capped_for_depth(profondeur, cpu, 0)
if profondeur <= nesting.PROFONDEUR_SURE:
return cpu, []
lignes = [
f"⚠ {t('Nesting level')} {profondeur} —"
f" {t('vendors document two, not more.')}",
f" {t('Measured at level 4: 36x slower, then a frozen kernel.')}",
]
if raison:
lignes.append(f" {t('vCPU capped to')} {borne}")
return borne, lignes
def _pve_ssh_ip(self, host):
"""Adresse par laquelle NOTRE ssh atteint l'hôte, ou "".
@ -1088,6 +1128,12 @@ class ProxmoxMenuMixin:
_c, cfg = self._pve_show("cat /etc/network/interfaces", quiet=True)
infos = pve.parse_bridge_config(cfg)
cpu, ram_libre = self._pve_capacity()
# La profondeur borne ce que l'écran offre. Ici, terminal encore à
# nous : une fois Textual à l'affiche, ces lignes n'auraient nulle
# part où aller.
cpu, notes_profondeur = self._pve_depth_note(host, cpu)
for ligne in notes_profondeur:
print(f" {ligne}")
# Le DNS de l'hôte, pour les VM en adresse fixe : sans lui elles
# routent mais ne résolvent rien, et « apt update » échoue sans que
# rien ne l'explique. Mesuré sur la VM d'essai.

View file

@ -28,6 +28,7 @@ from script.config import config_file
from script.execute import execute
from script.todo import todo_prefs
from script.todo.database_manager import DatabaseManager
from script.todo.longtest_menu import LongTestMenuMixin
from script.todo.proxmox_menu import ProxmoxMenuMixin
from script.todo.qemu_access import QemuAccessMixin
from script.todo.qemu_deploy import QemuDeployMixin
@ -96,6 +97,7 @@ class TODO(
QemuManageMixin,
QemuAccessMixin,
ProxmoxMenuMixin,
LongTestMenuMixin,
):
def __init__(self):
self.dir_path = None
@ -1752,6 +1754,30 @@ class TODO(
bloc[mots[0].lower()] = mots[1]
return bloc or {}
@classmethod
def _ssh_jump_depth(cls, cible, maxi=12):
"""Nombre de rebonds pour joindre `cible`, en suivant la chaîne.
C'est la mesure de PROFONDEUR d'un hôte imbriqué, et la seule dont on
dispose de l'extérieur. Elle est exacte pour les hôtes que nous avons
déployés : c'est nous qui écrivons ces entrées, un ProxyJump par
étage.
`maxi` borne le parcours : une boucle dans ~/.ssh/config — A qui
rebondit par B qui rebondit par A — tournerait sinon sans fin.
"""
vus, sauts = set(), 0
courant = cible
while sauts < maxi:
bloc = cls._ssh_config_block(courant)
saut = (bloc or {}).get("proxyjump")
if not saut or saut in vus:
break
vus.add(saut)
courant = saut
sauts += 1
return sauts
@staticmethod
def _ssh_config_user(host):
"""`User` déclaré pour cet hôte dans ~/.ssh/config, ou "".
@ -4134,6 +4160,9 @@ class TODO(
{"prompt_description": t("ERPLibre unit tests")},
{"prompt_description": t("Mail unit tests")},
{"prompt_description": t("Analyse unit tests")},
# Hors de la suite unitaire, et le libellé le dit : ceux-là créent
# de vraies machines et durent des heures.
{"prompt_description": t("Long tests - real VMs, hours")},
]
help_info = self.fill_help_info(choices)
@ -4152,6 +4181,8 @@ class TODO(
self.execute_unit_tests("test_mail*.py")
elif status == "5":
self.execute_unit_tests("test_analyse*.py")
elif status == "6":
self.prompt_execute_longtest()
else:
print(t("Command not found !"))

View file

@ -3422,6 +3422,50 @@ TRANSLATIONS = {
"fr": "L'adresse écrite n'est peut-être pas celle qu'il faut à pmxcfs.",
"en": "The address written may not be the one pmxcfs needs.",
},
"Long tests - real VMs, hours": {
"fr": "⏳ Tests longs - vraies VM, des heures",
"en": "⏳ Long tests - real VMs, hours",
},
"Long tests: real VMs, hours. Not the unit suite.": {
"fr": "Tests longs : de vraies VM, des heures. Pas la suite unitaire.",
"en": "Long tests: real VMs, hours. Not the unit suite.",
},
"Nested Proxmox depth: plan only (dry-run)": {
"fr": "Profondeur Proxmox imbriqué : le plan seulement (à blanc)",
"en": "Nested Proxmox depth: plan only (dry-run)",
},
"Nested Proxmox depth: run it": {
"fr": "Profondeur Proxmox imbriqué : le lancer",
"en": "Nested Proxmox depth: run it",
},
"Undo what the descent created": {
"fr": "Défaire ce que la descente a créé",
"en": "Undo what the descent created",
},
"Script not found:": {
"fr": "Script introuvable :",
"en": "Script not found:",
},
"Depth (default 10): ": {
"fr": "Profondeur (défaut 10) : ",
"en": "Depth (default 10): ",
},
"Nesting level": {
"fr": "Étage d'imbrication",
"en": "Nesting level",
},
"vendors document two, not more.": {
"fr": "les fabricants en documentent deux, pas plus.",
"en": "vendors document two, not more.",
},
"Measured at level 4: 36x slower, then a frozen kernel.": {
"fr": "Mesuré au niveau 4 : 36x plus lent, puis noyau gelé.",
"en": "Measured at level 4: 36x slower, then a frozen kernel.",
},
"vCPU capped to": {
"fr": "vCPU borné à",
"en": "vCPU capped to",
},
"pve-cluster is down: /etc/pve is not mounted.": {
"fr": "pve-cluster est à terre : /etc/pve n'est pas monté.",
"en": "pve-cluster is down: /etc/pve is not mounted.",

View file

@ -0,0 +1,205 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Combien d'étages de Proxmox tiennent, et avec quelles ressources.
L'écran de déploiement lisait la capacité de l'HÔTE et l'offrait en entier.
Sur un troisième étage à 14 cœurs et 9 Go de libre, il a proposé 12 vCPU et
9 Go à une VM qui n'a jamais démarré : même RIP à trois relevés deux minutes
d'écart, pas un octet lu de plus. Le nombre n'était pas absurde pour la
machine ; il l'était pour sa profondeur.
"""
import sys
import unittest
sys.argv = ["todo.py"]
from script.proxmox import nesting # noqa: E402
class TestLePlanDesEtages(unittest.TestCase):
"""Deux ressources s'épuisent en descendant, et le plan doit le dire
AVANT de créer quoi que ce soit."""
# La machine réelle sur laquelle l'algorithme a été réglé.
HOTE = dict(cpu_hote=28, ram_dispo_mo=29549, disque_libre_go=139)
def test_ten_levels_fit_on_this_machine(self):
plan = nesting.nesting_plan(10, **self.HOTE)
self.assertEqual(plan["atteignable"], 10)
self.assertEqual(plan["arret"], "")
def test_every_level_shrinks(self):
# Le disque de l'enfant vit DANS celui du parent, qui doit aussi
# contenir son propre système : rien ne peut rester constant.
niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"]
for precedent, suivant in zip(niveaux, niveaux[1:]):
self.assertLess(suivant["ram"], precedent["ram"])
self.assertLess(suivant["disque"], precedent["disque"])
def test_the_first_level_may_be_wide_the_others_not(self):
"""12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début
de démarrage ; les mêmes 2 vCPU avançaient. Amener douze processeurs
en ligne demande autant d'allers-retours à travers la pile."""
niveaux = nesting.nesting_plan(4, **self.HOTE)["niveaux"]
self.assertGreater(niveaux[0]["vcpu"], nesting.VCPU_IMBRIQUE - 1)
for n in niveaux[1:]:
self.assertEqual(n["vcpu"], nesting.VCPU_IMBRIQUE)
def test_running_out_of_ram_is_named(self):
plan = nesting.nesting_plan(
10, cpu_hote=8, ram_dispo_mo=12288, disque_libre_go=500
)
self.assertEqual(plan["arret"], "ram")
self.assertLess(plan["atteignable"], 10)
# Aucun étage sous le plancher : un Proxmox sous 2 Go ne démarre pas
# ses démons.
for n in plan["niveaux"]:
self.assertGreaterEqual(n["ram"], nesting.RAM_MIN_MO)
def test_running_out_of_disk_is_named(self):
plan = nesting.nesting_plan(
10, cpu_hote=8, ram_dispo_mo=200000, disque_libre_go=60
)
self.assertEqual(plan["arret"], "disque")
for n in plan["niveaux"]:
self.assertGreaterEqual(n["disque"], nesting.DISQUE_MIN_GO)
def test_a_machine_too_small_for_even_one_level(self):
plan = nesting.nesting_plan(
3, cpu_hote=2, ram_dispo_mo=4096, disque_libre_go=200
)
self.assertEqual(plan["atteignable"], 0)
self.assertEqual(plan["niveaux"], [])
self.assertEqual(plan["arret"], "ram")
def test_a_plan_is_never_promised_beyond_what_fits(self):
# Mieux vaut annoncer six étages et en réussir six que d'en promettre
# dix et mourir au septième sans savoir pourquoi.
for profondeur in range(1, 13):
plan = nesting.nesting_plan(profondeur, **self.HOTE)
self.assertEqual(len(plan["niveaux"]), plan["atteignable"])
self.assertLessEqual(plan["atteignable"], profondeur)
class TestLaProfondeurDUnHote(unittest.TestCase):
"""Comptée depuis la chaîne de rebonds : c'est la seule mesure dont on
dispose de l'extérieur, et elle est exacte pour les hôtes que nous avons
nous-mêmes déployés — c'est nous qui écrivons ces entrées."""
def test_no_jump_is_the_first_level(self):
self.assertEqual(nesting.depth_from_jumps(0), 1)
def test_each_jump_adds_a_level(self):
for sauts, attendu in ((1, 2), (2, 3), (3, 4), (9, 10)):
self.assertEqual(nesting.depth_from_jumps(sauts), attendu)
class TestCompterLesRebonds(unittest.TestCase):
"""La profondeur se lit dans ~/.ssh/config : un ProxyJump par étage.
Hermétique — une configuration synthétique. La vraie a été nettoyée entre
deux mesures, et un test qui dépend de la machine qui le lance ne prouve
rien le lendemain."""
CONFIG = """
Host niveau1
HostName 192.168.1.10
Host niveau2
HostName 10.10.10.150
ProxyJump niveau1
Host niveau3
HostName 10.10.20.150
ProxyJump niveau2
Host niveau4
HostName 10.10.10.150
ProxyJump niveau3
Host boucle-a
ProxyJump boucle-b
Host boucle-b
ProxyJump boucle-a
"""
def setUp(self):
import os
import tempfile
sys.argv = ["todo.py"]
from script.todo.todo import TODO
self.maison = tempfile.mkdtemp()
os.makedirs(os.path.join(self.maison, ".ssh"))
with open(
os.path.join(self.maison, ".ssh/config"), "w", encoding="utf-8"
) as fh:
fh.write(self.CONFIG)
self._vrai = os.environ.get("HOME")
os.environ["HOME"] = self.maison
self.TODO = TODO
def tearDown(self):
import os
import shutil
if self._vrai is not None:
os.environ["HOME"] = self._vrai
shutil.rmtree(self.maison, ignore_errors=True)
def test_each_level_is_counted(self):
for nom, attendu in (
("niveau1", 1),
("niveau2", 2),
("niveau3", 3),
("niveau4", 4),
):
with self.subTest(hote=nom):
sauts = self.TODO._ssh_jump_depth(nom)
self.assertEqual(nesting.depth_from_jumps(sauts), attendu)
def test_an_unknown_host_is_the_first_level(self):
self.assertEqual(self.TODO._ssh_jump_depth("jamais-vu"), 0)
def test_a_loop_does_not_spin_forever(self):
# A rebondit par B qui rebondit par A : sans garde, le parcours ne
# s'arrête jamais.
self.assertLessEqual(self.TODO._ssh_jump_depth("boucle-a"), 2)
class TestBornerCeQueLEcranOffre(unittest.TestCase):
def test_the_first_two_levels_are_left_alone(self):
# L'imbrication à deux niveaux est documentée par les fabricants : on
# n'a rien à corriger là.
for profondeur in (1, 2):
self.assertEqual(
nesting.capped_for_depth(profondeur, 12, 9216),
(12, 9216, ""),
)
def test_beyond_that_the_vcpu_is_capped_and_said(self):
vcpu, ram, raison = nesting.capped_for_depth(3, 12, 9216)
self.assertEqual(vcpu, nesting.VCPU_IMBRIQUE)
self.assertTrue(raison)
self.assertIn("12", raison)
def test_the_ram_is_never_touched(self):
"""La même VM gelait au MÊME octet avec 9 Go et avec 2 Go : la
mémoire n'est pas le levier. La rogner ne gagnerait rien et priverait
l'étage suivant."""
for profondeur in (1, 3, 8):
_v, ram, _r = nesting.capped_for_depth(profondeur, 12, 9216)
self.assertEqual(ram, 9216)
def test_a_modest_request_is_not_reported_as_capped(self):
# Rien n'a bougé : ne rien dire. Un avertissement à chaque
# déploiement finit par ne plus être lu.
self.assertEqual(nesting.capped_for_depth(5, 2, 4096), (2, 4096, ""))
self.assertEqual(nesting.capped_for_depth(5, 1, 4096), (1, 4096, ""))
if __name__ == "__main__":
unittest.main(verbosity=2)

142
test/test_todo_longtest.py Normal file
View file

@ -0,0 +1,142 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les tests LONGS : qu'ils existent, qu'ils annoncent, et qu'ils ne
polluent pas la suite unitaire.
Un test qui crée dix VM n'a rien à faire dans `test/` : le lanceur unitaire
doit rester lançable en quelques secondes, partout, y compris sur une machine
sans virtualisation. Ce fichier-ci vérifie la frontière, et que l'essai à
blanc du test long dit quelque chose sans rien créer.
"""
import os
import subprocess
import sys
import unittest
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PYTHON = os.path.join(RACINE, ".venv.erplibre/bin/python")
class TestLaFrontiere(unittest.TestCase):
"""LongTest est hors de portée du lanceur unitaire, et ce n'est pas un
rangement de confort."""
def test_the_unit_runner_does_not_sweep_LongTest(self):
with open(
os.path.join(RACINE, "script/test/run_unit_test.sh"),
encoding="utf-8",
) as fh:
lanceur = fh.read()
# Le lanceur ne liste que des fichiers de test/ : rien qui parte de
# LongTest, sinon la suite unitaire créerait des VM.
self.assertNotIn("LongTest", lanceur)
def test_the_naming_rule_is_written_where_it_is_read(self):
# Un fichier hors préfixe tombe dans le même silence qu'un fichier
# absent : douze tests écrits, jamais lancés.
with open(
os.path.join(RACINE, "script/test/run_unit_test.sh"),
encoding="utf-8",
) as fh:
self.assertIn("NOMMER UN NOUVEAU FICHIER", fh.read())
def test_the_script_is_executable_and_documented(self):
script = os.path.join(RACINE, "LongTest/deep_proxmox.py")
self.assertTrue(os.access(script, os.X_OK), "doit être exécutable")
# La doc est un .base.md : un .md généré se perd au prochain
# « make doc_markdown ».
self.assertTrue(
os.path.exists(os.path.join(RACINE, "LongTest/README.base.md"))
)
class TestLEssaiABlanc(unittest.TestCase):
"""L'essai à blanc annonce le plan et n'exécute RIEN.
C'est ce qui rend un test de plusieurs heures relisable avant de le
lancer : on voit les ressources de chaque étage et les commandes, sans
créer une machine."""
@classmethod
def setUpClass(cls):
cls.res = subprocess.run(
[
PYTHON,
os.path.join(RACINE, "LongTest/deep_proxmox.py"),
"--depth",
"4",
"--dry-run",
],
capture_output=True,
text=True,
timeout=180,
cwd=RACINE,
env=dict(os.environ, PYTHONPATH=RACINE),
)
def test_it_exits_cleanly(self):
self.assertEqual(self.res.returncode, 0, self.res.stderr[-800:])
def test_it_announces_the_plan_before_anything(self):
sortie = self.res.stdout
self.assertIn("étage", sortie)
# Quatre étages demandés, quatre lignes de plan.
for niveau in ("1", "2", "3", "4"):
self.assertIn(niveau, sortie)
self.assertIn("dry-run", sortie)
def test_it_shows_the_commands_it_would_send(self):
# Une étape affichée est une étape rejouable à la main : c'est ainsi
# que les pannes de ce module ont été diagnostiquées.
self.assertIn("qm create", self.res.stdout)
self.assertIn("install_proxmox.sh", self.res.stdout)
def test_the_first_level_is_wide_and_the_others_are_not(self):
# 12 vCPU au quatrième étage ont gelé un noyau invité ; deux
# avançaient.
# Par expression exacte : la ligne « machine : … Mo … Go » du haut
# contient les mêmes unités et décalait l'index d'un cran.
import re
plan = re.findall(
r"^\s+(\d+)\s+(\d+)\s+(\d+) Mo\s+(\d+) Go\s*$",
self.res.stdout,
re.M,
)
self.assertEqual(len(plan), 4, plan)
niveaux = {int(n): int(v) for n, v, _r, _d in plan}
self.assertGreater(niveaux[1], 1, "le premier étage peut être large")
for niveau in (2, 3, 4):
self.assertEqual(niveaux[niveau], 2, f"étage {niveau}")
class TestLeMenu(unittest.TestCase):
def test_the_mixin_is_wired_into_TODO(self):
todo = TODO.__new__(TODO)
self.assertTrue(hasattr(todo, "prompt_execute_longtest"))
def test_the_script_is_found_from_the_repository_root(self):
todo = TODO.__new__(TODO)
ancien = os.getcwd()
try:
os.chdir(RACINE)
self.assertTrue(todo._longtest_script("deep_proxmox.py"))
self.assertFalse(todo._longtest_script("nexiste-pas.py"))
finally:
os.chdir(ancien)
def test_the_test_menu_offers_it(self):
import inspect
src = inspect.getsource(TODO.prompt_execute_test)
self.assertIn("prompt_execute_longtest", src)
self.assertIn("Long tests", src)
if __name__ == "__main__":
unittest.main(verbosity=2)