erplibre/script/proxmox/nesting.py
Mathieu Benoit 9bce1a8503 [FIX] LongTest : sh au lieu de bash, et --detruire trop large
Attaqué par trois lentilles sur le code écrit, avant de le lancer pour de
vrai. Deux fautes valaient à elles seules l'exercice.

Il n'aurait JAMAIS fonctionné. L'installeur était lancé par « sh », or il
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.
Chaque étage aurait échoué sur l'installation, à tous les coups.

Et « --detruire » pouvait emporter une machine étrangère. Il prenait toute
entrée ssh dont le nom CONTENAIT « deep-pve », puis sur son rebond détruisait
toute VM dont le nom contenait « deep-pve » — une « deep-pve-lab » de
production tombait dedans, et « --purge » emporte les disques. Son tri « du
plus profond au plus haut » comptait les « + » de l'alias, or alias_etage
remplace le « + » du parent par un « - » : chaque alias en portait exactement
UN, le tri ne triait rien, et la destruction partait du plus HAUT — le disque
du parent emportait ses enfants sans qu'on les ait nommés. Il ignorait
« --dry-run », ne lisait aucun code de retour, concluait « ✓ défait », et le
menu le lançait d'une touche.

Il ne détruit plus que ce que le RAPPORT nomme : un couple (parent, VMID) par
étage, du plus profond d'après le niveau lu, égalité stricte du nom, arrêt
CONSTATÉ avant destruction, codes de retour lus, et une confirmation par
« OUI » après la liste.

Six autres constats, tous réels. Le redémarrage se prouve par btime et non par
le seul noyau — rejoué sur un étage déjà installé, on validait un redémarrage
qui n'avait pas eu lieu, exactement le piège corrigé la semaine dernière dans
le suivi. La sonde de disponibilité ne demande plus sudo, sinon un sudo lent
se lisait « jamais joignable en ssh ». Les délais suivent la profondeur : le
script existe pour mesurer un ralentissement de 36x, et un plafond fixe
déclarait échouée une installation qui avançait. L'adresse fixe est contrôlée
AVANT de télécharger une image et de démarrer une VM. Le DNS de l'hôte suit la
spec, sinon apt meurt sans rien expliquer. Et l'essai à blanc ne prétend plus
avoir atteint quoi que ce soit — son rapport était indiscernable d'une
réussite, JSON compris.

L'algorithme aussi : profondeur 0 rendait un plan d'UN étage, donc
« --depth 0 » créait une VM ; et sur un hôte de quatre cœurs le premier étage
recevait UN vCPU quand son invité en recevait deux — un parent plus étroit que
son enfant.

Les tests mordent, prouvé par mutation : remplacer le calcul du premier étage
par la valeur imbriquée les laissait verts.

--- EN ---

Attacked by three lenses on the written code, before running it for real. Two
faults alone justified the exercise.

It would NEVER have worked. The installer was run by "sh", yet it carries "set
-euo pipefail" and a bash shebang: on Debian /bin/sh is dash, which answers
"set: Illegal option -o pipefail" and exits on the FIRST line. Every level
would have failed at install, every time.

And "--detruire" could take a stranger's machine. It took every ssh entry
whose name CONTAINED "deep-pve", then on its jump host destroyed every VM
whose name contained "deep-pve" — a production "deep-pve-lab" fell in, and
"--purge" takes the disks. Its "deepest first" sort counted the "+" in the
alias, yet alias_etage replaces the parent's "+" with a "-": every alias had
exactly ONE, the sort sorted nothing, and destruction started from the TOP —
the parent's disk took its children with it, unnamed. It ignored "--dry-run",
read no return code, concluded "✓ done", and the menu fired it on one key.

It now destroys only what the REPORT names: a (parent, VMID) pair per level,
deepest first by the recorded level, strict name equality, shutdown VERIFIED
before destruction, return codes read, and a "OUI" confirmation after the
list.

Six more findings, all real. The reboot is proven by btime, not by the kernel
alone — replayed on an already-installed level, we validated a reboot that
never happened, exactly the trap fixed last week in the monitor. The liveness
probe no longer asks for sudo, or a slow sudo read as "never reachable by
ssh". Timeouts follow the depth: the script exists to measure a 36x slowdown,
and a fixed ceiling declared failed an install that was progressing. The
static address is checked BEFORE downloading an image and starting a VM. The
host's DNS follows the spec, or apt dies explaining nothing. And the dry run
no longer claims to have reached anything — its report was indistinguishable
from a success, JSON included.

The algorithm too: depth 0 returned a ONE-level plan, so "--depth 0" created a
VM; and on a four-core host the first level got ONE vCPU while its guest got
two — a parent narrower than its child.

The tests bite, proven by mutation: replacing the first level's computation
with the nested value left them green.

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

159 lines
6.5 KiB
Python

#!/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 = [], ""
# « max(1, …) » forçait un tour : profondeur 0 rendait un plan d'UN
# étage, et « --depth 0 » créait donc une VM. range(1, 1) est déjà vide,
# et un plan vide est la bonne réponse à une demande vide.
for niveau in range(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,
# Le plancher est VCPU_IMBRIQUE et non 1 : sur un hôte de
# quatre cœurs, « // 4 » donnait UN vCPU au premier étage —
# l'hyperviseur parent — alors que son invité en recevait
# deux. Un parent plus étroit que son enfant est absurde, et
# c'est tout l'inverse de ce que ce module raconte.
"vcpu": (
max(
VCPU_IMBRIQUE,
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}",
)