[ADD] VM imbriquées : déployer sur Proxmox, mesurer la profondeur

Vingt-six commits : le déploiement Proxmox durci par l'usage, et deux tests
longs qui créent de vraies machines pour mesurer ce qu'aucune documentation ne
dit.

CE QUE LES MESURES ONT ÉTABLI

Sur 28 cœurs AMD, trois étages d'hyperviseurs imbriqués tiennent en 16 minutes
pour QEMU, 34 pour Proxmox. Le quatrième FONCTIONNE — un Proxmox s'y est
installé complètement — mais coûte 15 à 30 fois plus : 4 h 20 d'amorçage et
7 h 18 d'installation. Ce n'est pas un refus du matériel, c'est une
dégradation super-linéaire, et elle tombe là où les fabricants cessent de
documenter l'imbrication. La profondeur par défaut est donc trois.

CE QUE LES TESTS PROUVENT AU LIEU DE SUPPOSER

deploy_qemu.py n'échoue pas quand /dev/kvm manque : il pose --virt-type qemu
et crée une VM ÉMULÉE, sept minutes et demie de démarrage, sans qu'aucun code
de retour ne le dise. Chaque étage doit montrer /dev/kvm, « nested » à Y et un
domaine en type='kvm' — ce qui n'a pas été lu vaut NON. Sans cela, la descente
mesurerait de la TCG empilée en croyant mesurer de l'imbrication.

CE QUE « --detruire » NE FAIT JAMAIS

Détruire ce qu'il n'a pas créé. Un champ « cree », écrit à l'instant où la
machine existe, commande la liste, le repli par nom et le retrait des entrées
~/.ssh/config ; il refuse tant qu'une descente tourne, et chaque pile ne voit
que ses propres rapports.

CÔTÉ PROXMOX

Le pont interne ne prend plus l'adresse de sa propre passerelle, pmxcfs est
remis debout par l'écran lui-même, le suivi reconnaît une VM par son VMID et
son hôte plutôt que par son nom, et le pare-feu n'est plus démarré depuis
l'extérieur.

--- EN ---

Twenty-six commits: Proxmox deployment hardened by use, and two long tests
that create real machines to measure what no documentation states.

WHAT THE MEASUREMENTS ESTABLISHED

On 28 AMD cores, three levels of nested hypervisors take 16 minutes for QEMU,
34 for Proxmox. The fourth WORKS — a Proxmox installed completely there — but
costs 15 to 30 times more: 4h20 of boot and 7h18 of install. Not a hardware
refusal, a super-linear degradation, landing exactly where vendors stop
documenting nesting. The default depth is therefore three.

WHAT THE TESTS PROVE INSTEAD OF ASSUMING

deploy_qemu.py does not fail when /dev/kvm is missing: it sets --virt-type
qemu and creates an EMULATED VM, seven and a half minutes to boot, with no
exit code to say so. Every level must show /dev/kvm, "nested" at Y and a
type='kvm' domain — what was not read counts as NO. Without that, the descent
would measure stacked TCG believing it measured nesting.

WHAT "--detruire" NEVER DOES

Destroy what it did not create. A "cree" field, written the instant the
machine exists, governs the list, the fallback to the name and the removal of
~/.ssh/config entries; it refuses while a descent runs, and each stack sees
only its own reports.

ON THE PROXMOX SIDE

The internal bridge no longer takes its own gateway's address, pmxcfs is
brought back up by the screen itself, the monitor identifies a VM by its VMID
and host rather than by its name, and the firewall is no longer started from
outside.

Assisted-by: claude-opus-5
This commit is contained in:
Mathieu Benoit 2026-08-29 02:08:25 -04:00
commit b5209ee226
26 changed files with 8563 additions and 54 deletions

View file

@ -41,6 +41,9 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Ajouté
<!-- [en] -->
- `long_test/` — tests that create real machines and take hours, kept out of `test/` so the unit runner stays runnable in seconds. `deep_proxmox.py` stacks Proxmox in Proxmox, `deep_qemu.py` stacks QEMU in QEMU, and they share one engine. Measured on 28 cores: three levels cost 34 minutes, the fourth 4 h 20 of boot plus 7 h 18 of install — everything there is 15 to 30 times slower, and that is where the vendors stop documenting nesting. The depth is a parameter and defaults to three, because three works
- deep_qemu proves KVM at every level instead of assuming it: `deploy_qemu.py` never passes `--cpu host-passthrough` and, when /dev/kvm is missing, it does not fail — it sets `--virt-type qemu` and creates a fully EMULATED VM, seven and a half minutes to boot, with no exit code to say so. Unguarded, the descent would measure stacked TCG while believing it measured nesting. Each level must show `/dev/kvm`, `nested=Y` and a child domain in `type='kvm'`; what was not read counts as NO
- Both long tests take `--hote` to start from a machine you already own, rather than creating a head VM to host a hypervisor you have on hand — that costs five minutes AND one level of nesting. The plan is then sized on the ROOT, read over ssh; the delays count ABSOLUTE depth; and the root is never a level reached, never destroyed, and its ~/.ssh/config entry is never removed
- Support Odoo migration database and module with TODO
- Support multi version odoo switch on same workspace
- Script for hardening the installation
@ -122,6 +125,9 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
<!-- [fr] -->
- `long_test/` — des tests qui créent de vraies machines et durent des heures, tenus hors de `test/` pour que le lanceur unitaire reste lançable en quelques secondes. `deep_proxmox.py` empile des Proxmox dans des Proxmox, `deep_qemu.py` des QEMU dans des QEMU, et les deux partagent un moteur. Mesuré sur 28 cœurs : trois étages coûtent 34 minutes, 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 profondeur est un paramètre et vaut trois par défaut, parce que trois marche
- deep_qemu PROUVE KVM à chaque étage au lieu de le supposer : `deploy_qemu.py` ne passe jamais `--cpu host-passthrough` et, quand /dev/kvm manque, il n'échoue pas — il pose `--virt-type qemu` et crée une VM entièrement ÉMULÉE, sept minutes et demie de démarrage, sans qu'aucun code de retour ne le dise. Sans garde, la descente mesurerait de la TCG empilée en croyant mesurer de l'imbrication. Chaque étage doit montrer `/dev/kvm`, `nested=Y` et un domaine enfant en `type='kvm'` ; ce qui n'a pas été lu vaut NON
- Les deux tests longs acceptent `--hote` pour partir d'une machine qu'on possède déjà, au lieu de créer une VM de tête pour héberger un hyperviseur qu'on a sous la main — cela coûte cinq minutes ET un étage d'imbrication. Le plan se dimensionne alors sur la RACINE, lue par ssh ; les délais comptent la profondeur ABSOLUE ; et la racine n'est jamais un étage atteint, jamais détruite, et son entrée ~/.ssh/config n'est jamais retirée
- Support de la migration de base de données et de modules Odoo avec TODO
- Support du changement multi-version Odoo sur le même espace de travail
- Script pour le renforcement de la sécurité de l'installation

View file

@ -15,6 +15,9 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Ajouté
- `long_test/` — des tests qui créent de vraies machines et durent des heures, tenus hors de `test/` pour que le lanceur unitaire reste lançable en quelques secondes. `deep_proxmox.py` empile des Proxmox dans des Proxmox, `deep_qemu.py` des QEMU dans des QEMU, et les deux partagent un moteur. Mesuré sur 28 cœurs : trois étages coûtent 34 minutes, 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 profondeur est un paramètre et vaut trois par défaut, parce que trois marche
- deep_qemu PROUVE KVM à chaque étage au lieu de le supposer : `deploy_qemu.py` ne passe jamais `--cpu host-passthrough` et, quand /dev/kvm manque, il n'échoue pas — il pose `--virt-type qemu` et crée une VM entièrement ÉMULÉE, sept minutes et demie de démarrage, sans qu'aucun code de retour ne le dise. Sans garde, la descente mesurerait de la TCG empilée en croyant mesurer de l'imbrication. Chaque étage doit montrer `/dev/kvm`, `nested=Y` et un domaine enfant en `type='kvm'` ; ce qui n'a pas été lu vaut NON
- Les deux tests longs acceptent `--hote` pour partir d'une machine qu'on possède déjà, au lieu de créer une VM de tête pour héberger un hyperviseur qu'on a sous la main — cela coûte cinq minutes ET un étage d'imbrication. Le plan se dimensionne alors sur la RACINE, lue par ssh ; les délais comptent la profondeur ABSOLUE ; et la racine n'est jamais un étage atteint, jamais détruite, et son entrée ~/.ssh/config n'est jamais retirée
- Support de la migration de base de données et de modules Odoo avec TODO
- Support du changement multi-version Odoo sur le même espace de travail
- Script pour le renforcement de la sécurité de l'installation

View file

@ -15,6 +15,9 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Added
- `long_test/` — tests that create real machines and take hours, kept out of `test/` so the unit runner stays runnable in seconds. `deep_proxmox.py` stacks Proxmox in Proxmox, `deep_qemu.py` stacks QEMU in QEMU, and they share one engine. Measured on 28 cores: three levels cost 34 minutes, the fourth 4 h 20 of boot plus 7 h 18 of install — everything there is 15 to 30 times slower, and that is where the vendors stop documenting nesting. The depth is a parameter and defaults to three, because three works
- deep_qemu proves KVM at every level instead of assuming it: `deploy_qemu.py` never passes `--cpu host-passthrough` and, when /dev/kvm is missing, it does not fail — it sets `--virt-type qemu` and creates a fully EMULATED VM, seven and a half minutes to boot, with no exit code to say so. Unguarded, the descent would measure stacked TCG while believing it measured nesting. Each level must show `/dev/kvm`, `nested=Y` and a child domain in `type='kvm'`; what was not read counts as NO
- Both long tests take `--hote` to start from a machine you already own, rather than creating a head VM to host a hypervisor you have on hand — that costs five minutes AND one level of nesting. The plan is then sized on the ROOT, read over ssh; the delays count ABSOLUTE depth; and the root is never a level reached, never destroyed, and its ~/.ssh/config entry is never removed
- Support Odoo migration database and module with TODO
- Support multi version odoo switch on same workspace
- Script for hardening the installation

View file

@ -25,6 +25,10 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0)
- Pour les commits : suivre le format `[TYPE] description` (ex: `[FIX]`, `[UPD]`, `[ADD]`, `[REM]`)
- Pour la documentation : modifier les `.base.md`, jamais les `.md` ou `.fr.md` directement
- Outil mmg disponible via `source .venv.erplibre/bin/activate && mmg`
- Les tests qui créent de VRAIES machines vivent dans `long_test/` et non dans
`test/` : le lanceur unitaire balaie `test/test_*.py` et doit rester lançable
en quelques secondes, même sans virtualisation. Ils durent des heures et se
défont par `--detruire` — voir `long_test/README.md`
## Core Principles

383
long_test/README.base.md Normal file
View file

@ -0,0 +1,383 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# long_test — 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 — and one
measurement is not a measurement.
A manual look at one fourth-level VM found 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) and then a frozen kernel: identical RIP across three samples
two minutes apart, and **not one byte written** to disk.
Running this script **refuted the conclusion drawn from it**. Its own
fourth-level VM — 2 vCPU where the manual one had 12 — booted, installed, and
wrote gigabytes. What looked like a nesting ceiling was a *parallelism*
ceiling under nesting. That is exactly what the algorithm caps, and this is
how it stopped being a guess.
Which is the point of the script: a number obtained once, on one machine, in
one chain, is an anecdote.
```
./long_test/deep_proxmox.py # three levels, ~30 minutes
./long_test/deep_proxmox.py --dry-run # the plan, nothing created
./long_test/deep_proxmox.py --depth 5 # ask for more, knowingly
./long_test/deep_proxmox.py --detruire # undo it
```
### How deep is worth asking for
The depth is the only setting, and **three** is the default because three
works. Measured on a 28-core machine, one full descent per row:
| level | boot (ssh) | install | total |
|------:|-----------:|--------:|------:|
| 1 | 0 s | 200 s | 280 s |
| 2 | 37 s | 344 s | 495 s |
| 3 | 93 s | 777 s | 1 064 s |
| 4 | **15 608 s** | **26 306 s** | did not finish |
Three levels cost half an hour. The **fourth** cost 4 h 20 of boot and 7 h 18
of install on the same machine — everything there is 15 to 30 times slower, not
just one step. And it lands exactly where the hardware vendors stop: level 4 is
the *third* nested hypervisor, and AMD documents two.
A wider guest makes it worse, sharply: at level 4, one extra vCPU multiplied
the boot by 9.4 (1 664 s at two vCPU, 15 608 s at three), and at eight vCPU the
guest read 32 MiB in 106 minutes with a static instruction pointer. At levels 2
and 3 that same vCPU costs nothing.
So: three by default, five if you want to know, ten only to watch the wall.
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 — sized from the bottom up
The first version handed down whatever the parent could spare, and a real
descent showed what that costs. Level 4 ended up with 44 GB of memory and
2 vCPU **on a host that had 2** — a hundred percent overcommit, at every
level, with the hypervisor itself to serve on top. Its install ran past two
and a half hours against thirteen minutes for level 3, and extrapolating that
ratio gave five years for the tenth.
So the direction is reversed for **memory and disk**. The deepest level gets
what a test Proxmox actually asks for — 4 GB of memory, 25 GB of disk — and
every parent above it adds its own overhead and nothing else: 2 GiB and 10 GB.
A ten-level descent therefore asks its first level for 22 GB and 115 GB, where
handing resources down wanted 50 GB of memory for the same depth. The
processor follows a different rule; see below.
Three budgets can bound the depth, and `script/proxmox/nesting.py` names the
one that ran out:
* **memory** — every level must run its own daemons (`pve-cluster`,
`pvestatd`, `pvedaemon`, `pveproxy`) *and* hold its child;
* **disk** — the child's disk lives *inside* the parent's, which must also
hold its own system;
* **processor** — it does *not* grow with depth. Every nested level keeps a
fixed, narrow width; only the first level counts against the physical cores.
Either the machine can carry that first level or it can carry nothing.
That third rule is measured, and it cost two descents to get right. A nested
guest at the **fourth** level freezes in early boot as soon as it is wide:
twelve vCPU the first time, eight the second — same instruction pointer at
three readings five minutes apart, 32 MiB read and not one byte more for 106
minutes. Two vCPU boots.
The first freeze was blamed on **overcommit**: that VM had twelve vCPU on a
host with two. The second measurement refuted it — eight vCPU on a parent with
**nine**, load 1.47, no overcommit at all, and the same freeze. It is the
nested guest's vCPU count, not its ratio to its host's.
At the third level, 9 vCPU boots in 117 s. The threshold sits between the
third and fourth level, so no nested level is ever made wide. An earlier
version of this algorithm gave each parent one vCPU more than its child, which
made level 4 eight wide — exactly the frozen case. The rule made wide what
must stay narrow.
Hence three fixed widths: `VCPU_METAL` for level 1 (on bare metal, no freeze
risk — eleven vCPU booted there in 42 s), `VCPU_IMBRIQUE` for the deepest, and
`VCPU_INTERMEDIAIRE` in between, wide enough to host its child without being
as narrow as it. That middle number is a **hypothesis**: two is proven to boot
at the fourth level and eight is proven to freeze, with nothing measured in
between. The descent decides.
Memory is not the lever. On that same manual VM, dropping it from 9 GB to 2 GB
moved nothing — it stopped after reading the same 32 MiB, which is simply the
size of the boot files.
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.
## deep_qemu.py — how deep does QEMU-in-QEMU go?
The same descent, a different stack — and the pair is the point. The fourth
level's slowdown comes from the **processor**: what a VM exit costs under
nested paging. The per-level *cost*, though, comes from what you install. A
Proxmox node lays down a kernel, corosync, ceph and a web UI; a libvirt host
lays down `libvirtd` and `qemu-kvm`. Measured together, the two separate what
is due to the hardware from what is due to the stack — two things the Proxmox
measurement alone confounds.
### What this test must prove before it measures anything
`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, and no
exit code says so.
Unguarded, this script would measure stacked TCG while believing it measured
nesting — and return a more flattering number that means nothing. So every
level must prove, not assume:
* `/dev/kvm` is readable;
* `/sys/module/kvm_amd|kvm_intel/parameters/nested` reads `Y`;
* the child's domain is `<domain type='kvm'>`, checked right after creation.
**What was not read counts as NO.** An absent `/sys/module` file means an
unloaded module, not a permissions problem. A level that fails these stops the
descent instead of prolonging it into the void.
## Starting from a host you already have
Both scripts take `--hote`. Creating a head VM to host a hypervisor you
already own costs five minutes *and* one level of nesting — that is, slowness,
which is the very thing being measured.
```
./long_test/deep_proxmox.py --hote root@10.0.0.5 # an existing Proxmox
./long_test/deep_qemu.py --hote erplibre@10.0.0.7 # an existing libvirt host
```
Three things follow, and they are not decorative:
* the plan is sized on the **root**, read over ssh — sizing it on the local
machine while the levels live elsewhere would announce levels that do not
fit;
* the delays count **absolute** depth: a level-1 child placed in a root that
is already at the third level is really at the fourth;
* the root is **never** a level reached, and **never** destroyed. A borrowed
host has no local libvirt UUID, so `--detruire` refuses to fall back on its
name — `virsh undefine --remove-all-storage` erases a disk for good.
The menu offers the host already chosen without searching for it, and undoes
each stack separately: they share the report directory, but each knows only
its own reports.
<!-- [fr] -->
# long_test — 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 — et
une mesure n'est pas une mesure.
Un examen à la main d'UNE VM du quatrième étage a trouvé 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é :
même RIP à trois relevés deux minutes d'écart, et **pas un octet écrit** sur
le disque.
Lancer ce script a **réfuté la conclusion qu'on en avait tirée**. Sa propre VM
du quatrième étage — 2 vCPU là où celle de la main en avait 12 — a démarré,
s'est installée, et a écrit des gigaoctets. Ce qui ressemblait à un plafond
d'imbrication était un plafond de *parallélisme* sous imbrication. C'est
précisément ce que l'algorithme borne, et c'est ainsi qu'il a cessé d'être une
supposition.
D'où le script : un chiffre obtenu une fois, sur une machine, dans une chaîne,
est une anecdote.
```
./long_test/deep_proxmox.py # trois étages, ~30 minutes
./long_test/deep_proxmox.py --dry-run # le plan, rien de créé
./long_test/deep_proxmox.py --depth 5 # en demander plus, sciemment
./long_test/deep_proxmox.py --detruire # défaire
```
### Quelle profondeur vaut la peine d'être demandée
La profondeur est le seul réglage, et **trois** est le défaut parce que trois
marche. Mesuré sur une machine à 28 cœurs, une descente complète par ligne :
| étage | amorçage (ssh) | installation | total |
|------:|---------------:|-------------:|------:|
| 1 | 0 s | 200 s | 280 s |
| 2 | 37 s | 344 s | 495 s |
| 3 | 93 s | 777 s | 1 064 s |
| 4 | **15 608 s** | **26 306 s** | n'a pas abouti |
Trois étages coûtent une demi-heure. Le **quatrième** a coûté 4 h 20
d'amorçage et 7 h 18 d'installation sur la même machine — tout y est 15 à 30
fois plus lent, pas une seule étape. Et cela tombe précisément là où les
fabricants s'arrêtent : le quatrième étage est le *troisième* hyperviseur
imbriqué, et AMD en documente deux.
Un invité plus large aggrave brutalement : au quatrième étage, un vCPU de plus
a multiplié l'amorçage par 9,4 (1 664 s à deux vCPU, 15 608 s à trois), et à
huit vCPU l'invité a lu 32 Mio en 106 minutes, pointeur d'instruction
immobile. Aux étages 2 et 3, ce même vCPU ne coûte rien.
Donc : trois par défaut, cinq pour savoir, dix seulement pour voir le mur.
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 — dimensionné depuis le bas
La première version cédait à l'enfant ce que le parent pouvait céder, et une
descente réelle a montré ce que cela coûte. L'étage 4 se retrouvait avec 44 Go
de mémoire et 2 vCPU **sur un hôte qui en avait 2** — cent pour cent de
surengagement, à chaque étage, avec l'hyperviseur lui-même à servir par-dessus.
Son installation dépassait deux heures et demie contre treize minutes pour
l'étage 3, et l'extrapolation de ce rapport donnait cinq ANS pour le dixième.
Le sens est donc inversé pour la **mémoire et le disque**. Le plus profond
reçoit ce qu'un Proxmox de test demande vraiment — 4 Go de mémoire, 25 Go de
disque — et chaque parent au-dessus ajoute son propre surcoût, rien d'autre :
2 Gio et 10 Go. Une descente à dix étages demande ainsi 22 Go et 115 Go à son
premier étage, là où la cession de haut en bas voulait 50 Go de mémoire pour la
même profondeur. Le processeur, lui, suit une autre règle — voir plus bas.
Trois budgets peuvent borner la profondeur, et `script/proxmox/nesting.py`
nomme celui qui a manqué :
* **la mémoire** — chaque étage doit faire tourner ses propres démons
(`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) *et* héberger son
enfant ;
* **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit
aussi contenir son propre système ;
* **le processeur** — il ne croît *pas* avec la profondeur. Tout étage
imbriqué garde une largeur fixe et étroite ; seul le premier compte sur les
cœurs physiques. Ou la machine peut porter ce premier étage, ou elle ne peut
rien.
Cette troisième règle est mesurée, et il a fallu deux descentes pour la poser
juste. Un invité imbriqué au **quatrième** étage gèle en tout début de
démarrage dès qu'il est large : douze vCPU la première fois, huit la seconde —
même pointeur d'instruction à trois relevés espacés de cinq minutes, 32 Mio lus
et plus un octet pendant 106 minutes. Deux vCPU démarrent.
Le premier gel avait été imputé au **surengagement** : cette VM à douze vCPU
tournait sur un hôte qui en avait deux. La seconde mesure l'a réfuté — huit
vCPU sur un parent qui en avait **neuf**, charge 1,47, aucun surengagement, et
le même gel. C'est le nombre de vCPU de l'invité imbriqué, et non son rapport à
celui de son hôte.
Au troisième étage, 9 vCPU démarrent en 117 s. Le seuil est entre le troisième
et le quatrième étage : aucun étage imbriqué n'est donc rendu large. Une version
précédente de cet algorithme donnait un vCPU de plus à chaque parent, ce qui
rendait l'étage 4 large de huit — exactement le cas gelé. La règle rendait large
ce qui doit rester étroit.
D'où trois largeurs fixes : `VCPU_METAL` pour l'étage 1 (sur le métal, aucun
risque de gel — onze vCPU y ont démarré en 42 s), `VCPU_IMBRIQUE` pour le plus
profond, et `VCPU_INTERMEDIAIRE` entre les deux, juste assez large pour héberger
son enfant sans être aussi étroit que lui. Ce nombre du milieu est une
**hypothèse** : deux démarre au quatrième étage, huit gèle, et rien n'est mesuré
entre les deux. C'est la descente qui tranche.
La mémoire n'est pas le levier. Sur cette même VM examinée à la main, la faire
passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu les
mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage.
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.
## deep_qemu.py — jusqu'à quel étage une QEMU dans une QEMU tient-elle ?
La même descente, une autre pile — et c'est le couple qui compte. Le
ralentissement du quatrième étage vient du **processeur** : de ce que coûte une
sortie de VM sous pagination imbriquée. Le *coût* par étage, lui, vient de ce
qu'on installe. Un nœud Proxmox pose un noyau, corosync, ceph et une interface
web ; un hôte libvirt pose `libvirtd` et `qemu-kvm`. Mesurées ensemble, les
deux séparent ce qui tient au matériel de ce qui tient à la pile — deux choses
que la seule mesure Proxmox confond.
### Ce que ce test doit prouver avant de mesurer quoi que ce soit
`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, et aucun code de retour ne le dit.
Sans garde, ce script mesurerait de la TCG empilée en croyant mesurer de
l'imbrication — et rendrait un chiffre plus flatteur qui ne veut rien dire.
Chaque étage doit donc prouver, et non supposer :
* `/dev/kvm` est lisible ;
* `/sys/module/kvm_amd|kvm_intel/parameters/nested` vaut `Y` ;
* le domaine de l'enfant est `<domain type='kvm'>`, vérifié juste après sa
création.
**Ce qui n'a pas été lu vaut NON.** Un fichier `/sys/module` absent, c'est un
module non chargé, pas un problème de permission. Un étage qui échoue à cela
arrête la descente au lieu de la prolonger dans le vide.
## Partir d'un hôte qu'on possède déjà
Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un
hyperviseur qu'on a sous la main coûte cinq minutes *et* un étage
d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure.
```
./long_test/deep_proxmox.py --hote root@10.0.0.5 # un Proxmox existant
./long_test/deep_qemu.py --hote erplibre@10.0.0.7 # un hôte libvirt existant
```
Trois choses en découlent, et elles ne sont pas décoratives :
* le plan se dimensionne sur la **racine**, lue par ssh — le dimensionner sur
la machine locale quand les étages vivent ailleurs annoncerait des étages qui
ne tiennent pas ;
* les délais comptent la profondeur **absolue** : un enfant de niveau 1 posé
dans une racine déjà au troisième étage est en réalité au quatrième ;
* la racine n'est **jamais** un étage atteint, et **jamais** détruite. Un hôte
emprunté n'a pas d'UUID libvirt local, donc `--detruire` refuse de se rabattre
sur son nom — `virsh undefine --remove-all-storage` efface un disque pour de
bon.
Le menu propose l'hôte déjà retenu sans le rechercher, et défait chaque pile
séparément : elles partagent le dossier des rapports, mais chacune ne connaît
que les siens.

194
long_test/README.fr.md Normal file
View file

@ -0,0 +1,194 @@
# long_test — 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 — et
une mesure n'est pas une mesure.
Un examen à la main d'UNE VM du quatrième étage a trouvé 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é :
même RIP à trois relevés deux minutes d'écart, et **pas un octet écrit** sur
le disque.
Lancer ce script a **réfuté la conclusion qu'on en avait tirée**. Sa propre VM
du quatrième étage — 2 vCPU là où celle de la main en avait 12 — a démarré,
s'est installée, et a écrit des gigaoctets. Ce qui ressemblait à un plafond
d'imbrication était un plafond de *parallélisme* sous imbrication. C'est
précisément ce que l'algorithme borne, et c'est ainsi qu'il a cessé d'être une
supposition.
D'où le script : un chiffre obtenu une fois, sur une machine, dans une chaîne,
est une anecdote.
```
./long_test/deep_proxmox.py # trois étages, ~30 minutes
./long_test/deep_proxmox.py --dry-run # le plan, rien de créé
./long_test/deep_proxmox.py --depth 5 # en demander plus, sciemment
./long_test/deep_proxmox.py --detruire # défaire
```
### Quelle profondeur vaut la peine d'être demandée
La profondeur est le seul réglage, et **trois** est le défaut parce que trois
marche. Mesuré sur une machine à 28 cœurs, une descente complète par ligne :
| étage | amorçage (ssh) | installation | total |
|------:|---------------:|-------------:|------:|
| 1 | 0 s | 200 s | 280 s |
| 2 | 37 s | 344 s | 495 s |
| 3 | 93 s | 777 s | 1 064 s |
| 4 | **15 608 s** | **26 306 s** | n'a pas abouti |
Trois étages coûtent une demi-heure. Le **quatrième** a coûté 4 h 20
d'amorçage et 7 h 18 d'installation sur la même machine — tout y est 15 à 30
fois plus lent, pas une seule étape. Et cela tombe précisément là où les
fabricants s'arrêtent : le quatrième étage est le *troisième* hyperviseur
imbriqué, et AMD en documente deux.
Un invité plus large aggrave brutalement : au quatrième étage, un vCPU de plus
a multiplié l'amorçage par 9,4 (1 664 s à deux vCPU, 15 608 s à trois), et à
huit vCPU l'invité a lu 32 Mio en 106 minutes, pointeur d'instruction
immobile. Aux étages 2 et 3, ce même vCPU ne coûte rien.
Donc : trois par défaut, cinq pour savoir, dix seulement pour voir le mur.
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 — dimensionné depuis le bas
La première version cédait à l'enfant ce que le parent pouvait céder, et une
descente réelle a montré ce que cela coûte. L'étage 4 se retrouvait avec 44 Go
de mémoire et 2 vCPU **sur un hôte qui en avait 2** — cent pour cent de
surengagement, à chaque étage, avec l'hyperviseur lui-même à servir par-dessus.
Son installation dépassait deux heures et demie contre treize minutes pour
l'étage 3, et l'extrapolation de ce rapport donnait cinq ANS pour le dixième.
Le sens est donc inversé pour la **mémoire et le disque**. Le plus profond
reçoit ce qu'un Proxmox de test demande vraiment — 4 Go de mémoire, 25 Go de
disque — et chaque parent au-dessus ajoute son propre surcoût, rien d'autre :
2 Gio et 10 Go. Une descente à dix étages demande ainsi 22 Go et 115 Go à son
premier étage, là où la cession de haut en bas voulait 50 Go de mémoire pour la
même profondeur. Le processeur, lui, suit une autre règle — voir plus bas.
Trois budgets peuvent borner la profondeur, et `script/proxmox/nesting.py`
nomme celui qui a manqué :
* **la mémoire** — chaque étage doit faire tourner ses propres démons
(`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) *et* héberger son
enfant ;
* **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit
aussi contenir son propre système ;
* **le processeur** — il ne croît *pas* avec la profondeur. Tout étage
imbriqué garde une largeur fixe et étroite ; seul le premier compte sur les
cœurs physiques. Ou la machine peut porter ce premier étage, ou elle ne peut
rien.
Cette troisième règle est mesurée, et il a fallu deux descentes pour la poser
juste. Un invité imbriqué au **quatrième** étage gèle en tout début de
démarrage dès qu'il est large : douze vCPU la première fois, huit la seconde —
même pointeur d'instruction à trois relevés espacés de cinq minutes, 32 Mio lus
et plus un octet pendant 106 minutes. Deux vCPU démarrent.
Le premier gel avait été imputé au **surengagement** : cette VM à douze vCPU
tournait sur un hôte qui en avait deux. La seconde mesure l'a réfuté — huit
vCPU sur un parent qui en avait **neuf**, charge 1,47, aucun surengagement, et
le même gel. C'est le nombre de vCPU de l'invité imbriqué, et non son rapport à
celui de son hôte.
Au troisième étage, 9 vCPU démarrent en 117 s. Le seuil est entre le troisième
et le quatrième étage : aucun étage imbriqué n'est donc rendu large. Une version
précédente de cet algorithme donnait un vCPU de plus à chaque parent, ce qui
rendait l'étage 4 large de huit — exactement le cas gelé. La règle rendait large
ce qui doit rester étroit.
D'où trois largeurs fixes : `VCPU_METAL` pour l'étage 1 (sur le métal, aucun
risque de gel — onze vCPU y ont démarré en 42 s), `VCPU_IMBRIQUE` pour le plus
profond, et `VCPU_INTERMEDIAIRE` entre les deux, juste assez large pour héberger
son enfant sans être aussi étroit que lui. Ce nombre du milieu est une
**hypothèse** : deux démarre au quatrième étage, huit gèle, et rien n'est mesuré
entre les deux. C'est la descente qui tranche.
La mémoire n'est pas le levier. Sur cette même VM examinée à la main, la faire
passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu les
mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage.
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.
## deep_qemu.py — jusqu'à quel étage une QEMU dans une QEMU tient-elle ?
La même descente, une autre pile — et c'est le couple qui compte. Le
ralentissement du quatrième étage vient du **processeur** : de ce que coûte une
sortie de VM sous pagination imbriquée. Le *coût* par étage, lui, vient de ce
qu'on installe. Un nœud Proxmox pose un noyau, corosync, ceph et une interface
web ; un hôte libvirt pose `libvirtd` et `qemu-kvm`. Mesurées ensemble, les
deux séparent ce qui tient au matériel de ce qui tient à la pile — deux choses
que la seule mesure Proxmox confond.
### Ce que ce test doit prouver avant de mesurer quoi que ce soit
`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, et aucun code de retour ne le dit.
Sans garde, ce script mesurerait de la TCG empilée en croyant mesurer de
l'imbrication — et rendrait un chiffre plus flatteur qui ne veut rien dire.
Chaque étage doit donc prouver, et non supposer :
* `/dev/kvm` est lisible ;
* `/sys/module/kvm_amd|kvm_intel/parameters/nested` vaut `Y` ;
* le domaine de l'enfant est `<domain type='kvm'>`, vérifié juste après sa
création.
**Ce qui n'a pas été lu vaut NON.** Un fichier `/sys/module` absent, c'est un
module non chargé, pas un problème de permission. Un étage qui échoue à cela
arrête la descente au lieu de la prolonger dans le vide.
## Partir d'un hôte qu'on possède déjà
Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un
hyperviseur qu'on a sous la main coûte cinq minutes *et* un étage
d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure.
```
./long_test/deep_proxmox.py --hote root@10.0.0.5 # un Proxmox existant
./long_test/deep_qemu.py --hote erplibre@10.0.0.7 # un hôte libvirt existant
```
Trois choses en découlent, et elles ne sont pas décoratives :
* le plan se dimensionne sur la **racine**, lue par ssh — le dimensionner sur
la machine locale quand les étages vivent ailleurs annoncerait des étages qui
ne tiennent pas ;
* les délais comptent la profondeur **absolue** : un enfant de niveau 1 posé
dans une racine déjà au troisième étage est en réalité au quatrième ;
* la racine n'est **jamais** un étage atteint, et **jamais** détruite. Un hôte
emprunté n'a pas d'UUID libvirt local, donc `--detruire` refuse de se rabattre
sur son nom — `virsh undefine --remove-all-storage` efface un disque pour de
bon.
Le menu propose l'hôte déjà retenu sans le rechercher, et défait chaque pile
séparément : elles partagent le dossier des rapports, mais chacune ne connaît
que les siens.

183
long_test/README.md Normal file
View file

@ -0,0 +1,183 @@
# long_test — 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 — and one
measurement is not a measurement.
A manual look at one fourth-level VM found 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) and then a frozen kernel: identical RIP across three samples
two minutes apart, and **not one byte written** to disk.
Running this script **refuted the conclusion drawn from it**. Its own
fourth-level VM — 2 vCPU where the manual one had 12 — booted, installed, and
wrote gigabytes. What looked like a nesting ceiling was a *parallelism*
ceiling under nesting. That is exactly what the algorithm caps, and this is
how it stopped being a guess.
Which is the point of the script: a number obtained once, on one machine, in
one chain, is an anecdote.
```
./long_test/deep_proxmox.py # three levels, ~30 minutes
./long_test/deep_proxmox.py --dry-run # the plan, nothing created
./long_test/deep_proxmox.py --depth 5 # ask for more, knowingly
./long_test/deep_proxmox.py --detruire # undo it
```
### How deep is worth asking for
The depth is the only setting, and **three** is the default because three
works. Measured on a 28-core machine, one full descent per row:
| level | boot (ssh) | install | total |
|------:|-----------:|--------:|------:|
| 1 | 0 s | 200 s | 280 s |
| 2 | 37 s | 344 s | 495 s |
| 3 | 93 s | 777 s | 1 064 s |
| 4 | **15 608 s** | **26 306 s** | did not finish |
Three levels cost half an hour. The **fourth** cost 4 h 20 of boot and 7 h 18
of install on the same machine — everything there is 15 to 30 times slower, not
just one step. And it lands exactly where the hardware vendors stop: level 4 is
the *third* nested hypervisor, and AMD documents two.
A wider guest makes it worse, sharply: at level 4, one extra vCPU multiplied
the boot by 9.4 (1 664 s at two vCPU, 15 608 s at three), and at eight vCPU the
guest read 32 MiB in 106 minutes with a static instruction pointer. At levels 2
and 3 that same vCPU costs nothing.
So: three by default, five if you want to know, ten only to watch the wall.
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 — sized from the bottom up
The first version handed down whatever the parent could spare, and a real
descent showed what that costs. Level 4 ended up with 44 GB of memory and
2 vCPU **on a host that had 2** — a hundred percent overcommit, at every
level, with the hypervisor itself to serve on top. Its install ran past two
and a half hours against thirteen minutes for level 3, and extrapolating that
ratio gave five years for the tenth.
So the direction is reversed for **memory and disk**. The deepest level gets
what a test Proxmox actually asks for — 4 GB of memory, 25 GB of disk — and
every parent above it adds its own overhead and nothing else: 2 GiB and 10 GB.
A ten-level descent therefore asks its first level for 22 GB and 115 GB, where
handing resources down wanted 50 GB of memory for the same depth. The
processor follows a different rule; see below.
Three budgets can bound the depth, and `script/proxmox/nesting.py` names the
one that ran out:
* **memory** — every level must run its own daemons (`pve-cluster`,
`pvestatd`, `pvedaemon`, `pveproxy`) *and* hold its child;
* **disk** — the child's disk lives *inside* the parent's, which must also
hold its own system;
* **processor** — it does *not* grow with depth. Every nested level keeps a
fixed, narrow width; only the first level counts against the physical cores.
Either the machine can carry that first level or it can carry nothing.
That third rule is measured, and it cost two descents to get right. A nested
guest at the **fourth** level freezes in early boot as soon as it is wide:
twelve vCPU the first time, eight the second — same instruction pointer at
three readings five minutes apart, 32 MiB read and not one byte more for 106
minutes. Two vCPU boots.
The first freeze was blamed on **overcommit**: that VM had twelve vCPU on a
host with two. The second measurement refuted it — eight vCPU on a parent with
**nine**, load 1.47, no overcommit at all, and the same freeze. It is the
nested guest's vCPU count, not its ratio to its host's.
At the third level, 9 vCPU boots in 117 s. The threshold sits between the
third and fourth level, so no nested level is ever made wide. An earlier
version of this algorithm gave each parent one vCPU more than its child, which
made level 4 eight wide — exactly the frozen case. The rule made wide what
must stay narrow.
Hence three fixed widths: `VCPU_METAL` for level 1 (on bare metal, no freeze
risk — eleven vCPU booted there in 42 s), `VCPU_IMBRIQUE` for the deepest, and
`VCPU_INTERMEDIAIRE` in between, wide enough to host its child without being
as narrow as it. That middle number is a **hypothesis**: two is proven to boot
at the fourth level and eight is proven to freeze, with nothing measured in
between. The descent decides.
Memory is not the lever. On that same manual VM, dropping it from 9 GB to 2 GB
moved nothing — it stopped after reading the same 32 MiB, which is simply the
size of the boot files.
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.
## deep_qemu.py — how deep does QEMU-in-QEMU go?
The same descent, a different stack — and the pair is the point. The fourth
level's slowdown comes from the **processor**: what a VM exit costs under
nested paging. The per-level *cost*, though, comes from what you install. A
Proxmox node lays down a kernel, corosync, ceph and a web UI; a libvirt host
lays down `libvirtd` and `qemu-kvm`. Measured together, the two separate what
is due to the hardware from what is due to the stack — two things the Proxmox
measurement alone confounds.
### What this test must prove before it measures anything
`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, and no
exit code says so.
Unguarded, this script would measure stacked TCG while believing it measured
nesting — and return a more flattering number that means nothing. So every
level must prove, not assume:
* `/dev/kvm` is readable;
* `/sys/module/kvm_amd|kvm_intel/parameters/nested` reads `Y`;
* the child's domain is `<domain type='kvm'>`, checked right after creation.
**What was not read counts as NO.** An absent `/sys/module` file means an
unloaded module, not a permissions problem. A level that fails these stops the
descent instead of prolonging it into the void.
## Starting from a host you already have
Both scripts take `--hote`. Creating a head VM to host a hypervisor you
already own costs five minutes *and* one level of nesting — that is, slowness,
which is the very thing being measured.
```
./long_test/deep_proxmox.py --hote root@10.0.0.5 # an existing Proxmox
./long_test/deep_qemu.py --hote erplibre@10.0.0.7 # an existing libvirt host
```
Three things follow, and they are not decorative:
* the plan is sized on the **root**, read over ssh — sizing it on the local
machine while the levels live elsewhere would announce levels that do not
fit;
* the delays count **absolute** depth: a level-1 child placed in a root that
is already at the third level is really at the fourth;
* the root is **never** a level reached, and **never** destroyed. A borrowed
host has no local libvirt UUID, so `--detruire` refuses to fall back on its
name — `virsh undefine --remove-all-storage` erases a disk for good.
The menu offers the host already chosen without searching for it, and undoes
each stack separately: they share the report directory, but each knows only
its own reports.

412
long_test/deep_proxmox.py Executable file
View file

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

564
long_test/deep_qemu.py Normal file
View file

@ -0,0 +1,564 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Jusqu'à quel étage une QEMU dans une QEMU tient-elle ?
Le pendant de `deep_proxmox.py`, et sa raison d'être : le ralentissement du
quatrième étage vient du PROCESSEUR — de ce que coûte une sortie de VM sous
pagination imbriquée — mais le coût par étage, lui, vient de ce qu'on installe.
Un nœud Proxmox pose un noyau, corosync, ceph et une interface web ; un hôte
libvirt nu pose libvirtd et qemu-kvm. Les deux mesures ensemble séparent ce qui
tient au matériel de ce qui tient à la pile, deux choses que la seule mesure
Proxmox confond.
CE QUE CE TEST DOIT PROUVER AVANT DE MESURER
`deploy_qemu.py` ne passe jamais « --cpu host-passthrough » : il s'en remet au
défaut de virt-install. Et quand /dev/kvm manque, il n'échoue pas — il pose
« --virt-type qemu », avertit sur une ligne, et crée une VM ENTIÈREMENT ÉMULÉE.
Un étage émulé démarre en sept minutes et demie au lieu de quelques secondes,
et rien dans le code de retour ne le dit.
Sans garde, ce script mesurerait donc de la TCG empilée en croyant mesurer de
la virtualisation imbriquée — et rendrait un chiffre plus flatteur, et faux.
D'où le contrôle de chaque étage : /dev/kvm lisible, « nested » à Y, et le
domaine de l'enfant en type='kvm' avec un CPU host-passthrough. Un étage qui
échoue à cela arrête la descente au lieu de la prolonger dans le vide.
./long_test/deep_qemu.py # trois étages
./long_test/deep_qemu.py --depth 5 # en demander plus, sciemment
./long_test/deep_qemu.py --dry-run # le plan, rien de créé
./long_test/deep_qemu.py --detruire # défaire ce qui a été posé
"""
import os
import re
import shlex
import subprocess
import sys
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 nesting # noqa: E402
from script.proxmox import proxmox_deploy as pve # noqa: E402
import descente # noqa: E402
from descente import ( # noqa: E402,F401
DELAIS,
Famille,
a_defaire,
alias_etage as _alias_etage,
autre_descente,
capacite_hote,
cle_publique,
dernier_rapport,
descente_vivante,
detruire,
detruire_etage1,
dire,
identite_de,
mener,
module_qemu,
nom_etage as _nom_etage,
retirer_alias,
)
# Une Debian nue : c'est elle qui recevra libvirt et qemu-kvm.
DISTRO = "debian"
NOM_BASE = "deep-qemu"
OUTIL = "deep_qemu"
# Le script est envoyé par scp, comme install_proxmox.sh l'est pour Proxmox :
# c'est NOTRE code qu'on veut éprouver, et il n'importe que la bibliothèque
# standard, donc il tourne dans un invité nu sans rien d'autre.
LOCAL_CLI = "script/qemu/deploy_qemu.py"
DISTANT_CLI = "/tmp/deploy_qemu.py"
CLE_DISTANTE = "/root/.ssh/longtest.pub"
# Trois faits, trois lignes, et l'ABSENCE d'une ligne vaut « non ». Écrit pour
# dash : /bin/sh sur Debian n'est pas bash.
CONTROLE_CMD = (
"if [ -r /dev/kvm ]; then echo KVM=oui; else echo KVM=non; fi; "
"cat /sys/module/kvm_amd/parameters/nested 2>/dev/null"
" | sed s/^/NESTED=/; "
"cat /sys/module/kvm_intel/parameters/nested 2>/dev/null"
" | sed s/^/NESTED=/; "
"df --output=avail -BG /var/lib/libvirt/images 2>/dev/null"
" | tail -1 | sed s/^/DISQUE=/"
)
# Les listes apt AVANT toute installation. Constaté au premier lancement
# réel : « --setup-host » a échoué en ZÉRO seconde sur « Unable to locate
# package qemu-system-x86 », alors que le paquet existe. La VM venait de
# démarrer, ses listes ne portaient que « bookworm-security », et un
# apt-get update les a complétées d'un coup.
#
# Deux causes, une seule parade : cloud-init n'a pas fini de composer
# /etc/apt, et apt-daily tient le verrou des listes au premier démarrage.
# install_proxmox.sh a la même parade, et pour la même raison — arrêter les
# minuteries, puis réessayer.
PREPARE_APT_CMD = (
"systemctl stop apt-daily.service apt-daily-upgrade.service"
" apt-daily.timer apt-daily-upgrade.timer >/dev/null 2>&1;"
" i=1; while [ $i -le 12 ]; do"
" DEBIAN_FRONTEND=noninteractive apt-get update && exit 0;"
" echo APT-RETRY=$i; sleep 15; i=$((i+1)); done; exit 1"
)
# Le réseau « default » de libvirt sert 192.168.122.0/24, à TOUS les étages.
# Constaté au premier essai réel : l'étage 2, dont l'adresse était
# 192.168.122.45 — servie par le « default » de son parent — a vu son propre
# « net-start default » refusé net :
#
# error: internal error: Network is already in use by interface enp1s0
#
# Un invité qui vit DANS un réseau ne peut pas servir le même. Chaque étage
# reçoit donc son propre sous-réseau, déduit de sa PROFONDEUR : deux étages ne
# peuvent pas tomber sur le même, et rien n'est à deviner.
#
# 131 et au-delà : 122 est celui de libvirt et 123 celui de la machine où ce
# test a été écrit. Les éviter tous les deux coûte un octet.
RESEAU_BASE = 131
def cidr_pour(profondeur):
"""Le troisième octet du sous-réseau d'un étage. Déterminé, jamais tiré."""
return f"192.168.{RESEAU_BASE + max(0, int(profondeur) - 1)}"
def reseau_xml(prefixe, nom="default"):
"""Le réseau NAT d'un étage, en une ligne — pas de heredoc.
Une seule ligne parce qu'elle traverse deux couches de quoting pour
atterrir dans dash : un heredoc n'y survivrait pas.
"""
return (
f"<network><name>{nom}</name><forward mode='nat'/>"
f"<bridge name='virbr0' stp='on' delay='0'/>"
f"<ip address='{prefixe}.1' netmask='255.255.255.0'>"
f"<dhcp><range start='{prefixe}.10' end='{prefixe}.200'/></dhcp>"
f"</ip></network>"
)
RESEAU_CMD = (
"virsh -c qemu:///system net-info default 2>&1 | sed s/^/NET:/; "
"systemctl is-active libvirtd 2>/dev/null | sed s/^/UNITE:/"
)
def nom_etage(niveau):
return _nom_etage(niveau, NOM_BASE)
def alias_etage(niveau, parent_alias):
return _alias_etage(niveau, parent_alias, NOM_BASE)
def parse_controle(texte):
"""Ce que le contrôle a VU. Ce qui n'a pas été lu vaut « non ».
L'absence d'une ligne n'est jamais un oui : si
/sys/module/kvm_amd/parameters/nested n'existe pas, c'est que le module
n'est pas chargé, et l'étage suivant serait émulé.
"""
propre = pve.strip_ssh_noise(texte or "")
nested = re.search(r"^NESTED=(\S+)", propre, re.M)
disque = re.search(r"^DISQUE=\s*(\d+)", propre, re.M)
return {
"kvm": bool(re.search(r"^KVM=oui\s*$", propre, re.M)),
# « Y » ou « 1 » selon les versions du module.
"nested": bool(nested and nested.group(1).strip() in ("Y", "1")),
"disque_go": int(disque.group(1)) if disque else 0,
}
def parse_reseau(texte):
"""Le réseau libvirt « default » est-il actif, et libvirtd debout ?"""
propre = pve.strip_ssh_noise(texte or "")
actif = re.search(r"^NET:\s*Active:\s*(\S+)", propre, re.M | re.I)
unite = re.search(r"^UNITE:(\S+)", propre, re.M)
return {
"reseau": bool(actif and actif.group(1).lower() == "yes"),
"libvirtd": bool(unite and unite.group(1).strip() == "active"),
}
def parse_domifaddr(texte):
"""La première adresse IPv4 d'un domaine, sans son masque, ou "".
virsh écrit un tableau ; on ne prend que les lignes qui annoncent « ipv4 »,
et jamais la ligne d'en-tête ni les tirets.
"""
for ligne in pve.strip_ssh_noise(texte or "").splitlines():
champs = ligne.split()
if len(champs) >= 4 and champs[2].lower() == "ipv4":
return champs[3].split("/")[0]
return ""
def parse_domaine(xml):
"""Le domaine tourne-t-il sous KVM, et son CPU passe-t-il l'hôte ?
Les deux comptent, et pour la même raison : un domaine type='qemu' est
ÉMULÉ, et un CPU qui ne passe pas les drapeaux de l'hôte ne porte pas la
virtualisation — l'étage suivant serait émulé à son tour, sept minutes et
demie de démarrage, sans qu'aucun code de retour ne le dise.
"""
propre = pve.strip_ssh_noise(xml or "")
domaine = re.search(r"<domain[^>]*\btype=['\"](\w+)['\"]", propre)
cpu = re.search(r"<cpu[^>]*\bmode=['\"]([\w-]+)['\"]", propre)
return {
"type": domaine.group(1) if domaine else "",
"cpu": cpu.group(1) if cpu else "",
}
class Descente(descente.Descente):
"""Les verbes de QEMU/KVM. Le reste est dans `descente.Descente`."""
OUTIL = OUTIL
NOM_BASE = NOM_BASE
DISTRO = DISTRO
# ------------------------------------------------------------------ #
def _envoyer_cli(self, hote):
"""Pose NOTRE deploy_qemu.py sur l'hôte. Rend True s'il y est.
Refait à chaque besoin plutôt qu'une fois : /tmp est vidé au
démarrage sur bien des systèmes, et l'installation redémarre.
"""
local = os.path.join(RACINE, LOCAL_CLI)
if self.dry_run:
print(f" scp {local} <hôte>:{DISTANT_CLI}")
return True
argv = pve.ssh_argv(hote, "")[:-1] # les options, sans la commande
cible = argv[-1]
options = argv[1:-1]
res = subprocess.run(
["scp", "-q"] + options + [local, f"{cible}:{DISTANT_CLI}"],
capture_output=True,
text=True,
timeout=300,
)
if res.returncode:
self.dire(f" ✗ scp : {res.stderr.strip()[:200]}")
return False
return True
def installer(self, hote):
"""libvirt et qemu-kvm, par « --setup-host ».
Le code de retour ne prouve RIEN ici : « --setup-host » rend 0 même
quand il s'est contenté de PROGRAMMER un redémarrage. C'est l'étape
suivante — redémarrer et constater que la machine est revenue — qui
prouve quelque chose, et le contrôle de fin d'étage qui prouve que KVM
est là.
"""
if self.dry_run:
print(f" {DISTANT_CLI} --setup-host")
return True
if not self._envoyer_cli(hote):
return False
# Les listes d'abord. Sans elles, « --setup-host » échoue en zéro
# seconde sur des paquets qui existent — et le message parle de
# paquets introuvables, pas de listes vides.
code, _o = self.executer(
hote,
PREPARE_APT_CMD,
self.delai("install"),
"apt-get update",
)
if code:
self.dire(" ✗ listes apt : le verrou reste tenu")
return False
code, _o = self.executer(
hote,
f"python3 {DISTANT_CLI} --setup-host --assume-yes",
self.delai("install"),
"deploy_qemu --setup-host",
montrer=True,
)
return code == 0
def noyau_convient(self, noyau):
"""Tout noyau convient : c'est le REDÉMARRAGE qui compte, pas ce
qu'on redémarre.
Il charge les modules KVM et applique l'appartenance au groupe
libvirt, qui ne prend effet qu'à la SESSION SUIVANTE — sans lui,
virt-install retombe sur qemu:///session, où le réseau « default »
n'existe pas. Le moteur prouve déjà que la machine a vraiment
redémarré en comparant l'instant de démarrage.
"""
return bool(noyau)
def remettre_debout(self, hote):
"""libvirtd actif et le réseau « default » démarré."""
if self.dry_run:
print(" libvirtd + réseau default")
return True
# Son sous-réseau à LUI, sinon « net-start default » se heurte à
# l'adresse que son parent lui a servie.
profondeur = self.profondeur_racine + max(1, self.niveau_courant)
xml = reseau_xml(cidr_pour(profondeur))
self.executer(
hote,
"systemctl enable --now libvirtd 2>/dev/null;"
" virsh -c qemu:///system net-destroy default 2>/dev/null;"
" virsh -c qemu:///system net-undefine default 2>/dev/null;"
f" printf '%s' {shlex.quote(xml)} > /tmp/reseau.xml;"
" virsh -c qemu:///system net-define /tmp/reseau.xml;"
" virsh -c qemu:///system net-start default;"
" virsh -c qemu:///system net-autostart default; true",
self.delai("reparation"),
"libvirtd",
)
self.dire(f" réseau {cidr_pour(profondeur)}.0/24")
_c, out = self.executer(
hote, RESEAU_CMD, self.delai("controle"), "réseau"
)
vu = parse_reseau(out)
self.dire(
f" libvirtd {'actif' if vu['libvirtd'] else 'ABSENT'},"
f" réseau default {'actif' if vu['reseau'] else 'ABSENT'}"
)
return vu["libvirtd"] and vu["reseau"]
def rallumer_a_froid(self, parent, nom):
"""« virsh destroy » puis « start » : un processus QEMU neuf.
Mesuré sur la machine bloquée : à chaud elle restait 46 minutes au
même pointeur d'instruction, dans son micrologiciel ; à froid elle a
chargé son noyau en 60 à 90 secondes, trois fois de suite, à 2, 3 et
4 Go. Ce n'est donc pas la taille de la mémoire — c'est la façon de
redémarrer.
"""
if self.dry_run or not parent:
return False
self.executer(
parent,
f"virsh -c qemu:///system destroy {nom} 2>/dev/null; true",
self.delai("controle"),
"extinction",
)
code, _o = self.executer(
parent,
f"virsh -c qemu:///system start {nom}",
self.delai("controle"),
"rallumage",
)
return code == 0
def controler(self, hote):
"""CET étage peut-il héberger le suivant SANS l'émuler ?
Le contrôle qui donne son sens à la mesure. Sans lui, un étage sans
KVM ne casse pas : il bascule en émulation et continue. La descente
irait plus « profond » en mesurant tout autre chose — de la TCG
empilée, pas de la virtualisation imbriquée.
"""
if self.dry_run:
print(" /dev/kvm + nested=Y + place disque")
return True
code, out = self.executer(
hote, CONTROLE_CMD, self.delai("controle"), "kvm"
)
if code:
self.dire(" ✗ contrôle KVM illisible : rien conclu")
return False
vu = parse_controle(out)
self.dire(
f" /dev/kvm {'oui' if vu['kvm'] else 'NON'},"
f" nested {'oui' if vu['nested'] else 'NON'},"
f" {vu['disque_go']} Go libres"
)
if not vu["kvm"]:
self.dire(" ✗ pas de /dev/kvm : l'étage suivant serait ÉMULÉ")
return False
if not vu["nested"]:
self.dire(
" ✗ virtualisation imbriquée absente :"
" l'étage suivant serait ÉMULÉ"
)
return False
return True
def preparer_parent(self, parent):
"""Ce qu'il faut du parent : son réseau. C'est tout.
Rien à construire, contrairement à Proxmox, où il faut poser un pont
et un NAT dans /etc/network/interfaces : libvirt fournit déjà
« default », avec NAT, bail DHCP ET résolveur dnsmasq. Le défaut qui a
coûté cher là-bas — une VM en adresse fixe qui route mais ne résout
rien, et une installation qui meurt sur « apt update » sans que rien
ne l'explique — ne peut pas se produire ici.
"""
if self.dry_run:
print(" réseau default du parent")
return ("default",)
code, out = self.executer(
parent, RESEAU_CMD, self.delai("controle"), "réseau"
)
if code:
self.dire(" ✗ état du réseau illisible : rien conclu")
return None
vu = parse_reseau(out)
if not vu["reseau"]:
self.dire(
" ✗ le réseau « default » du parent n'est pas actif"
)
return None
return ("default",)
def attendre_adresse(self, parent, nom):
"""Le bail DHCP de l'enfant, attendu. Rend l'adresse, ou "".
ATTENDU, et non lu une fois. Constaté au troisième étage : le domaine
était créé, en type='kvm', et « domifaddr » ne rendait rien — l'invité
n'avait pas encore demandé son bail. Plus l'étage est profond, plus il
démarre lentement, et c'est justement ce qu'on mesure.
`deploy_qemu` attend lui-même l'adresse — 90 secondes par défaut — puis
rend 0 quand il ne l'a pas trouvée. Son code de sortie ne prouve donc
rien ici non plus.
"""
debut = time.time()
delai = self.delai("ssh")
while time.time() - debut < delai:
_c, sortie = self.executer(
parent,
f"virsh -c qemu:///system domifaddr {nom} --source lease",
DELAIS["controle"],
"domifaddr",
)
adresse = parse_domifaddr(sortie)
if adresse:
if time.time() - debut > 20:
self.dire(f" bail après {int(time.time() - debut)} s")
return adresse
time.sleep(15)
return ""
def creer_enfant(self, parent, niveau, res, prepare, noter=None):
"""Une VM dans le parent, par NOTRE deploy_qemu.py.
L'ordre compte, et il n'est pas celui de Proxmox. Là-bas l'adresse est
exigée AVANT la création (« --ipconfig0 ») ; ici libvirt ne la donne
qu'APRÈS le démarrage. On note donc l'identité — le nom du domaine,
qui est déterminé — avant la première commande qui peut créer quoi que
ce soit, faute de quoi une création échouée à mi-chemin laisserait une
machine que le rapport ne nomme nulle part.
"""
(reseau,) = prepare
nom = self.nom_etage(niveau)
if noter:
noter(nom)
if not self._envoyer_cli(parent):
return None, None
pub = cle_publique()
if pub and not self.dry_run:
with open(pub, encoding="utf-8") as fh:
contenu = fh.read().strip()
self.executer(
parent,
f"mkdir -p /root/.ssh && printf '%s\\n'"
f" {shlex.quote(contenu)} > {CLE_DISTANTE}",
DELAIS["controle"],
"clé",
)
creation = (
f"python3 {DISTANT_CLI} --distro {DISTRO} --name {nom}"
f" --vcpus {res['vcpu']} --memory {res['ram']}"
f" --disk-size {res['disque']}G --network network={reseau}"
f" --ssh-key {CLE_DISTANTE} --assume-yes"
)
code, _o = self.executer(
parent,
creation,
self.delai("creation"),
"deploy_qemu",
montrer=True,
)
if code and not self.dry_run:
return None, None
if self.dry_run:
return nom, "10.10.10.150"
# Le domaine est-il vraiment accéléré ? « deploy_qemu » n'échoue PAS
# quand KVM manque : il pose --virt-type qemu et continue. Un étage
# émulé fausserait toute la mesure sans rien dire.
_c, xml = self.executer(
parent,
f"virsh -c qemu:///system dumpxml {nom}",
self.delai("controle"),
"dumpxml",
)
vu = parse_domaine(xml)
if vu["type"] != "kvm":
self.dire(
f" ✗ domaine type='{vu['type'] or '?'}' : cette VM est"
" ÉMULÉE, la mesure ne voudrait rien dire"
)
return None, None
self.dire(f" domaine kvm, cpu {vu['cpu'] or '?'}")
adresse = self.attendre_adresse(parent, nom)
if not adresse:
self.dire(" ✗ créée, mais sans adresse : rien à joindre")
return None, None
self.dire(f" {nom} : {adresse}")
return nom, adresse
def detruire_une(parent_alias, identite, nom, journal):
"""Arrête puis détruit UNE VM chez son parent, par son NOM et son UUID.
Par égalité STRICTE du nom : un filtre par sous-chaîne aurait pris une
« deep-qemu-lab » de production, et « --remove-all-storage » efface un
disque pour de bon.
"""
parent = {"target": parent_alias, "sudo": "sudo ", "jump": ""}
code, out = pve.run(
parent, "virsh -c qemu:///system list --all --name", 180
)
if code:
dire(f" ✗ {parent_alias} injoignable : rien touché", journal)
return False
presents = [
ligne.strip()
for ligne in pve.strip_ssh_noise(out).splitlines()
if ligne.strip()
]
if nom not in presents:
dire(f" — {nom} : absent de {parent_alias}", journal)
return True
pve.run(parent, f"virsh -c qemu:///system destroy {nom}", 300)
code, _o = pve.run(
parent,
f"virsh -c qemu:///system undefine {nom} --nvram --remove-all-storage",
600,
)
if code:
dire(f" ✗ {nom} sur {parent_alias} : undefine a échoué", journal)
return False
dire(f" ✓ {nom} ({identite}) sur {parent_alias}", journal)
return True
FAMILLE = Famille(OUTIL, NOM_BASE, detruire_une)
def principal(argv=None):
return mener(
argv,
"Jusqu'à quel étage une QEMU dans une QEMU tient-elle ?",
FAMILLE,
Descente,
nesting.COUTS_QEMU,
)
if __name__ == "__main__":
sys.exit(principal())

1308
long_test/descente.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -127,9 +127,55 @@ host_ip() {
return 1
}
# Sans ceci, tout ce que fait fix_hosts est ANNULÉ au prochain démarrage.
# L'image cloud Debian règle « manage_etc_hosts: True » : cloud-init réécrit
# alors /etc/hosts depuis son gabarit à chaque boot, et y remet
# « 127.0.1.1 <nom> ». pmxcfs, qui cherche une adresse non-bouclage pour le
# nom d'hôte, ne démarre plus — /etc/pve n'est pas monté, « pvesm » répond
# « Connection refused », et l'écran de déploiement conclut « il manque le
# stockage ». Le vrai défaut est trois étages plus bas.
#
# Vécu, et révélé par le redémarrage désormais automatique : l'installation
# corrigeait /etc/hosts, le reboot amorçait le noyau Proxmox, et cloud-init
# défaisait la correction dans le même mouvement.
#
# Un fichier de surcharge plutôt qu'une édition de cloud.cfg : c'est la voie
# que cloud-init documente, et une mise à jour du paquet ne l'écrase pas.
freeze_cloud_hosts() {
local dossier=/etc/cloud/cloud.cfg.d
local fichier="${dossier}/99-erplibre-hosts.cfg"
[ -d /etc/cloud ] || return 0
# Sur le CONTENU et non sur l'existence : « printf … > fichier » TRONQUE
# avant d'écrire. Une coupure au mauvais moment laisse un fichier de zéro
# octet, et une garde à l'existence annonce alors « déjà gelé » pour
# toujours — cloud-init continue de remettre 127.0.1.1 à chaque
# démarrage, et le défaut redevient invisible. Une redirection est de
# toute façon idempotente : il n'y a rien à protéger d'autre.
if grep -qE "^[[:space:]]*manage_etc_hosts:[[:space:]]*false" \
"${fichier}" 2>/dev/null; then
say " cloud-init ne touche déjà plus à /etc/hosts"
return 0
fi
say " cloud-init : gel de /etc/hosts (${fichier})"
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} manage_etc_hosts: false" \
"> ${fichier}"
return 0
fi
sudo mkdir -p "${dossier}"
printf '%s\n' \
"# Posé par ERPLibre : Proxmox exige que le nom d'hôte résolve vers" \
"# une adresse ROUTABLE. cloud-init y remettait 127.0.1.1 à chaque" \
"# démarrage, et pmxcfs ne démarrait plus." \
"manage_etc_hosts: false" \
| sudo tee "${fichier}" >/dev/null
CHANGED=1
}
fix_hosts() {
local ip fqdn short
ip="$(host_ip)" || die "aucune adresse IPv4 routable : réseau absent ?"
freeze_cloud_hosts
short="$(hostname -s)"
fqdn="$(hostname -f 2>/dev/null || echo "${short}")"
[ "${fqdn}" = "${short}" ] && fqdn="${short}.local"
@ -159,6 +205,70 @@ fix_hosts() {
"« hostname --ip-address » rend « ${vu:-rien} » : le nom d'hôte ne" \
"résout toujours pas vers une adresse routable."
say " hostname --ip-address : $(printf '%s ' ${routables})"
revive_pve_services
}
# Les services de Proxmox abandonnent après cinq essais rapprochés : systemd
# marque l'unité « failed » et n'y revient JAMAIS de lui-même — « Start request
# repeated too quickly ». Or ils ont TOUS échoué pendant que /etc/hosts était
# faux. Corriger le fichier ne suffit donc pas.
#
# L'ordre compte : pve-cluster d'abord, il monte /etc/pve dont les autres
# dépendent.
#
# pvestatd n'est pas un luxe. C'est lui qui remplit « /cluster/resources » ;
# arrêté, l'hôte rend une entrée SQUELETTIQUE par VM — ni nom, ni mémoire, ni
# disque, et « status: unknown ». Le tableau de bord n'a alors aucune colonne
# vivante, et il a même pris cette entrée pour une VM disparue.
# pve-firewall n'y est PAS, et c'est délibéré. Sa configuration vit dans
# /var/lib/pve-cluster/config.db, donc elle est invisible tant que /etc/pve
# n'est pas monté — c'est-à-dire exactement dans l'état qu'on répare. Le
# démarrer, c'est appliquer des règles qu'on ne peut pas lire sur la seule
# voie d'accès à la machine : ce script tourne au bout d'un ssh, et une VM
# imbriquée n'a pas d'autre porte. Une révision adversariale l'a classé
# « isole l'hôte » par trois lentilles indépendantes.
#
# Il n'est de toute façon pas nécessaire au but : le stockage et le suivi
# demandent pve-cluster et pvestatd, l'interface web pveproxy. Le pare-feu
# repartira au prochain démarrage, quand /etc/pve sera monté à temps.
PVE_SERVICES="pve-cluster pvestatd pvedaemon pveproxy"
revive_pve_services() {
command -v systemctl >/dev/null 2>&1 || return 0
local unite etat casse=""
for unite in ${PVE_SERVICES}; do
systemctl list-unit-files "${unite}.service" >/dev/null 2>&1 || continue
etat="$(systemctl is-active "${unite}" 2>/dev/null || true)"
[ "${etat}" = "active" ] && continue
casse="${casse} ${unite}"
done
[ -n "${casse}" ] || return 0
say " services à relancer :${casse}"
if [ "${DRY}" = "1" ]; then
say " ${Yellow}[dry-run]${Color_Off} systemctl reset-failed puis" \
"start :${casse}"
return 0
fi
for unite in ${casse}; do
sudo systemctl reset-failed "${unite}" 2>/dev/null || true
sudo systemctl start "${unite}" 2>&1 || \
say " ${Yellow}⚠${Color_Off} ${unite} :" \
"journalctl -u ${unite} -n 30"
CHANGED=1
done
# Le montage n'est pas instantané : on le CONSTATE plutôt que de le
# supposer, et on le dit quand il n'arrive pas.
local i
for i in 1 2 3 4 5 6 7 8 9 10; do
[ -e /etc/pve/.version ] && break
sleep 1
done
if [ -e /etc/pve/.version ]; then
say " ${Green}✓${Color_Off} /etc/pve monté"
else
say " ${Yellow}⚠${Color_Off} /etc/pve toujours absent :" \
"journalctl -u pve-cluster -n 30"
fi
}
# --- 4. Dépôt et clé --------------------------------------------------------
@ -327,6 +437,33 @@ wait_cloud_init() {
return 0
}
# Le premier « apt update » d'une image cloud tombe sur un verrou qui n'est
# pas celui qu'on croit. Mesuré, une seconde après le premier ssh :
#
# E: Could not get lock /var/lib/apt/lists/lock.
# It is held by process 1026 (apt-get)
#
# Ce n'est pas cloud-init — « cloud-init status --wait » avait rendu la main.
# C'est apt-daily, le minuteur de Debian, qui se déclenche au démarrage. Et le
# verrou des LISTES n'est pas couvert par « DPkg::Lock::Timeout », qui ne vaut
# que pour celui de dpkg : l'attente configurée ne s'applique donc pas ici.
#
# On arrête les minuteurs, puis on RÉESSAIE — arrêter une unité n'interrompt
# pas l'apt-get déjà en vol, et cloud-init peut en avoir un autre en route.
prepare_apt() {
run sudo systemctl stop apt-daily.service apt-daily-upgrade.service \
apt-daily.timer apt-daily-upgrade.timer >/dev/null 2>&1 || true
local i
for i in $(seq 1 12); do
if apt_get update; then
return 0
fi
say " verrou apt tenu, nouvel essai dans 15 s (${i}/12)"
sleep 15
done
die "apt update impossible : le verrou des listes reste tenu."
}
install_pve() {
wait_cloud_init
preseed_debconf
@ -343,7 +480,7 @@ install_pve() {
# loin — pas même la désactivation, si elle attendait la fin.
disable_enterprise
say "\n---- apt update ----"
apt_get update
prepare_apt
# Le noyau d'abord, comme l'amont le prescrit : c'est lui qui porte les
# modules dont pve a besoin, et l'installer seul laisse une machine qui
# redémarre proprement même si la suite échoue.

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

@ -0,0 +1,364 @@
#!/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 :
* au QUATRIÈME étage, un invité large GÈLE en tout début de démarrage. Mesuré
deux fois, à douze vCPU puis à huit : même RIP à trois relevés espacés de
cinq minutes, 32 Mio lus et plus un octet — 106 minutes durant, pour le
second. Les mêmes 2 vCPU démarrent.
On avait d'abord imputé le premier gel au SURENGAGEMENT : cette VM à douze
vCPU tournait sur un hôte qui en avait deux. La seconde mesure l'a réfuté —
huit vCPU sur un parent qui en avait NEUF, charge 1,47, aucun surengagement,
et le même gel. C'est bien le nombre de vCPU de l'invité imbriqué, et non son
rapport à celui de son hôte.
Au TROISIÈME étage, 9 vCPU démarrent en 117 s. Le seuil est donc entre le
troisième et le quatrième étage ; tout étage imbriqué reste étroit ;
* cette VM-là s'arrêtait après avoir lu 33 682 432 octets — 32 Mio, soit
simplement la taille de ses fichiers d'amorçage — et le chiffre ne bougeait
pas quand on lui retirait de la mémoire. La mémoire n'était donc pas le
levier, et c'est pourquoi ce module n'en borne pas.
Une descente complète a ensuite RÉFUTÉ ce qu'on avait conclu de la première :
son quatrième étage, à 2 vCPU, a démarré, s'est installé, et a écrit des
gigaoctets. Le plafond était celui du parallélisme sous imbrication, pas celui
de l'imbrication. La profondeur RÉELLEMENT atteignable se mesure — long_test la
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. Un PLANCHER, complété par une part —
# quatre gigaoctets sur une machine de soixante, c'est 6 % laissés à l'hôte,
# et le jour où les invités touchent vraiment leur mémoire c'est l'hôte qui
# part en swap. La mesure serait alors celle du swap, pas de l'imbrication.
HOTE_RESERVE_RAM_MO = 4096
HOTE_RESERVE_PART = 8 # un huitième
HOTE_RESERVE_DISQUE_GO = 20
# Ce qu'un étage garde pour LUI, en plus de ce qu'il cède à son enfant. 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
# Ce dont le PLUS PROFOND a besoin — et c'est de là qu'on part.
#
# Le dimensionnement allait d'abord de haut en bas : chaque étage recevait tout
# ce que son parent pouvait céder. Mesuré sur une descente réelle, l'étage 4 se
# retrouvait avec 44 Go — onze millions de pages à cartographier, chaque défaut
# traversant les quatre hyperviseurs empilés. Son installation dépassait deux
# heures et demie là où l'étage 3 mettait treize minutes, et l'extrapolation
# donnait cinq ANS pour le dixième étage.
#
# On part donc du bas : le plus profond reçoit ce qu'un Proxmox de test demande
# vraiment, et chaque parent ajoute seulement son propre surcoût. Pour dix
# étages, le premier a besoin de 4 + 9×2 = 22 Go au lieu de cinquante — et
# chaque étage est PETIT, donc rapide.
PVE_RAM_CIBLE_MO = 4096
PVE_DISQUE_CIBLE_GO = 25
# En dessous, un Proxmox ne démarre pas ses démons ou n'a plus la place
# d'importer une image cloud.
# Une profondeur qu'aucun budget ne borne. Grand, mais fini : « inf » se
# propagerait dans min() et rendrait un float là où tout le reste compte des
# étages entiers.
PLAFOND_LIBRE = 10**6
RAM_MIN_MO = 2048
DISQUE_MIN_GO = 15
# Le processeur NE se dimensionne PAS depuis le bas, contrairement à la
# mémoire et au disque. Trois nombres fixes, et la mesure les impose.
#
# Une première version donnait à chaque parent un vCPU de plus qu'à son enfant,
# pour supprimer le surengagement : le plus profond deux, son parent trois, et
# ainsi de suite jusqu'à onze au premier. Elle rendait donc LARGES les étages
# du milieu — huit au quatrième, sept au cinquième. Or c'est exactement là que
# l'invité gèle : mesuré, l'étage 4 à huit vCPU n'a pas passé son amorçage en
# 106 minutes, sur un parent à neuf vCPU parfaitement sain. La règle rendait
# large ce qui doit rester étroit.
#
# Le plus profond : deux, le seul chiffre dont on ait la preuve qu'il démarre
# au quatrième étage.
VCPU_IMBRIQUE = 2
# Les étages imbriqués PEU PROFONDS : un de plus, pour héberger leur enfant
# sans être aussi étroits que lui. Deux hébergeant deux, c'est cent pour cent
# de surengagement — et l'installation de l'étage 4 dépassait alors 2 h 50
# contre 793 s pour l'étage 3.
#
# « Peu profonds », et c'est la mesure qui l'impose. Ce troisième vCPU ne coûte
# RIEN aux étages 2 et 3 — leur ssh répond en 37 s et 93 s, comme à deux vCPU —
# et il coûte tout au quatrième : 15 608 s, soit 4 h 20, contre 1 664 s à deux
# vCPU. Un seul vCPU de plus, l'amorçage multiplié par 9,4.
VCPU_INTERMEDIAIRE = 3
# Le premier étage qui doit rester au strict minimum.
#
# Amorçage du quatrième étage, mesuré : 1 664 s à 2 vCPU, 15 608 s à 3, jamais
# à 8 ni à 12 — même RIP à cinq minutes d'intervalle. Le « gel » observé à 8 et
# 12 n'est probablement pas autre chose que cette courbe poussée assez loin :
# 1 664 × 9,4 par vCPU supplémentaire dépasse vite toute patience.
#
# Aux étages 2 et 3, la même largeur ne coûte rien. Le seuil est donc là.
SEUIL_ETROIT = 4
# Le premier étage tourne sur le MÉTAL : aucun risque de gel, et son amorçage
# est rapide — onze vCPU y ont démarré en 42 s. Il n'a pourtant qu'un enfant à
# trois vCPU à servir ; quatre suffisent, et laissent la machine physique aux
# autres.
VCPU_METAL = 4
# Ce qu'on LAISSE à la machine physique. L'orchestrateur tourne dessus, son
# ssh vers chaque étage aussi, et la suite de tests avec.
#
# Un nombre fixe, et non une fraction : « la moitié des cœurs » gardait
# quatorze cœurs inutilisés sur vingt-huit, et sur une machine à deux cœurs le
# plancher qui l'accompagnait rendait un budget de deux — soit la machine
# entière, hôte compris.
#
# Deux, et pas plus : l'orchestrateur passe son temps à ATTENDRE du ssh, il ne
# calcule rien. Quatre auraient interdit toute descente sur un hôte à quatre
# cœurs, où un étage tient très bien. Sur une machine trop petite le plan rend
# franchement zéro étage plutôt que de surengager l'hôte.
HOTE_RESERVE_VCPU = 2
# Au-delà, l'imbrication n'est pas un terrain documenté par les fabricants.
# On ne refuse pas — on le DIT.
PROFONDEUR_SURE = 2
class Couts:
"""Ce que coûte UN étage de la pile qu'on empile.
Les constantes vCPU décrivent la physique de l'imbrication — la pagination
imbriquée frappe un QEMU nu comme un nœud Proxmox — et valent donc pour
toutes les piles. Ces six nombres-ci, non : cinq démons PVE demandent
2 Gio quand libvirtd seul tient dans un, et un nœud Proxmox occupe 5,6 Go
de disque contre 3 pour une Debian et qemu-kvm.
"""
def __init__(
self,
ram_par_etage,
disque_par_etage,
ram_cible,
disque_cible,
ram_min,
disque_min,
):
self.ram_par_etage = ram_par_etage
self.disque_par_etage = disque_par_etage
self.ram_cible = ram_cible
self.disque_cible = disque_cible
self.ram_min = ram_min
self.disque_min = disque_min
COUTS_PVE = Couts(
ram_par_etage=PVE_RAM_MO,
disque_par_etage=PVE_DISQUE_GO,
ram_cible=PVE_RAM_CIBLE_MO,
disque_cible=PVE_DISQUE_CIBLE_GO,
ram_min=RAM_MIN_MO,
disque_min=DISQUE_MIN_GO,
)
# Un hôte libvirt nu : libvirtd et qemu-kvm, rien d'autre. Le poste qui domine
# n'est plus le système mais l'IMAGE CLOUD que l'étage télécharge pour créer
# son enfant — d'où un disque cible qui n'est pas si petit.
#
# La mémoire a été DOUBLÉE après une descente à cinq étages : l'étage 2, avec
# 5 Go, hébergeait un invité de 4 Go et il ne lui restait que 127 Mo de libre.
# Un parent qui ne garde pas de quoi respirer sert mal son enfant, et deux
# gibioctets d'écart plutôt qu'un lui laissent cette marge.
# Le couple (6 Go de cible, 2 Go par étage) n'est pas arbitraire : il fait
# tomber le PREMIER étage d'une descente à dix sur exactement 24 Go, ce qu'une
# machine à 36 Go peut porter. Descendre plus bas au fond ne servirait à rien —
# c'est l'écart entre parent et enfant qui compte, mesuré à 127 Mo de libre
# quand il n'était que d'un gibioctet.
COUTS_QEMU = Couts(
ram_par_etage=2048,
disque_par_etage=6,
ram_cible=6144,
disque_cible=20,
ram_min=2048,
disque_min=12,
)
def nesting_plan(
profondeur: int,
cpu_hote: int,
ram_dispo_mo: int,
disque_libre_go: int,
couts: "Couts" = None,
) -> dict:
"""Les ressources de chaque étage, dimensionnées DEPUIS LE BAS.
Rend {"demandee", "atteignable", "niveaux", "arret", "plafonds"}.
`arret` nomme la ressource qui BORNE réellement la profondeur — "ram",
"disque" ou "vcpu" — et "" si la profondeur demandée tient. `plafonds`
donne les trois profondeurs, une par ressource, pour qu'on puisse voir
d'un coup ce qu'il faudrait ajouter et de combien.
Nommer la bonne, c'est le sujet : la version d'avant prenait la première
d'une chaîne figée ram > disque > vcpu, évaluée à la profondeur DEMANDÉE.
Sur une machine à deux cœurs et 20 Go, elle annonçait « manque de ram »
quand le processeur bornait à un seul étage ; l'opérateur doublait la
mémoire et n'y gagnait pas un étage.
Depuis le bas, et c'est tout le sujet. De haut en bas, chaque étage
recevait ce que son parent pouvait céder : mesuré, l'étage 4 se retrouvait
avec 44 Go de RAM et son installation dépassait deux heures et demie
contre treize minutes pour l'étage 3. Sous pagination imbriquée, un gros
invité coûte cher à cartographier, et le coût se multiplie par étage.
Le plus profond reçoit donc ce qu'un Proxmox de test demande, et chaque
parent ajoute son surcoût — rien de plus. Une descente à dix étages
demande alors 22 Go au premier au lieu de cinquante, et chaque étage est
petit.
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.
"""
couts = couts or COUTS_PVE
reserve = max(HOTE_RESERVE_RAM_MO, int(ram_dispo_mo) // HOTE_RESERVE_PART)
budget_ram = ((int(ram_dispo_mo) - reserve) // 1024) * 1024
budget_disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO
def besoin(d):
"""Ce que le PREMIER étage doit avoir pour qu'une descente de `d`
étages tienne : la cible du bas, plus un surcoût par étage au-dessus.
Le processeur n'en fait PAS partie de la même façon : il ne croît
pas avec la profondeur — il DÉCROÎT, et se stabilise à
VCPU_IMBRIQUE dès SEUIL_ETROIT. Le premier étage demande VCPU_METAL,
quelle que soit la profondeur.
"""
return (
couts.ram_cible + (d - 1) * couts.ram_par_etage,
couts.disque_cible + (d - 1) * couts.disque_par_etage,
VCPU_METAL if d > 1 else VCPU_IMBRIQUE,
)
budget_vcpu = int(cpu_hote) - HOTE_RESERVE_VCPU
# La profondeur que chaque budget permet À LUI SEUL. C'est de l'inverse de
# `besoin` : un balayage décroissant donnait le même résultat, mais son
# coût suivait la profondeur demandée — nesting_plan(10**6, …) tournait un
# million de tours pour rendre le même plan.
plafonds = {
"ram": (budget_ram - couts.ram_cible) // couts.ram_par_etage + 1,
"disque": (budget_disque - couts.disque_cible)
// couts.disque_par_etage
+ 1,
# Le processeur ne borne plus la profondeur : les étages imbriqués
# gardent une largeur fixe, seul le premier compte sur le métal. Il
# borne encore à ZÉRO une machine trop petite pour ce premier étage.
"vcpu": PLAFOND_LIBRE if budget_vcpu >= VCPU_METAL else 0,
}
plafonds = {nom: max(0, valeur) for nom, valeur in plafonds.items()}
demandee = max(0, int(profondeur))
atteignable = min(demandee, *plafonds.values())
arret = ""
if atteignable < demandee:
# La ressource qui BORNE, c'est-à-dire celle dont le plafond est le
# plus bas — pas la première d'un ordre figé. En ajouter une autre ne
# ferait pas monter la profondeur d'un seul étage.
arret = min(plafonds, key=lambda nom: (plafonds[nom], nom))
niveaux = [
{
"niveau": niveau,
# Le métal peut être large ; un étage imbriqué peu profond
# gagne son troisième vCPU pour héberger son enfant sans
# surengagement ; à partir de SEUIL_ETROIT, le strict minimum,
# parce que là ce troisième vCPU multiplie l'amorçage par 9,4.
#
# L'étage juste AU-DESSUS du seuil garde donc trois quand son
# enfant en a deux : c'est le seul endroit de la descente où le
# surengagement disparaît, et c'est celui qui compte, puisque
# l'étage 4 est le premier dont l'installation s'effondrait.
"vcpu": (
VCPU_METAL
if niveau == 1
else (
VCPU_IMBRIQUE
if niveau >= SEUIL_ETROIT
else VCPU_INTERMEDIAIRE
)
),
# Les planchers ne sont pas décoratifs : ils tiennent même si
# quelqu'un baisse une CIBLE un jour. Sans eux, ils n'étaient plus
# lus par personne et les tests qui les vérifiaient passaient
# d'eux-mêmes.
"ram": max(couts.ram_min, couts.ram_cible)
+ (atteignable - niveau) * couts.ram_par_etage,
"disque": max(couts.disque_min, couts.disque_cible)
+ (atteignable - niveau) * couts.disque_par_etage,
}
for niveau in range(1, atteignable + 1)
]
return {
"demandee": int(profondeur),
"atteignable": atteignable,
"niveaux": niveaux,
"arret": arret,
"plafonds": plafonds,
}
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 : sur la VM examinée au
quatrième étage, passer de 9 Go à 2 Go n'a rien déplacé — elle s'arrêtait
après les mêmes 32 Mio, la taille de ses fichiers d'amorçage. Douze vCPU,
en revanche, gelaient là où deux avançaient, et une descente complète a
fini par franchir cet étage à 2 vCPU. La RAM passe donc telle quelle : la
rogner ne gagnerait rien et priverait l'étage suivant, qui en a besoin
pour héberger le sien.
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

@ -20,6 +20,7 @@ fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau.
"""
from __future__ import annotations
import ipaddress
import json
import re
import shlex
@ -282,6 +283,318 @@ def parse_qm_list(text: str) -> list:
return out
# De quoi savoir POURQUOI il n'y a aucun stockage, en un aller-retour.
#
# « pvesm » ne parle qu'à travers /etc/pve, un système de fichiers monté par
# pmxcfs. pmxcfs à terre, la commande répond « Connection refused » et la liste
# est vide — l'écran conclut « il manque le stockage » alors que le défaut est
# trois étages plus bas.
CLUSTER_CHECK_CMD = (
"systemctl is-active pve-cluster 2>/dev/null || true; "
"echo '---ERPLIBRE-PVE-FS---'; "
# « .version » et non « storage.cfg » : ce dernier N'EXISTE PAS sur une
# installation neuve — Proxmox se contente alors de ses stockages par
# défaut, et « local » répond parfaitement. Le tester revenait à déclarer
# /etc/pve absent sur un hôte sain. « .version » est un fichier virtuel de
# pmxcfs : il est là si et seulement si le montage est là.
"test -e /etc/pve/.version && echo MONTE || echo ABSENT; "
"echo '---ERPLIBRE-HOSTNAME-IP---'; "
"hostname --ip-address 2>/dev/null || true"
)
def _usable_address(adresse: str) -> bool:
"""Cette adresse permet-elle à pmxcfs de s'identifier ?
Ni bouclage, ni LIEN-LOCAL. Le lien-local est le piège : mesuré,
« hostname --ip-address » peut ne rendre QUE des fe80::, et une adresse
APIPA en 169.254 passait le seul test « ne commence pas par 127. ». Dans
les deux cas pmxcfs n'a rien d'utilisable, mais le diagnostic concluait
« le nom résout vers une adresse routable » — et renvoyait vers
journalctl au lieu de /etc/hosts, sur un hôte qu'on ne peut inspecter que
par ssh."""
try:
adr = ipaddress.ip_address(adresse)
except ValueError:
return False
return not (adr.is_loopback or adr.is_link_local)
def parse_cluster_check(text: str) -> dict:
"""{"actif", "monte", "adresses", "routables", "lu"} depuis
CLUSTER_CHECK_CMD.
`routables` vide est la cause la plus fréquente : pmxcfs parcourt les
adresses du nom d'hôte jusqu'à en trouver une qui ne soit pas de
bouclage, et l'entrée « 127.0.1.1 <nom> » de l'image cloud le mène dans
le mur.
`lu` dit si la sonde a RÉPONDU — les deux sentinelles sont là. Sans lui,
un simple dépassement de délai rendait « monte: False, adresses: [] », et
l'appelant affirmait « le nom d'hôte ne résout que vers ? » sans avoir
rien mesuré. Affirmer une cause qu'on n'a pas constatée est pire que se
taire : cela envoie réécrire /etc/hosts sur une machine peut-être
saine."""
brut = strip_ssh_noise(text or "")
tete, sep1, reste = brut.partition("---ERPLIBRE-PVE-FS---")
milieu, sep2, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---")
# Filtré sur ce qu'EST une adresse, pas sur sa ponctuation. run() colle
# stderr après stdout, donc tout ce que sudo écrit atterrit dans cette
# queue — et « sudo: unable to resolve host pve: … » se produit
# précisément dans la panne qu'on diagnostique. Mesuré : l'écran affichait
# « le nom d'hôte ne résout que vers 127.0.1.1 sudo: pve: ». Il affirmait
# des adresses là où la sonde n'avait rien mesuré.
adresses = []
for jeton in queue.split():
try:
ipaddress.ip_address(jeton)
except ValueError:
continue
adresses.append(jeton)
return {
"lu": bool(sep1 and sep2),
"actif": "active" in tete and "inactive" not in tete,
"monte": "MONTE" in milieu,
"adresses": adresses,
"routables": [a for a in adresses if _usable_address(a)],
}
# Marqueur de NOTRE ligne dans /etc/hosts. Il rend la réécriture exactement
# idempotente : on retire ce qui porte la marque, puis on ajoute. Sans lui, la
# garde devait s'indexer sur l'ADRESSE — et en DHCP une adresse qui change
# ajoutait une ligne de plus à chaque passage sans retirer la précédente.
HOSTS_MARK = "erplibre-hosts"
# Services relancés par la réparation. pve-firewall n'y est PAS : sa
# configuration vit dans /var/lib/pve-cluster/config.db, invisible tant que
# /etc/pve n'est pas monté — c'est-à-dire exactement l'état qu'on répare. Le
# démarrer appliquerait des règles illisibles sur la seule voie d'accès à la
# machine.
#
# rrdcached d'abord : pve-cluster le requiert, et une limite de démarrage
# atteinte sur lui fait échouer pve-cluster sur « dependency » sans que
# reset-failed sur pve-cluster n'y change quoi que ce soit.
PVE_UNITS = ("rrdcached", "pve-cluster", "pvestatd", "pvedaemon", "pveproxy")
def ssh_server_ip(text: str) -> str:
"""Adresse de l'hôte telle que NOTRE ssh l'atteint, depuis $SSH_CONNECTION.
« client_ip client_port SERVER_ip server_port » : le troisième champ. C'est
la seule adresse dont on SAIT qu'elle mène à la machine, rebond compris.
Les candidats habituels se trompent ici. Mesuré sur une Proxmox imbriquée :
« hostname -I » rend « 10.10.10.150 10.10.20.1 », et la seconde est le pont
interne que notre propre code vient de créer. La poser dans /etc/hosts
ferait s'identifier le nœud par une adresse que personne ne joint.
"""
champs = strip_ssh_noise(text or "").split()
return champs[2] if len(champs) >= 4 and _usable_address(champs[2]) else ""
# Deux sources pour les noms, dans cet ordre. La seconde est indispensable au
# REJEU : au second passage il n'y a plus de ligne 127.0.1.1 — c'est nous qui
# l'avons retirée — et sans elle un vrai FQDN était remplacé par
# « <court>.local ». La commande n'était donc pas idempotente sur ce qu'elle
# avait elle-même préservé. Attrapé par un test qui la rejoue deux fois.
_NOMS_DEPUIS_LOOPBACK = (
r"sed -nE 's/^[[:space:]]*127\.0\.1\.1[[:space:]]+([^#]*).*$/\1/p'"
)
_NOMS_DEPUIS_MARQUE = (
r"sed -nE 's/^[^[:space:]]+[[:space:]]+([^#]*)#[[:space:]]*"
+ HOSTS_MARK
+ r"[[:space:]]*$/\1/p'"
)
# Normalise les séparateurs. L'installeur Debian écrit /etc/hosts avec des
# TABULATIONS, et le test du nom court cherchait des ESPACES : au rejeu, la
# ligne écrite gagnait un « srv » de plus.
_ROGNE = r"sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'"
def hosts_repair_cmd(ip: str) -> str:
"""UNE écriture ATOMIQUE de /etc/hosts, ou "" sans adresse utilisable.
La première version promettait « une seule commande » et n'en tenait rien :
« sed -i » puis « printf >> » sont DEUX écritures, sans set -e et sans
retour en arrière. Une attaque adversariale l'a mesuré sur trois états
réels — /etc en lecture seule, fichier rendu immuable par chattr, quota
atteint :
* sed refusé, ajout réussi -> la ligne 127.0.1.1 survit et reste PREMIÈRE,
donc gagnante, et notre ligne s'ajoute UNE FOIS PAR TENTATIVE. Le
marqueur, censé rendre l'opération idempotente, ne retirait rien puisque
c'est le sed qui portait la suppression.
* sed réussi, ajout refusé -> l'hôte n'a PLUS d'entrée pour son nom. Sur
une machine qu'on ne joint que par ssh, chaque sudo attend ensuite le
résolveur puis répond « unable to resolve host ». C'est exactement l'état
« pire qu'avant » que la docstring prétendait écarter.
Donc : on construit le fichier ENTIER dans un temporaire du même
répertoire, on vérifie ce qu'il contient, et on ne le recopie qu'ensuite.
« cat > » et non « mv » : le renommage remplace l'inode et perdrait mode,
propriétaire et contexte SELinux de /etc/hosts.
Bénéfice supplémentaire : « sed » sans -i ajoute le saut de ligne final
manquant. Sans lui, un /etc/hosts non terminé par \\n — cloud-init
« write_files » n'en met pas — voyait notre ligne se coller à la
précédente, et le nom du nœud partait sur l'adresse d'une AUTRE machine.
POSIX seulement (dash), et aucun « sudo » dedans : c'est wrap_privilege
qui porte le privilège, et sur un hôte root@ il n'enrobe rien.
"""
if not _usable_address(ip):
return ""
tmp = "/etc/hosts.erplibre.$$"
return (
"short=$(hostname -s); "
f"noms=$({_NOMS_DEPUIS_LOOPBACK} /etc/hosts | head -1 | {_ROGNE}); "
f'[ -n "$noms" ] || noms=$({_NOMS_DEPUIS_MARQUE} /etc/hosts'
f" | head -1 | {_ROGNE}); "
'[ -n "$noms" ] || noms="$short.local $short"; '
# Le nom court DOIT y être : c'est lui que pmxcfs résout. Le test se
# fait sur des séparateurs NORMALISÉS — l'installeur Debian écrit des
# tabulations, et « case " $noms " in *" $short "* » ne les voyait pas,
# d'où un « srv srv » au rejeu.
'case " $noms " in *" $short "*) ;; *) noms="$noms $short";; esac; '
# Le fichier complet d'abord, dans le MÊME répertoire : un temporaire
# ailleurs ne se recopierait pas forcément (montages séparés).
"{ "
# awk et non sed : « print » émet un saut de ligne par
# enregistrement, donc un /etc/hosts non terminé par \n est
# NORMALISÉ. sed, lui, préserve l'absence — vérifié — et notre ligne
# se collait alors à la précédente : le nom du nœud partait sur
# l'adresse d'une autre machine. mawk 1.3.4, celui de Debian, fait
# bien ce qu'on attend.
r"awk '!/^[ \t]*127\.0\.1\.1[ \t]/"
f" && !/#[ \\t]*{HOSTS_MARK}[ \\t]*$/' /etc/hosts"
f" && printf '%s\\t%s\\t# {HOSTS_MARK}\\n' {shlex.quote(ip)} \"$noms\""
f" ; }} > {tmp} || {{ rm -f {tmp}; echo HOSTS-KO; exit 0; }}; "
# On vérifie le TEMPORAIRE avant de toucher à l'original : notre ligne
# présente une seule fois, et plus aucune 127.0.1.1.
f"vu=$(sed -nE 's/^([^#[:space:]]+)[[:space:]].*#[[:space:]]*"
f"{HOSTS_MARK}[[:space:]]*$/\\1/p' {tmp}); "
f'if [ "$vu" != {shlex.quote(ip)} ] '
rf"|| grep -qE '^[[:space:]]*127\.0\.1\.1[[:space:]]' {tmp}; then "
f"rm -f {tmp}; echo HOSTS-KO; exit 0; fi; "
# La seule écriture destructive, et elle est la dernière.
f"cat {tmp} > /etc/hosts || {{ rm -f {tmp}; echo HOSTS-KO; exit 0; }}; "
f"rm -f {tmp}; echo HOSTS-OK"
)
def cloud_hosts_freeze_cmd() -> str:
"""Empêche cloud-init de réécrire /etc/hosts au prochain démarrage.
Gardé sur le CONTENU et non sur l'existence : « printf … > » TRONQUE avant
d'écrire, donc une coupure laisse zéro octet et une garde à l'existence
annonce « déjà gelé » pour toujours. Une redirection est de toute façon
idempotente : il n'y a rien d'autre à protéger.
"""
fichier = "/etc/cloud/cloud.cfg.d/99-erplibre-hosts.cfg"
return (
"[ -d /etc/cloud ] || { echo FREEZE-SANS-OBJET; exit 0; }; "
f"grep -qE '^[[:space:]]*manage_etc_hosts:[[:space:]]*false' {fichier}"
" 2>/dev/null && { echo FREEZE-DEJA; exit 0; }; "
"mkdir -p /etc/cloud/cloud.cfg.d; "
"printf '%s\\n' "
"'# Posé par ERPLibre : pmxcfs exige une adresse routable.' "
"'manage_etc_hosts: false' "
f"> {fichier} && echo FREEZE-OK || echo FREEZE-KO"
)
def pve_unit_cmd(unite: str, remonte: bool = False) -> str:
"""Relance UNE unité, sans jamais être fatale.
Par unité et non toutes ensemble : « systemctl start » BLOQUE jusqu'à
TimeoutStartSec (90 s par défaut), et cinq unités groupées dépassent le
délai de l'appel — on recevrait « timeout » sans savoir laquelle.
« restart » quand pve-cluster est ACTIF mais /etc/pve absent : le montage
FUSE est alors périmé (pmxcfs tué par l'OOM killer), et « start » sur une
unité active est un no-op qui rend 0 — la réparation ne convergeait jamais
et ne nommait rien.
Le journal accompagne un échec : c'est la seule façon de dire la cause à
quelqu'un dont le seul accès à l'hôte est cet outil.
"""
u = shlex.quote(unite)
# « active » ne prouve RIEN sur le lien à pmxcfs. Pour pve-cluster c'était
# déjà admis : actif sans /etc/pve, le montage FUSE est périmé et « start »
# est un no-op qui rend 0. Le même raisonnement vaut pour ses dépendants —
# pvestatd, pvedaemon et pveproxy tournaient pendant toute la panne, en
# échouant sur ipcc_send_rec. Les laisser en place après avoir remonté
# /etc/pve donnait une GUI qui répond « communication failure » juste
# après notre ✓. `remonte` dit que le montage était absent au diagnostic.
if unite == "pve-cluster":
actif = (
"[ -e /etc/pve/.version ] "
f'&& {{ echo "DEJA {unite}"; exit 0; }}; '
f"systemctl restart {u}"
)
elif remonte:
actif = f"systemctl restart {u}"
else:
actif = f'echo "DEJA {unite}"; exit 0'
return (
f"systemctl list-unit-files {u}.service >/dev/null 2>&1"
f' || {{ echo "SKIP {unite}"; exit 0; }}; '
f"etat=$(systemctl is-active {u} 2>/dev/null || true); "
f'if [ "$etat" = active ]; then {actif}; else '
f"systemctl reset-failed {u} 2>/dev/null || true; "
f"systemctl start {u}; fi "
f'|| {{ echo "KO {unite}"; '
f"journalctl -u {u} -n 20 --no-pager -o cat 2>/dev/null; }}"
)
def mount_wait_cmd(tours: int = 20, repos: int = 5) -> str:
"""Attend le montage de /etc/pve, puis le RECONFIRME.
En une seule commande : une boucle côté Python rouvrirait une connexion
par tour — deux poignées de main à travers un rebond, vingt fois — et si
le chemin vient d'être perdu, tous les tours rendraient « timeout » et on
accuserait pmxcfs de ce qui est une perte de contact.
Reconfirmé après une pause, parce qu'une seule observation ne prouve rien :
reset-failed vient d'effacer la limite de relance, donc un pmxcfs qui
battait repart pour une salve entière. Le voir monter puis mourir se lit
dans NRestarts, qu'on rend aussi.
"""
return (
f"i=0; while [ $i -lt {int(tours)} ]; do "
"[ -e /etc/pve/.version ] && break; sleep 1; i=$((i+1)); done; "
"if [ -e /etc/pve/.version ]; then "
f"sleep {int(repos)}; "
"if [ -e /etc/pve/.version ]; then echo MONTE; "
"else echo BATTEMENT; fi; "
"else echo ABSENT; fi; "
"printf 'NRESTARTS %s\\n' "
'"$(systemctl show -p NRestarts --value pve-cluster 2>/dev/null)"'
)
def parse_mount_wait(text: str) -> dict:
"""{"verdict": MONTE|BATTEMENT|ABSENT|INCONNU, "relances": int|None}.
INCONNU quand rien de lisible n'est revenu — délai dépassé, coupure. Ce
n'est pas « absent » : conclure « /etc/pve n'est pas monté » d'une perte
de contact envoie chercher dans journalctl une panne qui n'existe pas.
"""
brut = strip_ssh_noise(text or "")
verdict = "INCONNU"
for mot in ("BATTEMENT", "MONTE", "ABSENT"):
if mot in brut:
verdict = mot
break
trouve = re.search(r"NRESTARTS\s+(\d+)", brut)
return {
"verdict": verdict,
"relances": int(trouve.group(1)) if trouve else None,
}
def parse_storages(text: str) -> list:
"""Sortie de « pvesm status --content images » -> [{name, type, avail}]."""
out = []
@ -426,6 +739,69 @@ def pick_bridge(bridges, voulu: str = "") -> str:
INTERNAL_BRIDGE = "vmbr0"
INTERNAL_CIDR = "10.10.10.1/24"
# Le réseau interne ne peut PAS être une constante : un Proxmox dans un
# Proxmox hérite du réseau interne de son parent, et 10.10.10.1 y est
# l'adresse de sa propre PASSERELLE. La poser sur son pont rend tout le /24
# local — la passerelle devient injoignable et la machine s'isole
# instantanément, au milieu de la commande qui la configure. Vécu : « ifup »
# n'a jamais rendu la main et la VM ne répondait plus, ni en ssh ni en ping.
#
# On choisit donc un /24 que l'hôte ne connaît pas encore. La liste va du plus
# attendu au plus improbable : un parc imbriqué descend d'un cran par étage.
INTERNAL_CANDIDATES = (
"10.10.10.1/24",
"10.10.20.1/24",
"10.10.30.1/24",
"10.10.40.1/24",
"10.20.10.1/24",
"10.30.10.1/24",
"172.31.10.1/24",
"192.168.210.1/24",
)
# Tout ce que l'hôte sait déjà d'IPv4 : ses adresses ET ses routes. Les deux,
# parce qu'une route sans adresse locale suffit à créer le conflit — la route
# par défaut « via 10.10.10.1 » en est l'exemple exact.
USED_NETS_CMD = "ip -o -4 addr show; ip -4 route show"
def parse_used_nets(text: str) -> set:
"""Réseaux IPv4 lus dans la sortie de USED_NETS_CMD.
Une adresse nue compte pour un /32 : c'est honnête, et le
chevauchement avec un /24 candidat se calcule pareil. Un préfixe plus
large qu'un /24 — « 10.0.0.0/8 » — écarte donc bien tous nos candidats
en 10.x, ce qu'un test sur les trois premiers octets aurait raté."""
import ipaddress
nets = set()
motif = r"\b(\d{1,3}(?:\.\d{1,3}){3})(?:/(\d{1,2}))?\b"
for adresse, prefixe in re.findall(motif, text or ""):
try:
nets.add(
ipaddress.ip_network(
f"{adresse}/{prefixe or 32}", strict=False
)
)
except ValueError:
continue
return nets
def pick_internal_cidr(text: str, candidats=INTERNAL_CANDIDATES) -> str:
"""Le premier candidat qui ne chevauche RIEN de ce que l'hôte connaît.
Chaîne vide quand tous sont pris : le dire, plutôt que d'en écraser un.
Écraser, ici, c'est couper la seule voie d'accès à la machine."""
import ipaddress
utilises = parse_used_nets(text)
for candidat in candidats:
reseau = ipaddress.ip_network(candidat, strict=False)
if not any(reseau.overlaps(u) for u in utilises):
return candidat
return ""
def parse_bridge_config(text: str) -> dict:
"""/etc/network/interfaces -> {pont: {ports, address}}.
@ -509,7 +885,26 @@ def bridge_setup_cmds(
# Et l'erreur d'ifup n'est PAS masquée : « 2>/dev/null » cachait
# « operation failed with 'Operation not supported' » — le noyau cloud n'a
# pas le module bridge, et c'est ce qu'il fallait lire.
cmds.append(f"mkdir -p /run/network; ifup {nom} || ifreload -a")
# Et SURTOUT pas « ifreload -a » en repli : il recharge TOUTES les
# interfaces, y compris celle qui porte la session ssh, et sur une image
# cloud l'interface principale est décrite ailleurs (interfaces.d, ou
# netplan) — ifupdown2 la descend alors sans la remonter. Le repli est
# donc CHIRURGICAL : on monte le pont à la main, sans toucher à rien
# d'autre. La strophe, elle, le rend persistant au prochain démarrage.
manuel = [
f"ip link show {nom} >/dev/null 2>&1 || ip link add {nom} type bridge",
f"ip addr add {cidr} dev {nom} 2>/dev/null || true",
f"ip link set {nom} up",
]
if uplink:
regle = f"POSTROUTING -s {reseau} -o {uplink} -j MASQUERADE"
manuel.append(
f"iptables -t nat -C {regle} 2>/dev/null"
f" || iptables -t nat -A {regle}"
)
cmds.append(
f"mkdir -p /run/network; ifup {nom} || {{ " + "; ".join(manuel) + "; }"
)
return cmds

View file

@ -0,0 +1,181 @@
#!/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 `long_test/` 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 = "long_test"
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(
"Nested QEMU depth: plan only (dry-run)"
)
},
{"prompt_description": t("Nested QEMU depth: run it")},
{"prompt_description": t("Undo what the descent created")},
]
# Chaque choix : le script, et s'il faut demander d'où l'on part.
scripts = {
"1": ("deep_proxmox.py", True),
"2": ("deep_proxmox.py", True),
"3": ("deep_qemu.py", True),
"4": ("deep_qemu.py", True),
}
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
if status in scripts:
script, demander = scripts[status]
# La profondeur est DEMANDÉE : c'est le réglage qui décide de
# la durée — au-delà de trois étages, tout est 15 à 30 fois
# plus lent, et cinq se comptent en heures.
args = f"--depth {self._longtest_depth()}"
if demander:
args += self._longtest_depart(script)
if status in ("1", "3"):
args += " --dry-run"
self._longtest_run(script, args)
elif status == "5":
self._longtest_defaire()
else:
print(t("Command not found !"))
def _longtest_defaire(self):
"""Défaire, chaque pile la sienne.
Les deux scripts partagent le dossier des rapports mais chacun ne
connaît que les siens : lancer les deux ne peut pas faire détruire à
l'un ce que l'autre a créé.
Le script demande « OUI » avant de détruire, mais il LISTE d'abord :
on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne
mène pas directement à un « qm destroy --purge ».
"""
for script in ("deep_proxmox.py", "deep_qemu.py"):
self._longtest_run(script, "--detruire --dry-run")
if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")):
self._longtest_run(script, "--detruire")
def _longtest_depart(self, script):
"""D'où part la descente : une VM neuve, ou un hôte qu'on a déjà.
Créer une machine de tête pour héberger un hyperviseur qu'on possède
déjà coûte cinq minutes ET un étage d'imbrication — donc de la
lenteur, puisque c'est justement elle qu'on mesure.
L'hôte déjà retenu est proposé sans qu'on ait à le rechercher : c'est
`_pve_host(ask=False)`, qui ne demande rien et ne dit rien s'il n'y en
a pas.
"""
connu = None
if script == "deep_proxmox.py":
try:
connu = self._pve_host(ask=False)
except Exception: # noqa: BLE001 - une préférence illisible
connu = None
print(f"\n{t('Where does the descent start?')}")
print(f" [1] {t('Create a fresh QEMU VM as level one')} *")
if connu:
print(f" [2] {t('Start from:')} {self._pve_label(connu)}")
print(f" [3] {t('Start from another existing host')}")
choix = input(t("Choice (1-3, default 1): ")).strip()
if choix == "2" and connu:
return self._longtest_args_hote(connu)
if choix == "3":
hote = (
self._pve_pick_host()
if script == "deep_proxmox.py"
else self._longtest_hote_manuel()
)
if hote:
return self._longtest_args_hote(hote)
print(t("Cancelled."))
return ""
@staticmethod
def _longtest_args_hote(hote):
"""Les options que le script attend, à partir d'un dict d'hôte."""
args = f" --hote {hote['target']}"
if hote.get("jump"):
args += f" --jump {hote['jump']}"
return args
def _longtest_hote_manuel(self):
"""Un hôte libvirt de départ, saisi à la main.
Pas de sélecteur vérifié comme pour Proxmox : ce qu'on veut ici, c'est
un hôte qui porte KVM, et c'est le script qui le CONSTATE au premier
contrôle — /dev/kvm et l'imbrication — plutôt que le menu qui le
suppose.
"""
cible = input(t("Address (user@host, blank = cancel): ")).strip()
if not cible:
return None
return {
"target": cible,
"jump": input(t("SSH jump host (blank = none): ")).strip(),
}
def _longtest_depth(self):
"""Profondeur demandée. Trois par défaut, parce que trois marche.
Mesuré sur cette machine : les trois premiers étages prennent 280, 495
et 1 064 secondes — une demi-heure. Le quatrième a demandé 7 h 18
d'installation et 4 h 20 d'amorçage, et les suivants se comptent en
jours. Dix par défaut promettait ce qu'aucune machine ne tient.
"""
brut = input(f"{t('Depth (default 3): ')}").strip()
return int(brut) if brut.isdigit() and int(brut) > 0 else 3

View file

@ -343,10 +343,17 @@ class ProxmoxMenuMixin:
if not host:
return 255, ""
if not quiet:
# La forme RÉELLEMENT envoyée, enrobage sudo compris : une
# commande affichée doit pouvoir être recopiée telle quelle.
reel = pve.wrap_privilege(remote, host.get("sudo") or "")
print(f"\n{t('Will execute:')} ssh {host['target']} {reel}")
# La forme RÉELLEMENT envoyée : enrobage sudo, rebond et port
# compris. Sans « -J », la ligne copiée rendait « no route to
# host » — et c'est justement quand une étape échoue au milieu
# d'une réparation qu'on a besoin de la rejouer à la main.
argv = pve.ssh_argv(
host, pve.wrap_privilege(remote, host.get("sudo") or "")
)
print(
f"\n{t('Will execute:')} "
+ " ".join(shlex.quote(a) for a in argv)
)
code, out = pve.run(host, remote, timeout)
if out.strip() and not quiet:
print(out.rstrip())
@ -648,6 +655,248 @@ class ProxmoxMenuMixin:
parts = (sortie or "").split()
return parts[parts.index("dev") + 1] if "dev" in parts else ""
def _pve_cluster_state(self, host):
"""L'état du cluster, LU UNE FOIS, plus ce qu'on peut en faire.
Rend (etat, quoi) où `quoi` vaut "" (rien à proposer), "hosts" (le
résolveur est en cause, une réécriture est justifiée) ou "unites" (le
nom résout déjà, seules les unités sont à terre).
Séparer les deux décisions, et non les fondre dans une garde unique :
interrompue après la réécriture de /etc/hosts, la réparation laissait
un hôte à un « systemctl start » de fonctionner — et la garde d'avant,
qui sortait dès que « routables » était non vide, refusait alors de le
finir. L'outil savait exactement quoi faire et s'y refusait
définitivement.
"""
from script.proxmox import proxmox_deploy as pve
_c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40)
etat = pve.parse_cluster_check(out)
if not etat["lu"] or etat["monte"]:
return etat, ""
return etat, ("unites" if etat["routables"] else "hosts")
def _pve_cluster_reason(self, host, etat=None, quoi=None):
"""Pourquoi il n'y a AUCUN stockage. Liste vide si tout va bien.
« Il manque le stockage » est un symptôme, pas une cause : « pvesm »
ne parle qu'à travers /etc/pve, monté par pmxcfs. pmxcfs à terre, la
liste est vide et l'écran s'arrête sur le symptôme — le défaut est
trois étages plus bas, et il a fallu lire un journal pour le trouver.
`etat`/`quoi` viennent de `_pve_cluster_state` quand l'appelant l'a
déjà interrogé : une seule sonde, et surtout un seul verdict. Sondé
deux fois, on annonçait une réparation que la seconde lecture
refusait ensuite d'offrir — une promesse suivie de rien.
"""
if etat is None:
etat, quoi = self._pve_cluster_state(host)
if not etat["lu"]:
return [
f"⚠ {t('The cluster probe did not answer: cause unknown.')}"
]
if etat["monte"]:
return []
lignes = [
f"✗ {t('pve-cluster is down: /etc/pve is not mounted.')}",
f" {t('Without it pvesm answers nothing, hence no storage.')}",
]
if quoi == "hosts":
lignes += [
f" {t('The hostname only resolves to')}"
f" {' '.join(etat['adresses']) or '?'}"
f" — {t('pmxcfs needs a routable address.')}",
f" {t('cloud-init rewrites /etc/hosts at every boot.')}",
]
else:
# Le nom résout déjà : le résolveur n'est PAS en cause, et le dire
# évite d'envoyer réécrire un fichier système pour rien.
lignes.append(
f" {t('The hostname resolves fine: only the units are down.')}"
)
# La promesse UNIQUEMENT si l'offre suivra. Sinon on disait « cet écran
# peut le réparer » puis plus rien du tout.
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 "".
Lue SANS privilège, exprès : « sudo » remet l'environnement à zéro et
efface $SSH_CONNECTION. Cette lecture n'a besoin d'aucun droit.
"""
from script.proxmox import proxmox_deploy as pve
_c, out = pve.run(
dict(host, sudo=""), 'printf %s "$SSH_CONNECTION"', 20
)
return pve.ssh_server_ip(out)
def _pve_restart_units(self, remonte):
"""Relance les unités et rend (pivot_ok, lignes_de_cause).
`remonte` : /etc/pve était absent, donc les dépendants qui SEMBLENT
actifs parlaient à un pmxcfs mort. Leur état actif ne prouve rien sur
leur lien à pmxcfs — le même raisonnement qui impose un « restart » à
pve-cluster vaut pour eux, et sans cela la GUI répondait
« communication failure » après un ✓.
La sortie de chaque unité est LUE. pve-cluster est le pivot : les
trois suivantes le requièrent, donc s'il échoue, poursuivre ne produit
que soixante lignes de journal après la vraie cause.
"""
from script.proxmox import proxmox_deploy as pve
for unite in pve.PVE_UNITS:
_c, out = self._pve_show(
pve.pve_unit_cmd(unite, remonte=remonte), timeout=200
)
texte = pve.strip_ssh_noise(out)
if f"KO {unite}" in texte or f"SKIP {unite}" in texte:
if unite == "pve-cluster":
lignes = [
ligne
for ligne in texte.strip().splitlines()
if ligne.strip()
]
return False, lignes[-8:]
return True, []
def _pve_offer_cluster_fix(self, host, etat=None, quoi=None):
"""Propose de remettre pmxcfs debout, et le fait. Rend True si /etc/pve
est monté à la sortie.
Le pendant de `_pve_offer_bridge`, et pour la même raison : le terminal
est encore à nous, donc c'est ICI qu'on peut poser la question et
montrer ce qu'on exécute.
Pourquoi le faire au lieu de conseiller : le conseil était « rejouer
install_proxmox.sh sur l'hôte », et il ne pouvait PAS marcher. La VM
clone le dépôt distant, donc sa copie du script est celle du distant —
c'est-à-dire, tant que le correctif n'est pas poussé, celle qui ne
corrige rien. Trois hôtes de suite sont tombés dessus.
"""
from script.proxmox import proxmox_deploy as pve
if etat is None:
etat, quoi = self._pve_cluster_state(host)
if not quoi:
return bool(etat["monte"])
print(f"\n {t('Repair it from here?')}")
if quoi == "hosts":
print(
f" [1] {t('freeze cloud-init, fix /etc/hosts, restart pmxcfs')}"
)
else:
print(f" [1] {t('restart pmxcfs only (the address is fine)')}")
print(f" [0] {t('leave it alone')}")
if input(t("Choice: ")).strip() != "1":
return False
if quoi == "hosts":
ip = self._pve_ssh_ip(host)
if not ip:
print(
f" ✗ {t('Cannot tell which address reaches this host.')}"
)
return False
print(f" {t('address the node will answer for')} : {ip}")
# Le gel d'abord : sans lui la correction ne survit pas au
# prochain démarrage, et on aurait réparé pour une seule session.
for cmd in (
pve.cloud_hosts_freeze_cmd(),
pve.hosts_repair_cmd(ip),
):
code, sortie = self._pve_show(cmd, timeout=60)
if code or "-KO" in pve.strip_ssh_noise(sortie):
print(f" ✗ {t('Step failed, stopping here.')}")
return False
pivot, cause = self._pve_restart_units(remonte=True)
if not pivot:
print(f" ✗ pve-cluster {t('would not start:')}")
for ligne in cause:
print(f" {ligne}")
return False
_c, out = self._pve_show(pve.mount_wait_cmd(), timeout=120)
vu = pve.parse_mount_wait(out)
if vu["verdict"] == "MONTE":
print(f" ✓ /etc/pve {t('mounted')}")
return True
if vu["verdict"] == "INCONNU":
# Un silence du lien n'est PAS une absence de montage : conclure
# l'inverse envoie chercher dans journalctl une panne qui n'existe
# pas.
print(f" ⚠ {t('Cannot tell whether it mounted (link lost).')}")
return False
if vu["verdict"] == "BATTEMENT":
# Monté puis reperdu : le dire, parce qu'un ✓ suivi d'un « pvesm ne
# répond plus » dix secondes après est le pire des deux.
print(f" ⚠ /etc/pve {t('mounted then lost again')}")
else:
print(f" ✗ /etc/pve {t('still absent')}")
# L'adresse n'est mise en cause que quand pve-cluster a DÉMARRÉ et que
# le montage manque quand même : c'est le seul cas où le résolveur
# peut l'expliquer.
if quoi == "hosts":
print(
f" {t('The address written may not be the one pmxcfs needs.')}"
)
return False
def _pve_internal_cidr(self, host):
"""Réseau du futur pont interne, CHOISI d'après l'hôte.
Pas une constante : un Proxmox dans un Proxmox hérite du réseau
interne de son parent, et 10.10.10.1 y est l'adresse de sa propre
PASSERELLE. La poser sur son pont rend tout le /24 local, la
passerelle devient injoignable, et la machine s'isole au milieu de la
commande qui la configure. Vécu : « ifup » n'a jamais rendu la main et
la VM ne répondait plus, ni en ssh ni en ping."""
from script.proxmox import proxmox_deploy as pve
_c, out = pve.run(host, pve.USED_NETS_CMD, 40)
return pve.pick_internal_cidr(out)
def _pve_nat_ready(self, host):
"""(prêt ?, lignes à dire). La table NAT existe-t-elle sur cet hôte ?
@ -717,8 +966,11 @@ class ProxmoxMenuMixin:
raison = self._pve_nat_reason(host)
if raison:
return "", raison
cidr = self._pve_internal_cidr(host)
if not cidr:
return "", t("No free subnet left for an internal bridge.")
uplink = self._pve_uplink()
for cmd in pve.bridge_setup_cmds(uplink=uplink):
for cmd in pve.bridge_setup_cmds(cidr=cidr, uplink=uplink):
code, sortie = pve.run(host, cmd, 180)
if code:
lignes = pve.strip_ssh_noise(sortie).strip().splitlines()
@ -741,11 +993,20 @@ class ProxmoxMenuMixin:
"""
from script.proxmox import proxmox_deploy as pve
host = self._pve_host(ask=False)
# Le réseau est LU sur l'hôte avant d'être proposé : l'annoncer
# 10.10.10.1/24 pour en poser un autre serait mentir sur l'écran même
# où l'on demande l'accord.
cidr = self._pve_internal_cidr(host) if host else pve.INTERNAL_CIDR
print(f"\n ⚠ {t('No network bridge on this host.')}")
print(f" {t('qm create needs one. Two ways:')}")
if not cidr:
print(f" ✗ {t('No free subnet left for an internal bridge.')}")
print(f" {t('do it myself (bridge-ports <nic>, needs console)')}")
return ""
print(
f" [1] {t('create an internal')} {pve.INTERNAL_BRIDGE}"
f" ({pve.INTERNAL_CIDR}) + NAT — {t('touches no physical NIC')}"
f" ({cidr}) + NAT — {t('touches no physical NIC')}"
)
print(f" [2] {t('do it myself (bridge-ports <nic>, needs console)')}")
if input(t("Choice: ")).strip() != "1":
@ -759,7 +1020,6 @@ class ProxmoxMenuMixin:
f" ⚠ {t('This moves the host address: do it from a console.')}"
)
return ""
host = self._pve_host(ask=False)
ok, lignes = self._pve_nat_ready(host) if host else (True, [])
if not ok:
print()
@ -768,7 +1028,7 @@ class ProxmoxMenuMixin:
return ""
uplink = self._pve_uplink()
print(f" {t('uplink for NAT')} : {uplink or t('none')}")
for cmd in pve.bridge_setup_cmds(uplink=uplink):
for cmd in pve.bridge_setup_cmds(cidr=cidr, uplink=uplink):
code, _o = self._pve_show(cmd, timeout=120)
if code:
print(f" ✗ {t('Step failed, stopping here.')}")
@ -838,6 +1098,22 @@ class ProxmoxMenuMixin:
vms = self._pve_vms()
_c, out = self._pve_show("pvesm status --content images", quiet=True)
stockages = pve.parse_storages(out)
if not stockages:
# AVANT d'ouvrir l'écran : une fois Textual à l'affiche, ces
# lignes n'ont plus d'endroit où aller, l'écran ne dirait que
# « aucun stockage », et surtout il ne pourrait pas POSER la
# question — le terminal est encore à nous ici.
#
# UNE sonde, passée aux deux : sondé deux fois, on annonçait une
# réparation que la seconde lecture refusait ensuite d'offrir.
etat_pve, quoi_pve = self._pve_cluster_state(host)
for ligne in self._pve_cluster_reason(host, etat_pve, quoi_pve):
print(f" {ligne}")
if self._pve_offer_cluster_fix(host, etat_pve, quoi_pve):
_c, out = self._pve_show(
"pvesm status --content images", quiet=True
)
stockages = pve.parse_storages(out)
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
ponts = pve.parse_bridges(out)
if not ponts:
@ -852,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.
@ -889,7 +1171,13 @@ class ProxmoxMenuMixin:
# De quoi créer le pont DEPUIS l'écran, sans invite : le pont
# interne ne touche à aucune interface physique.
"make_bridge": self._pve_make_internal_bridge,
"internal_bridge": (pve.INTERNAL_BRIDGE, pve.INTERNAL_CIDR),
# Le libellé « ➕ créer un interne vmbr0 (…) » doit annoncer le
# réseau qui sera RÉELLEMENT posé — il dépend de l'hôte.
"internal_bridge": (
pve.INTERNAL_BRIDGE,
(self._pve_internal_cidr(host) if not ponts else "")
or pve.INTERNAL_CIDR,
),
"build_command": build_command,
"branches": self._qemu_branch_list() or ["master"],
# La branche du dépôt : c'est elle qu'on déploie le plus souvent.
@ -1409,6 +1697,13 @@ class ProxmoxMenuMixin:
f" ERPLibre : {spec['install'].get('label') or ''}"
f" ({spec['install'].get('branch')})"
)
# La VM CLONE la branche depuis le dépôt distant : tout ce qui
# tourne dedans — install_proxmox.sh compris — vient de là, pas
# d'ici. Un correctif non poussé est invisible pour elle.
for ligne in self._qemu_branch_gap_lines(
spec["install"].get("branch") or ""
):
print(f" {ligne}")
return self._is_yes_default_yes(
input(f"\n{t('Deploy this VM now? (Y/n): ')}")
)
@ -1654,7 +1949,20 @@ class ProxmoxMenuMixin:
pont = pve.pick_bridge(ponts)
if not stockage:
print(f"\n ✗ {t('No storage able to hold a VM disk.')}")
return
# Le symptôme ne suffit pas : dire la CAUSE quand on la connaît,
# puis proposer d'y remédier. Une seule sonde pour les deux.
etat_pve, quoi_pve = self._pve_cluster_state(host)
for ligne in self._pve_cluster_reason(host, etat_pve, quoi_pve):
print(f" {ligne}")
if not self._pve_offer_cluster_fix(host, etat_pve, quoi_pve):
return
_c, out = self._pve_show(
"pvesm status --content images", quiet=True
)
stockage = pve.pick_storage(pve.parse_storages(out))
if not stockage:
print(f" ✗ {t('No storage able to hold a VM disk.')}")
return
if not pont and not dry_run:
pont = self._pve_offer_bridge()
if not pont:

View file

@ -885,6 +885,12 @@ class QemuDeployMixin:
f" {t('Install:')} {t('branch')} {br_txt}, "
f"{t('profile')} {lb_txt}, {env}"
)
# La VM ne reçoit pas CE checkout : elle CLONE la branche depuis
# le dépôt distant. Un correctif commité ici et non poussé n'y est
# donc pas, et le défaut « revient » alors qu'il est corrigé —
# vécu deux fois de suite sur install_proxmox.sh.
for ligne in self._qemu_branch_gap_lines(br_txt):
print(f" {ligne}")
else:
print(f" {t('Install:')} {t('no')}")
flavour = spec.get("desktop")

View file

@ -1524,12 +1524,24 @@ def parse_odoo_probe(text: str) -> set:
def parse_pvestats(text: str) -> dict:
"""Sortie de PVE_STATS_CMD -> {nom: relevé}, même forme que domstats.
"""Sortie de PVE_STATS_CMD -> {VMID: relevé}, même forme que domstats.
Même forme exprès : les colonnes, le débit d'écriture et la RAM se
calculent alors sans savoir d'où vient la mesure. Une VM sur un hôte
Proxmox distant n'avait aucune de ces colonnes — elles viennent de virsh,
qui ne sait rien de cet hôte.
Par VMID et non par NOM, et c'est tout le sujet. « /cluster/resources »
est bâti par pvestatd ; celui-ci arrêté, l'hôte rend quand même une entrée
par VM, mais SQUELETTIQUE :
{"id":"qemu/100","node":"…","status":"unknown","type":"qemu",
"vmid":100}
Ni nom, ni mémoire, ni disque. Indexée par nom, cette entrée disparaissait
— la VM était donc « absente du relevé » alors que l'hôte venait de la
nommer. Trois tours plus tard : 🗑, état TERMINAL, et le suivi annonçait
« 1/1 terminées » au bout de neuf secondes sur une installation qui
tournait. Le VMID, lui, est toujours là ; c'est d'ailleurs le seul
identifiant unique d'un hôte Proxmox.
Même forme que domstats exprès : les colonnes, le débit d'écriture et la
RAM se calculent alors sans savoir d'où vient la mesure.
"""
brut, _, tailles = (text or "").partition("---ERPLIBRE-DU---")
try:
@ -1547,14 +1559,15 @@ def parse_pvestats(text: str) -> dict:
out = {}
maintenant = time.time()
for r in ressources if isinstance(ressources, list) else ():
nom = r.get("name")
if not nom:
vmid = int(r.get("vmid") or 0)
if not vmid:
continue
total = int(r.get("maxdisk") or 0)
utilise = int(r.get("disk") or 0) or occupe.get(
int(r.get("vmid") or 0), 0
)
out[nom] = {
utilise = int(r.get("disk") or 0) or occupe.get(vmid, 0)
out[vmid] = {
# Le nom reste DANS le relevé : il ne sert plus de clé, mais il
# aide à lire un journal quand les deux divergent.
"name": r.get("name") or "",
"ram_used": int(r.get("mem") or 0),
"ram_total": int(r.get("maxmem") or 0),
# Le relevé vient d'être fait : il n'est pas périmé, et c'est ce
@ -1678,11 +1691,21 @@ def _read_pvestats(vms, now=None):
# analysable. Rien d'autre, et surtout pas le code.
if _resources_parsable(sortie):
ok = True
# {VMID: relevé} -> {nom du manifeste: relevé}. La correspondance
# se fait ICI, où le manifeste est sous les yeux : lui seul dit
# quel VMID porte quel nom, et l'hôte peut très bien ne pas
# nommer ses VM (pvestatd arrêté).
releves = parse_pvestats(sortie)
ouverts = parse_odoo_probe(sortie)
for nom, rec in releves.items():
rec["odoo"] = adresses.get(nom) in ouverts
stats.update(releves)
for vm in vms or ():
pve_info = vm.get("pve") or {}
if pve_info.get("target") != target:
continue
rec = releves.get(int(pve_info.get("vmid") or 0))
if not rec:
continue
rec["odoo"] = adresses.get(vm["name"]) in ouverts
stats[vm["name"]] = rec
_PVE_CACHE.update({"at": maintenant, "stats": stats, "ok": ok})
return dict(stats), ok

View file

@ -2537,6 +2537,67 @@ class QemuManageMixin:
nom = (res.stdout or "").strip()
return "" if res.returncode or nom == "HEAD" else nom
@staticmethod
def _qemu_branch_gap(branche):
"""Combien de commits LOCAUX manquent à origin/<branche>, et lesquels.
Rend (nombre, [sujets]) — (0, []) quand il n'y a rien à dire, ou quand
la question ne se pose pas (pas de dépôt, pas de distant).
Pourquoi le déploiement s'en soucie : la VM ne reçoit PAS le checkout
d'ici, elle CLONE la branche depuis le dépôt distant. Tout ce qui
tourne dans la VM — install_proxmox.sh, les scripts d'installation, le
Makefile — vient donc de là.
Vécu deux fois de suite. Un correctif de install_proxmox.sh, commité
ici, absent du distant : chaque VM déployée ensuite recevait l'ancien
script, et le défaut « revenait » alors qu'il était corrigé. Rien ne
le disait ; il a fallu comparer les deux versions à la main.
"""
if not branche:
return 0, []
try:
res = subprocess.run(
[
"git",
"log",
"--oneline",
"--no-decorate",
f"origin/{branche}..HEAD",
],
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return 0, []
# Une branche inconnue du distant, ou aucun distant : ce n'est pas un
# écart à signaler, c'est une question qui ne se pose pas.
if res.returncode:
return 0, []
sujets = [
ligne.strip()
for ligne in (res.stdout or "").splitlines()
if ligne.strip()
]
return len(sujets), sujets
def _qemu_branch_gap_lines(self, branche, limite=3):
"""Les lignes à dire avant de déployer, ou []."""
nombre, sujets = self._qemu_branch_gap(branche)
if not nombre:
return []
lignes = [
f"⚠ {t('The VM clones')} origin/{branche}, "
f"{t('not this checkout.')}",
f" {nombre} {t('local commit(s) are missing there:')}",
]
lignes += [f" {s}" for s in sujets[:limite]]
if nombre > limite:
lignes.append(f" … {nombre - limite} {t('more')}")
lignes.append(f" → git push {t('to deploy your own work.')}")
return lignes
def _qemu_branch_list(self):
"""Branches distantes d'ERPLibre, triées. Vide si le réseau manque.

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
@ -1396,34 +1398,69 @@ class TODO(
@staticmethod
def _ssh_config_drop_hosts(content, names):
"""Retire les blocs « Host … » qui déclarent l'un de `names`.
"""Retire de ~/.ssh/config ce qui déclare l'un de `names`.
On découpe en blocs plutôt que de substituer par expression
régulière : une ligne Host peut porter PLUSIEURS noms, et il faut
alors retirer le bloc entier dès qu'un seul de ses noms est repris —
sinon le même nom se retrouverait défini deux fois, et ssh
appliquerait la première définition rencontrée."""
régulière : une ligne Host peut porter PLUSIEURS noms.
Deux règles, chacune corrigeant une perte de données CONSTATÉE dans
le fichier d'un utilisateur.
1. Seuls « Host » et « Match » clôturent un bloc. La règle d'avant —
« une ligne non indentée clôt le bloc » — prenait l'indentation
pour de la syntaxe, alors qu'elle est cosmétique dans ce format et
qu'un fichier écrit à la main s'en passe souvent. Sur un bloc au
corps non indenté, seule la ligne « Host » partait : HostName,
User, IdentityFile et « StrictHostKeyChecking no » restaient, sans
Host au-dessus, et ssh les rattachait au bloc PRÉCÉDENT. La
vérification de clé d'hôte se retrouvait désactivée sur un serveur
de production.
2. Un bloc qui déclare AUSSI des noms qu'on ne retire pas survit,
amputé de ceux-là seulement. Il partait en entier : « Host prod-db
vm-a » perdait le prod-db de l'utilisateur, et le surnom qu'on
ajoute à un bloc généré disparaissait au déploiement suivant.
La queue du bloc — lignes vides et commentaires — n'est pas emportée :
elle précède le plus souvent le bloc SUIVANT, et l'utilisateur y met
ses propres notes.
"""
drop = set(names)
out, block, block_names = [], [], set()
out, block, block_names = [], [], []
def flush():
if block and not (block_names & drop):
if not block:
return
restants = [n for n in block_names if n not in drop]
if restants == block_names:
out.extend(block)
return
fin = len(block)
while fin > 1 and (
not block[fin - 1].strip()
or block[fin - 1].lstrip().startswith("#")
):
fin -= 1
if restants:
tete = block[0]
marge = tete[: len(tete) - len(tete.lstrip())]
out.append(f"{marge}Host {' '.join(restants)}\n")
out.extend(block[1:fin])
out.extend(block[fin:])
for line in content.splitlines(keepends=True):
if re.match(r"^[ \t]*Host[ \t]+", line):
if re.match(r"^[ \t]*Host[ \t]+", line, re.I):
flush()
block = [line]
block_names = set(line.split()[1:])
block_names = line.split()[1:]
elif re.match(r"^[ \t]*Match[ \t]+", line, re.I):
# Match ouvre une section qui n'appartient à aucun Host : la
# garder telle quelle, quel que soit le sort du bloc d'avant.
flush()
block, block_names = [], []
out.append(line)
elif block:
# Une ligne non indentée et non vide clôt le bloc (Match,
# directive globale…) : elle n'appartient à personne.
if line.strip() and not line[:1].isspace():
flush()
block, block_names = [], set()
out.append(line)
else:
block.append(line)
block.append(line)
else:
out.append(line)
flush()
@ -1466,6 +1503,18 @@ class TODO(
existing = self._ssh_config_drop_hosts(
existing, names + [n for n in also_drop if n not in names]
).rstrip("\n")
if not names:
# Retirer sans réécrire est un appel légitime : les machines
# n'existent plus. Sans ce retour, un « Host » NU était écrit dans
# le ~/.ssh/config de l'utilisateur — un bloc sans nom, suivi d'un
# « HostName » vide, qui s'applique alors à rien et brouille la
# lecture du fichier.
with open(cfg, "w", encoding="utf-8") as fh:
fh.write(existing + "\n" if existing else "")
os.chmod(cfg, 0o600)
retires = ", ".join(also_drop)
print(f"🗑 {t('Removed from ~/.ssh/config:')} {retires}")
return
block = (
f"Host {' '.join(names)}\n"
f" HostName {ip}\n"
@ -1752,6 +1801,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 +4207,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 +4228,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

@ -278,6 +278,10 @@ TRANSLATIONS = {
"fr": "Ajouté à ~/.ssh/config :",
"en": "Added to ~/.ssh/config:",
},
"Removed from ~/.ssh/config:": {
"fr": "Retiré de ~/.ssh/config :",
"en": "Removed from ~/.ssh/config:",
},
"SSH address input method": {
"fr": "Méthode de saisie de l'adresse SSH",
"en": "SSH address input method",
@ -3358,6 +3362,190 @@ TRANSLATIONS = {
"fr": "Taille (+10G pour ajouter, 40G pour une cible) : ",
"en": "Size (+10G to add, 40G for a target): ",
},
"The cluster probe did not answer: cause unknown.": {
"fr": "La sonde du cluster n'a pas répondu : cause inconnue.",
"en": "The cluster probe did not answer: cause unknown.",
},
"This screen can repair it (see below).": {
"fr": "Cet écran peut le réparer (voir ci-dessous).",
"en": "This screen can repair it (see below).",
},
"Repair it from here?": {
"fr": "Le réparer d'ici ?",
"en": "Repair it from here?",
},
"freeze cloud-init, fix /etc/hosts, restart pmxcfs": {
"fr": "geler cloud-init, corriger /etc/hosts, relancer pmxcfs",
"en": "freeze cloud-init, fix /etc/hosts, restart pmxcfs",
},
"leave it alone": {
"fr": "ne rien toucher",
"en": "leave it alone",
},
"Cannot tell which address reaches this host.": {
"fr": "Impossible de savoir quelle adresse atteint cet hôte.",
"en": "Cannot tell which address reaches this host.",
},
"address the node will answer for": {
"fr": "adresse sous laquelle le nœud répondra",
"en": "address the node will answer for",
},
"mounted then lost again": {
"fr": "monté puis reperdu",
"en": "mounted then lost again",
},
"restarts": {
"fr": "relances",
"en": "restarts",
},
"mounted": {
"fr": "monté",
"en": "mounted",
},
"The hostname resolves fine: only the units are down.": {
"fr": "Le nom d'hôte résout bien : seules les unités sont à terre.",
"en": "The hostname resolves fine: only the units are down.",
},
"restart pmxcfs only (the address is fine)": {
"fr": "relancer pmxcfs seulement (l'adresse est bonne)",
"en": "restart pmxcfs only (the address is fine)",
},
"would not start:": {
"fr": "n'a pas démarré :",
"en": "would not start:",
},
"Cannot tell whether it mounted (link lost).": {
"fr": "Impossible de constater le montage (lien perdu).",
"en": "Cannot tell whether it mounted (link lost).",
},
"still absent": {
"fr": "toujours absent",
"en": "still absent",
},
"The address written may not be the one pmxcfs needs.": {
"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.",
},
"Destroy all that? (y/N): ": {
"fr": "Détruire tout cela ? (o/N) : ",
"en": "Destroy all that? (y/N): ",
},
"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:",
},
"Nested QEMU depth: plan only (dry-run)": {
"fr": "Profondeur QEMU imbriqué : le plan seulement (à blanc)",
"en": "Nested QEMU depth: plan only (dry-run)",
},
"Nested QEMU depth: run it": {
"fr": "Profondeur QEMU imbriqué : le lancer",
"en": "Nested QEMU depth: run it",
},
"Where does the descent start?": {
"fr": "D'où part la descente ?",
"en": "Where does the descent start?",
},
"Create a fresh QEMU VM as level one": {
"fr": "Créer une VM QEMU neuve comme premier étage",
"en": "Create a fresh QEMU VM as level one",
},
"Start from:": {
"fr": "Partir de :",
"en": "Start from:",
},
"Start from another existing host": {
"fr": "Partir d'un autre hôte existant",
"en": "Start from another existing host",
},
"Choice (1-3, default 1): ": {
"fr": "Choix (1-3, défaut 1) : ",
"en": "Choice (1-3, default 1): ",
},
"Address (user@host, blank = cancel): ": {
"fr": "Adresse (utilisateur@hôte, vide = annuler) : ",
"en": "Address (user@host, blank = cancel): ",
},
"Depth (default 3): ": {
"fr": "Profondeur (défaut 3) : ",
"en": "Depth (default 3): ",
},
"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.",
},
"Without it pvesm answers nothing, hence no storage.": {
"fr": "Sans lui, pvesm ne répond rien — d'où l'absence de stockage.",
"en": "Without it pvesm answers nothing, hence no storage.",
},
"The hostname only resolves to": {
"fr": "Le nom d'hôte ne résout que vers",
"en": "The hostname only resolves to",
},
"pmxcfs needs a routable address.": {
"fr": "pmxcfs exige une adresse routable.",
"en": "pmxcfs needs a routable address.",
},
"cloud-init rewrites /etc/hosts at every boot.": {
"fr": "cloud-init réécrit /etc/hosts à chaque démarrage.",
"en": "cloud-init rewrites /etc/hosts at every boot.",
},
"replay install_proxmox.sh on the host: it fixes /etc/hosts and stops cloud-init undoing it.": {
"fr": "rejouer install_proxmox.sh sur l'hôte : il corrige /etc/hosts et empêche cloud-init de le défaire.",
"en": "replay install_proxmox.sh on the host: it fixes /etc/hosts and stops cloud-init undoing it.",
},
"The VM clones": {
"fr": "La VM clone",
"en": "The VM clones",
},
"not this checkout.": {
"fr": "et non ce checkout.",
"en": "not this checkout.",
},
"local commit(s) are missing there:": {
"fr": "commit(s) local(aux) y manquent :",
"en": "local commit(s) are missing there:",
},
"to deploy your own work.": {
"fr": "pour déployer votre propre travail.",
"en": "to deploy your own work.",
},
"No storage able to hold a VM disk.": {
"fr": "Aucun stockage capable d'héberger un disque de VM.",
"en": "No storage able to hold a VM disk.",
@ -3410,6 +3598,10 @@ TRANSLATIONS = {
"fr": "Aucun noyau Proxmox installé : terminer l'installation d'abord.",
"en": "No Proxmox kernel installed: finish the install first.",
},
"No free subnet left for an internal bridge.": {
"fr": "Plus aucun réseau libre pour un pont interne.",
"en": "No free subnet left for an internal bridge.",
},
"No network bridge on this host.": {
"fr": "Aucun pont réseau sur cet hôte.",
"en": "No network bridge on this host.",

569
test/test_deep_qemu.py Normal file
View file

@ -0,0 +1,569 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le test long QEMU-dans-QEMU, et le garde qui donne un sens à sa mesure.
`deploy_qemu.py` n'échoue PAS quand KVM manque : il pose « --virt-type qemu »
et crée une VM entièrement émulée, sept minutes et demie de démarrage, sans
qu'aucun code de retour ne le dise. Une descente qui ne le vérifierait pas
irait plus « profond » en mesurant de la TCG empilée — un chiffre plus
flatteur, et faux.
Ces tests-ci ne créent aucune machine : ils lisent des sorties de `virsh` et de
`/sys` telles qu'elles arrivent vraiment, et vérifient ce qu'on en conclut.
"""
import contextlib
import io
import os
import sys
import unittest
RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, RACINE)
sys.path.insert(0, os.path.join(RACINE, "long_test"))
sys.argv = ["todo.py"]
import deep_qemu # noqa: E402
class TestLireCeQueVirshEcrit(unittest.TestCase):
"""Des sorties RÉELLES, prises sur la machine, pas inventées."""
def test_the_address_table_has_a_header_and_a_mask(self):
vrai = (
" Name MAC address Protocol Address\n"
"----------------------------------------------------------\n"
" vnet3 52:54:00:79:78:a4 ipv4 192.168.123.118/24\n"
)
self.assertEqual(deep_qemu.parse_domifaddr(vrai), "192.168.123.118")
def test_a_domain_without_a_lease_yet_gives_nothing(self):
vide = (
" Name MAC address Protocol Address\n"
"----------------------------------------------------------\n"
" vnet0 52:54:00:aa:bb:cc N/A N/A\n"
)
self.assertEqual(deep_qemu.parse_domifaddr(vide), "")
self.assertEqual(deep_qemu.parse_domifaddr(""), "")
self.assertEqual(deep_qemu.parse_domifaddr(None), "")
def test_an_emulated_domain_is_recognised(self):
emule = "<domain type='qemu' id='3'>\n <name>deep-qemu-2</name>\n"
self.assertEqual(deep_qemu.parse_domaine(emule)["type"], "qemu")
def test_an_accelerated_domain_and_its_cpu_mode(self):
vrai = (
"<domain type='kvm' id='3'>\n"
" <name>deep-qemu-2</name>\n"
" <cpu mode='host-passthrough' check='none' migratable='on'/>\n"
)
vu = deep_qemu.parse_domaine(vrai)
self.assertEqual(vu["type"], "kvm")
self.assertEqual(vu["cpu"], "host-passthrough")
def test_an_unreadable_dumpxml_claims_nothing(self):
vu = deep_qemu.parse_domaine("error: failed to get domain")
self.assertEqual(vu["type"], "")
self.assertEqual(vu["cpu"], "")
class TestUnEtageQuiNeSaitPasHeberger(unittest.TestCase):
"""Ce qui n'a pas été lu vaut NON.
Si /sys/module/kvm_amd/parameters/nested n'existe pas, c'est que le module
n'est pas chargé — et l'étage suivant serait émulé. Traiter l'absence
comme un oui rendrait le contrôle décoratif."""
def test_a_missing_nested_line_is_not_a_yes(self):
vu = deep_qemu.parse_controle("KVM=oui\nDISQUE=120G\n")
self.assertTrue(vu["kvm"])
self.assertFalse(vu["nested"])
self.assertEqual(vu["disque_go"], 120)
def test_both_spellings_of_yes_are_accepted(self):
for valeur in ("Y", "1"):
with self.subTest(valeur=valeur):
vu = deep_qemu.parse_controle(f"KVM=oui\nNESTED={valeur}\n")
self.assertTrue(vu["nested"])
def test_nested_off_is_read_as_off(self):
for valeur in ("N", "0"):
with self.subTest(valeur=valeur):
vu = deep_qemu.parse_controle(f"KVM=oui\nNESTED={valeur}\n")
self.assertFalse(vu["nested"])
def test_no_kvm_device_at_all(self):
vu = deep_qemu.parse_controle("KVM=non\nNESTED=Y\nDISQUE=50G\n")
self.assertFalse(vu["kvm"])
def test_an_empty_answer_asserts_nothing(self):
vu = deep_qemu.parse_controle("")
self.assertFalse(vu["kvm"])
self.assertFalse(vu["nested"])
self.assertEqual(vu["disque_go"], 0)
def test_the_probe_is_written_for_dash(self):
"""/bin/sh est dash sur Debian : « set -o pipefail » y répond
« Illegal option » et sort à la première ligne."""
for interdit in ("[[", "pipefail", "$(", "&&\n"):
self.assertNotIn(interdit, deep_qemu.CONTROLE_CMD, interdit)
class TestLesListesAptAvantToute(unittest.TestCase):
"""Constaté au premier lancement réel : « --setup-host » a échoué en ZÉRO
seconde sur « Unable to locate package qemu-system-x86 », alors que le
paquet existe. La VM venait de démarrer, ses listes ne portaient que
« bookworm-security », et un apt-get update les a complétées d'un coup.
Le message parlait de paquets introuvables, pas de listes vides : c'est
exactement le genre de diagnostic qui envoie chercher au mauvais endroit.
"""
def setUp(self):
self.d = deep_qemu.Descente.__new__(deep_qemu.Descente)
self.d.dry_run = False
self.d.journal = None
self.d.niveau_courant = 1
self.d._envoyer_cli = lambda hote: True
self.faits = []
def _repond(self, code_apt=0):
def executer(hote, cmd, delai, etiquette="", **k):
self.faits.append(etiquette)
if etiquette == "apt-get update":
return code_apt, ""
return 0, ""
self.d.executer = executer
def test_the_lists_are_refreshed_before_the_install(self):
self._repond()
with contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(self.d.installer({"target": "h"}))
self.assertEqual(
self.faits, ["apt-get update", "deploy_qemu --setup-host"]
)
def test_an_apt_lock_that_never_lets_go_stops_the_level(self):
"""Installer sur des listes vides donnerait « paquet introuvable » —
un diagnostic qui envoie chercher au mauvais endroit."""
self._repond(code_apt=1)
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertFalse(self.d.installer({"target": "h"}))
self.assertIn("verrou reste tenu", sortie.getvalue())
self.assertNotIn("deploy_qemu --setup-host", self.faits)
def test_the_daily_timers_are_stopped_first(self):
"""apt-daily tient le verrou des listes au premier démarrage.
install_proxmox.sh a la même parade, et pour la même raison."""
self.assertIn("apt-daily.timer", deep_qemu.PREPARE_APT_CMD)
self.assertIn("apt-daily.service", deep_qemu.PREPARE_APT_CMD)
def test_it_retries_rather_than_giving_up_at_once(self):
self.assertIn("while", deep_qemu.PREPARE_APT_CMD)
self.assertIn("sleep", deep_qemu.PREPARE_APT_CMD)
def test_the_apt_probe_is_written_for_dash(self):
for interdit in ("[[", "pipefail", "seq "):
self.assertNotIn(interdit, deep_qemu.PREPARE_APT_CMD, interdit)
class TestChaqueEtageSonSousReseau(unittest.TestCase):
"""Le « default » de libvirt sert 192.168.122.0/24 à TOUS les étages.
Constaté au premier essai réel : l'étage 2, dont l'adresse était
192.168.122.45 — servie par le « default » de son parent — a vu son propre
« net-start default » refusé net :
error: internal error: Network is already in use by interface enp1s0
Un invité qui vit DANS un réseau ne peut pas servir le même."""
def test_two_levels_never_share_a_subnet(self):
vus = [deep_qemu.cidr_pour(p) for p in range(1, 11)]
self.assertEqual(len(set(vus)), 10, vus)
def test_it_avoids_libvirts_own_and_the_hosts(self):
"""122 est celui de libvirt, 123 celui de la machine où ce test a été
écrit : tomber sur l'un ou l'autre recréerait la collision."""
for profondeur in range(1, 11):
prefixe = deep_qemu.cidr_pour(profondeur)
self.assertNotIn(prefixe, ("192.168.122", "192.168.123"))
def test_the_subnet_is_derived_not_drawn(self):
# Deux appels pour la même profondeur donnent le même : rien de tiré
# au hasard, sinon --detruire et le diagnostic ne se retrouveraient pas.
self.assertEqual(deep_qemu.cidr_pour(3), deep_qemu.cidr_pour(3))
def test_a_depth_of_zero_or_less_still_gives_a_subnet(self):
for profondeur in (0, -1):
self.assertTrue(deep_qemu.cidr_pour(profondeur).startswith("192."))
def test_the_network_xml_is_one_line(self):
"""Elle traverse deux couches de quoting pour atterrir dans dash : un
heredoc n'y survivrait pas."""
xml = deep_qemu.reseau_xml("192.168.131")
self.assertNotIn("\n", xml)
self.assertIn("<name>default</name>", xml)
self.assertIn("192.168.131.1", xml)
self.assertIn("mode='nat'", xml)
def test_the_dhcp_range_lives_in_its_own_subnet(self):
xml = deep_qemu.reseau_xml("192.168.137")
self.assertIn("start='192.168.137.10'", xml)
self.assertIn("end='192.168.137.200'", xml)
# Et la passerelle n'est pas dans la plage servie.
self.assertIn("address='192.168.137.1'", xml)
def test_the_level_redefines_before_starting(self):
"""« net-start » sur un réseau dont le sous-réseau collisionne échoue :
il faut le REDÉFINIR, pas seulement le démarrer."""
faits = []
d = deep_qemu.Descente.__new__(deep_qemu.Descente)
d.dry_run = False
d.journal = None
d.niveau_courant = 2
d.profondeur_racine = 0
def executer(hote, cmd, delai, etiquette="", **k):
faits.append(cmd)
return 0, "NET: Active: yes\nUNITE:active\n"
d.executer = executer
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertTrue(d.remettre_debout({"target": "h"}))
pose = faits[0]
self.assertLess(pose.index("net-undefine"), pose.index("net-define"))
self.assertLess(pose.index("net-define"), pose.index("net-start"))
# Le sous-réseau de CET étage, pas un autre.
self.assertIn("192.168.132", pose)
self.assertIn("192.168.132.0/24", sortie.getvalue())
def test_a_borrowed_root_shifts_every_subnet(self):
"""Partir d'une racine déjà au troisième étage : le premier enfant est
au quatrième, et doit prendre le sous-réseau du quatrième."""
faits = []
d = deep_qemu.Descente.__new__(deep_qemu.Descente)
d.dry_run = False
d.journal = None
d.niveau_courant = 1
d.profondeur_racine = 3
d.executer = lambda h, c, delai, e="", **k: (
faits.append(c) or (0, "NET: Active: yes\nUNITE:active\n")
)
with contextlib.redirect_stdout(io.StringIO()):
d.remettre_debout({"target": "h"})
self.assertIn(deep_qemu.cidr_pour(4), faits[0])
class TestLeControleArreteLaDescente(unittest.TestCase):
"""Un étage sans KVM ne casse pas : il bascule en émulation et continue.
C'est ce silence-là que le contrôle doit rompre."""
def setUp(self):
self.d = deep_qemu.Descente.__new__(deep_qemu.Descente)
self.d.dry_run = False
self.d.journal = None
self.d.niveau_courant = 2
def _repond(self, sortie, code=0):
self.d.executer = lambda h, c, delai, etiquette="", **k: (code, sortie)
def test_a_level_without_kvm_stops_the_descent(self):
self._repond("KVM=non\nNESTED=Y\nDISQUE=90G\n")
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertFalse(self.d.controler({"target": "h"}))
self.assertIn("serait ÉMULÉ", sortie.getvalue())
def test_a_level_without_nesting_stops_the_descent(self):
self._repond("KVM=oui\nNESTED=N\nDISQUE=90G\n")
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertFalse(self.d.controler({"target": "h"}))
self.assertIn("imbriquée absente", sortie.getvalue())
def test_an_unreadable_probe_concludes_nothing(self):
# Une lecture qui échoue ne dit pas « pas de KVM » : elle ne dit rien.
self._repond("", code=255)
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertFalse(self.d.controler({"target": "h"}))
self.assertIn("illisible", sortie.getvalue())
def test_a_healthy_level_passes(self):
"""Le contrôle NÉGATIF : sans lui, ce garde interdirait toute
descente."""
self._repond("KVM=oui\nNESTED=Y\nDISQUE=90G\n")
with contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(self.d.controler({"target": "h"}))
class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase):
"""« deploy_qemu » rend 0 en créant une VM émulée. La descente doit s'en
apercevoir à la création, pas après sept minutes de démarrage."""
def setUp(self):
self.d = deep_qemu.Descente.__new__(deep_qemu.Descente)
self.d.dry_run = False
self.d.journal = None
self.d.niveau_courant = 2
self.d._envoyer_cli = lambda hote: True
def _machine(self, xml, adresse="10.0.0.9"):
"""Chaque test une seule chose : l'attente du bail est bouchonnée ici,
elle a sa propre classe. Sans ce bouchon, un enfant sans adresse
faisait tourner la vraie boucle d'attente — des heures."""
def executer(hote, cmd, delai, etiquette="", **k):
if "dumpxml" in cmd:
return 0, xml
return 0, ""
self.d.executer = executer
self.d.attendre_adresse = lambda parent, nom: adresse
def test_an_emulated_child_is_refused(self):
self._machine("<domain type='qemu' id='1'><name>x</name>")
with contextlib.redirect_stdout(io.StringIO()) as sortie:
identite, adresse = self.d.creer_enfant(
{"target": "p"},
2,
{"vcpu": 2, "ram": 2048, "disque": 20},
("default",),
)
self.assertIsNone(identite)
self.assertIsNone(adresse)
self.assertIn("ÉMULÉE", sortie.getvalue())
def test_an_accelerated_child_is_kept(self):
self._machine(
"<domain type='kvm' id='1'><name>x</name>"
"<cpu mode='host-passthrough'/>"
)
with contextlib.redirect_stdout(io.StringIO()):
identite, adresse = self.d.creer_enfant(
{"target": "p"},
2,
{"vcpu": 2, "ram": 2048, "disque": 20},
("default",),
)
self.assertEqual(identite, "deep-qemu-2")
self.assertEqual(adresse, "10.0.0.9")
def test_the_identity_is_noted_before_anything_is_created(self):
"""Une création échouée à mi-chemin laisserait sinon une machine que
le rapport ne nomme nulle part — et que --detruire ne peut pas
défaire."""
vus = []
self._machine("<domain type='qemu'>")
with contextlib.redirect_stdout(io.StringIO()):
self.d.creer_enfant(
{"target": "p"},
4,
{"vcpu": 2, "ram": 2048, "disque": 20},
("default",),
noter=vus.append,
)
# Notée, alors même que la création a été REFUSÉE ensuite.
self.assertEqual(vus, ["deep-qemu-4"])
def test_a_child_without_an_address_is_refused(self):
self._machine("<domain type='kvm'>", adresse="")
with contextlib.redirect_stdout(io.StringIO()) as sortie:
identite, _a = self.d.creer_enfant(
{"target": "p"},
2,
{"vcpu": 2, "ram": 2048, "disque": 20},
("default",),
)
self.assertIsNone(identite)
self.assertIn("sans adresse", sortie.getvalue())
class TestLeBailSeFaitAttendre(unittest.TestCase):
"""Constaté au troisième étage : le domaine était créé, en type='kvm', et
« domifaddr » ne rendait rien — l'invité n'avait pas encore demandé son
bail. Plus l'étage est profond, plus il démarre lentement, et c'est
justement ce qu'on mesure.
`deploy_qemu` attend lui-même l'adresse puis rend 0 quand il ne l'a pas
trouvée : son code de sortie ne prouve rien ici non plus."""
def setUp(self):
self.d = deep_qemu.Descente.__new__(deep_qemu.Descente)
self.d.dry_run = False
self.d.journal = None
self.d.niveau_courant = 3
self.d.profondeur_racine = 0
vrai = deep_qemu.time.sleep
deep_qemu.time.sleep = lambda _s: None
self.addCleanup(setattr, deep_qemu.time, "sleep", vrai)
def test_it_retries_until_the_lease_appears(self):
tours = {"n": 0}
def executer(hote, cmd, delai, etiquette="", **k):
tours["n"] += 1
if tours["n"] < 3:
return 0, " x y N/A N/A"
return 0, " vnet0 52:54:00:aa:bb:cc ipv4 192.168.133.42/24"
self.d.executer = executer
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(
self.d.attendre_adresse({"target": "p"}, "deep-qemu-3"),
"192.168.133.42",
)
self.assertEqual(tours["n"], 3)
def test_a_single_probe_would_have_missed_it(self):
"""Le contrôle qui dit pourquoi la boucle existe : au premier tour, il
n'y a rien à lire.
Borné DANS la sonde plutôt qu'en détournant l'horloge : détourner
time.time détourne aussi celle d'unittest, et le test ne finissait
plus. Vécu il y a dix minutes.
"""
tours = {"n": 0}
def executer(hote, cmd, delai, etiquette="", **k):
tours["n"] += 1
if tours["n"] > 4:
raise AssertionError("sondé sans fin")
return 0, " x y N/A N/A"
self.d.executer = executer
with contextlib.redirect_stdout(io.StringIO()):
with self.assertRaises(AssertionError):
self.d.attendre_adresse({"target": "p"}, "deep-qemu-3")
# Plusieurs tours, pas un seul : c'est tout l'objet de la boucle.
self.assertGreater(tours["n"], 1)
def test_the_wait_grows_with_the_depth(self):
"""Le budget est celui du ssh à cet étage : un invité au quatrième
démarre des dizaines de fois plus lentement qu'au premier."""
self.d.niveau_courant = 1
court = self.d.delai("ssh")
self.d.niveau_courant = 4
self.assertGreater(self.d.delai("ssh"), court)
class TestNeDetruireQueLeSien(unittest.TestCase):
"""« virsh undefine --remove-all-storage » efface un disque pour de bon."""
def setUp(self):
self.vrai = deep_qemu.pve.run
self.addCleanup(setattr, deep_qemu.pve, "run", self.vrai)
self.lances = []
def _parent_avec(self, noms):
def faux(hote, remote, timeout=120):
self.lances.append(remote)
if "list --all --name" in remote:
return 0, "\n".join(noms) + "\n"
return 0, ""
deep_qemu.pve.run = faux
def test_a_name_that_merely_contains_ours_is_left_alone(self):
self._parent_avec(["deep-qemu-lab", "autre"])
with contextlib.redirect_stdout(io.StringIO()):
res = deep_qemu.detruire_une(
"p", "deep-qemu-2", "deep-qemu-2", None
)
self.assertTrue(res) # absente, donc rien à faire
self.assertFalse([c for c in self.lances if "undefine" in c])
def test_our_own_machine_is_stopped_then_undefined(self):
self._parent_avec(["deep-qemu-2"])
with contextlib.redirect_stdout(io.StringIO()):
res = deep_qemu.detruire_une(
"p", "deep-qemu-2", "deep-qemu-2", None
)
self.assertTrue(res)
ordre = [c for c in self.lances if "destroy" in c or "undefine" in c]
self.assertEqual(len(ordre), 2)
self.assertIn("destroy", ordre[0])
self.assertIn("--remove-all-storage", ordre[1])
def test_an_unreachable_parent_touches_nothing(self):
deep_qemu.pve.run = lambda h, r, t=120: (255, "")
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertFalse(
deep_qemu.detruire_une("p", "deep-qemu-2", "deep-qemu-2", None)
)
self.assertIn("rien touché", sortie.getvalue())
class TestLesDeuxTestsLongsSeRessemblent(unittest.TestCase):
"""Ce qui doit être identique doit l'être, et ce qui doit différer aussi."""
def setUp(self):
import deep_proxmox
self.pve, self.qemu = deep_proxmox, deep_qemu
def test_they_share_one_engine(self):
import descente
for module in (self.pve, self.qemu):
self.assertTrue(issubclass(module.Descente, descente.Descente))
def test_every_hook_is_implemented_by_both(self):
import descente
crochets = (
"preparer_parent",
"creer_enfant",
"installer",
"noyau_convient",
"remettre_debout",
"controler",
)
for module in (self.pve, self.qemu):
for crochet in crochets:
self.assertIsNot(
getattr(module.Descente, crochet),
getattr(descente.Descente, crochet),
f"{module.OUTIL} n'implémente pas {crochet}",
)
def test_they_never_share_a_name_a_tool_or_a_report(self):
self.assertNotEqual(self.pve.OUTIL, self.qemu.OUTIL)
self.assertNotEqual(self.pve.NOM_BASE, self.qemu.NOM_BASE)
self.assertNotEqual(
self.pve.FAMILLE.detruire_une, self.qemu.FAMILLE.detruire_une
)
def test_the_qemu_stack_asks_for_less_disk(self):
"""Une Debian avec qemu-kvm occupe ~3 Go là où un nœud Proxmox en
prend 5,6 — et l'image cloud que l'étage télécharge pour son enfant
pèse plus lourd que son propre système."""
from script.proxmox import nesting
pve = nesting.nesting_plan(3, 28, 39000, 150)
qemu = nesting.nesting_plan(3, 28, 39000, 150, nesting.COUTS_QEMU)
self.assertLess(
qemu["niveaux"][0]["disque"], pve["niveaux"][0]["disque"]
)
def test_every_parent_keeps_room_to_breathe(self):
"""La mémoire de la pile QEMU a été DOUBLÉE après mesure, et n'est
donc plus inférieure à celle de Proxmox : à cinq étages, l'étage 2
avait 5 Go, hébergeait un invité de 4 Go, et il ne lui restait que
127 Mo de libre. Ce qui compte n'est pas le plancher mais l'ÉCART —
un parent qui ne respire pas sert mal son enfant."""
from script.proxmox import nesting
niveaux = nesting.nesting_plan(5, 28, 39000, 200, nesting.COUTS_QEMU)[
"niveaux"
]
for parent, enfant in zip(niveaux, niveaux[1:]):
self.assertGreaterEqual(
parent["ram"] - enfant["ram"],
2048,
f"étage {parent['niveau']} n'a pas 2 Go de marge",
)
if __name__ == "__main__":
unittest.main()

View file

@ -241,8 +241,12 @@ class TestLeNoyau(unittest.TestCase):
# ne monte jamais.
montee = pve.bridge_setup_cmds("vmbr0", "10.10.10.1/24", "enp1s0")[-1]
self.assertIn("mkdir -p /run/network", montee)
# Et l'erreur d'ifup n'est plus masquée : c'est elle qui explique.
self.assertNotIn("2>/dev/null", montee)
# Et l'erreur d'IFUP n'est pas masquée : c'est elle qui explique.
# Porté sur l'appel lui-même, et non sur toute la ligne : le repli qui
# suit sonde légitimement (« ip link show », « iptables -C »), et
# interdire « 2>/dev/null » partout lui interdisait d'exister.
ifup = montee[montee.index("ifup ") :].split("||")[0]
self.assertNotIn("2>", ifup)
class TestLeDns(unittest.TestCase):
@ -673,5 +677,783 @@ class TestLaTableNat(unittest.TestCase):
self.assertIn("uname -r", pve.NAT_CHECK_CMD)
class TestLeReseauDuPontInterne(unittest.TestCase):
"""Le pont interne avait une adresse CODÉE EN DUR, 10.10.10.1/24.
Un Proxmox dans un Proxmox hérite du réseau interne de son parent : la VM
vivait en 10.10.10.152 avec 10.10.10.1 pour PASSERELLE. Lui demander de
poser 10.10.10.1/24 sur son propre pont, c'est prendre l'adresse de sa
passerelle et rendre tout le /24 local — la machine s'isole au milieu de
la commande qui la configure. Vécu : « ifup » n'a jamais rendu la main, et
la VM ne répondait plus ni en ssh ni en ping."""
IMBRIQUE = (
"2: eth0 inet 10.10.10.152/24 brd 10.10.10.255 scope global eth0\n"
"default via 10.10.10.1 dev eth0 onlink\n"
"10.10.10.0/24 dev eth0 proto kernel scope link src 10.10.10.152\n"
)
def test_a_nested_host_gets_another_subnet(self):
self.assertNotEqual(
pve.pick_internal_cidr(self.IMBRIQUE), "10.10.10.1/24"
)
self.assertEqual(
pve.pick_internal_cidr(self.IMBRIQUE), "10.10.20.1/24"
)
def test_a_fresh_host_keeps_the_usual_one(self):
vierge = "1: lo inet 127.0.0.1/8 scope host lo\n"
self.assertEqual(pve.pick_internal_cidr(vierge), "10.10.10.1/24")
def test_a_route_alone_is_enough_to_collide(self):
# Une route sans adresse locale suffit : c'est le cas exact de la
# route par défaut « via 10.10.10.1 ».
seule = "default via 10.10.10.1 dev eth0\n"
self.assertNotEqual(pve.pick_internal_cidr(seule), "10.10.10.1/24")
def test_a_supernet_rules_out_everything_under_it(self):
# « 10.0.0.0/8 » couvre tous les candidats en 10.x. Un test sur les
# trois premiers octets l'aurait raté.
choisi = pve.pick_internal_cidr("10.0.0.0/8 dev x\n")
self.assertFalse(choisi.startswith("10."), choisi)
def test_when_nothing_is_free_it_says_so(self):
tout = "\n".join(
c.replace("1/24", "0/24") for c in pve.INTERNAL_CANDIDATES
)
self.assertEqual(pve.pick_internal_cidr(tout), "")
def test_the_chosen_subnet_reaches_every_command(self):
cmds = pve.bridge_setup_cmds(cidr="10.10.20.1/24", uplink="eth0")
texte = "\n".join(cmds)
self.assertIn("address 10.10.20.1/24", texte)
self.assertIn("10.10.20.0/24", texte)
self.assertNotIn("10.10.10.", texte)
class TestLeRepliQuiNeCoupePasLaLigne(unittest.TestCase):
"""« ifreload -a » en repli rechargeait TOUTES les interfaces.
Y compris celle qui porte la session ssh — et sur une image cloud
l'interface principale est décrite ailleurs (interfaces.d, netplan), donc
ifupdown2 la descend sans la remonter. Le repli monte donc le pont à la
main, sans toucher à rien d'autre."""
def test_ifreload_is_gone(self):
texte = "\n".join(pve.bridge_setup_cmds(uplink="eth0"))
self.assertNotIn("ifreload", texte)
def test_the_fallback_builds_the_bridge_itself(self):
derniere = pve.bridge_setup_cmds(cidr="10.10.20.1/24", uplink="eth0")[
-1
]
self.assertIn("ifup vmbr0 ||", derniere)
self.assertIn("ip link add vmbr0 type bridge", derniere)
self.assertIn("ip addr add 10.10.20.1/24 dev vmbr0", derniere)
self.assertIn("ip link set vmbr0 up", derniere)
def test_the_masquerade_rule_is_idempotent(self):
# « -C » avant « -A » : rejouée, la commande n'empile pas les règles.
derniere = pve.bridge_setup_cmds(uplink="eth0")[-1]
self.assertIn("iptables -t nat -C POSTROUTING", derniere)
self.assertLess(
derniere.index("-t nat -C"), derniere.index("-t nat -A")
)
def test_the_fallback_is_valid_shell(self):
"""Exécuté pour de vrai, ip/iptables/ifup bouchonnés.
Un repli qu'on ne sait pas exécuter s'ouvre le jour où il casse — et
celui-là tourne sur une machine qu'on ne peut plus joindre s'il rate.
"""
import subprocess
derniere = pve.bridge_setup_cmds(cidr="10.10.20.1/24", uplink="eth0")[
-1
]
bouchons = (
'ip() { [ "$1 $2" = "link show" ] && return 1; return 0; }\n'
"iptables() { return 1; }\n"
"ifup() { return 1; }\n"
"mkdir() { :; }\n"
)
res = subprocess.run(
["bash", "-c", bouchons + derniere],
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(res.stderr, "", res.stderr)
class TestPourquoiAucunStockage(unittest.TestCase):
"""« Il manque le stockage » est un symptôme, pas une cause.
« pvesm » ne parle qu'à travers /etc/pve, monté par pmxcfs. pmxcfs à
terre, la commande répond « Connection refused », la liste est vide, et
l'écran s'arrête sur le symptôme — le défaut est trois étages plus bas.
Vécu sur un Proxmox imbriqué : le nom d'hôte ne résolvait que vers
127.0.1.1, parce que cloud-init réécrit /etc/hosts à CHAQUE démarrage. Le
redémarrage désormais automatique défaisait donc la correction que
l'installation venait de poser."""
def _sortie(self, actif, monte, adresses):
return (
f"{'active' if actif else 'inactive'}\n"
"---ERPLIBRE-PVE-FS---\n"
f"{'MONTE' if monte else 'ABSENT'}\n"
"---ERPLIBRE-HOSTNAME-IP---\n"
f"{' '.join(adresses)}\n"
)
def test_a_healthy_host(self):
lu = pve.parse_cluster_check(
self._sortie(True, True, ["10.10.10.152"])
)
self.assertTrue(lu["monte"])
self.assertEqual(lu["routables"], ["10.10.10.152"])
def test_a_probe_that_did_not_answer_says_so(self):
"""« La sonde n'a pas répondu » n'est PAS « rien n'est monté ».
Un dépassement de délai — hostname bloqué sur un DNS injoignable —
rend les mêmes vides. On affirmait alors « le nom ne résout que vers
? » sans avoir rien mesuré, ce qui envoyait réécrire /etc/hosts sur
une machine peut-être saine."""
self.assertFalse(pve.parse_cluster_check("timeout")["lu"])
self.assertFalse(pve.parse_cluster_check("")["lu"])
self.assertTrue(
pve.parse_cluster_check(self._sortie(True, True, ["10.0.0.1"]))[
"lu"
]
)
def test_a_link_local_address_is_not_routable(self):
"""Mesuré : « hostname --ip-address » peut ne rendre QUE des fe80::.
Le seul test « ne commence pas par 127. » les prenait pour routables,
et une APIPA en 169.254 aussi. pmxcfs n'a alors rien d'utilisable,
mais le diagnostic concluait l'inverse — et renvoyait vers journalctl
au lieu de /etc/hosts."""
for adresses in (
["fe80::5054:ff:fecf:bba9", "fe80::fc54:ff:fe79:78a4"],
["169.254.3.4"],
["127.0.1.1"],
):
with self.subTest(adresses=adresses):
lu = pve.parse_cluster_check(
self._sortie(False, False, adresses)
)
self.assertEqual(lu["routables"], [])
self.assertEqual(lu["adresses"], adresses)
def test_a_real_address_among_link_locals_still_counts(self):
lu = pve.parse_cluster_check(
self._sortie(True, True, ["10.10.10.152", "fe80::1"])
)
self.assertEqual(lu["routables"], ["10.10.10.152"])
def test_the_loopback_only_case(self):
lu = pve.parse_cluster_check(self._sortie(False, False, ["127.0.1.1"]))
self.assertFalse(lu["monte"])
self.assertEqual(lu["routables"], [])
self.assertEqual(lu["adresses"], ["127.0.1.1"])
def test_the_probe_does_not_ask_for_storage_cfg(self):
"""storage.cfg N'EXISTE PAS sur une installation neuve.
Proxmox se contente alors de ses stockages par défaut, et « local »
répond parfaitement — mesuré sur l'hôte imbriqué, où /etc/pve était
monté sans ce fichier. Le tester revenait à déclarer /etc/pve absent
sur un hôte sain."""
self.assertNotIn("storage.cfg", pve.CLUSTER_CHECK_CMD)
self.assertIn("/etc/pve/.version", pve.CLUSTER_CHECK_CMD)
def test_inactive_is_not_read_as_active(self):
# « inactive » contient « active » : la naïveté coûterait un
# diagnostic inversé.
lu = pve.parse_cluster_check(self._sortie(False, False, []))
self.assertFalse(lu["actif"])
class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase):
"""Deux gestes que l'installation ne faisait pas, et sans lesquels elle
laissait un hôte inutilisable."""
@classmethod
def setUpClass(cls):
from pathlib import Path as P
cls.src = P("script/proxmox/install_proxmox.sh").read_text(
encoding="utf-8"
)
def test_cloud_init_stops_rewriting_etc_hosts(self):
# Sans ce gel, tout ce que fait fix_hosts est ANNULÉ au prochain
# démarrage — celui que nous déclenchons nous-mêmes désormais.
self.assertIn("manage_etc_hosts: false", self.src)
self.assertIn("/etc/cloud/cloud.cfg.d", self.src)
self.assertIn("freeze_cloud_hosts", self.src)
def test_every_failed_pve_service_is_revived(self):
"""systemd marque l'unité « failed » après cinq essais rapprochés et
n'y revient jamais seul : corriger /etc/hosts ne suffit pas.
Et ils ont TOUS échoué pendant que le fichier était faux — le journal
de pvestatd le dit mot pour mot : « ipcc_send_rec failed: Connection
refused », c'est-à-dire pve-cluster absent. Relancer le seul
pve-cluster laissait pvestatd mort, donc un hôte qui ne nomme même pas
ses VM."""
self.assertIn("reset-failed", self.src)
for unite in ("pve-cluster", "pvestatd", "pvedaemon", "pveproxy"):
self.assertIn(unite, self.src, unite)
def test_pve_cluster_comes_first(self):
# Il monte /etc/pve, dont les autres dépendent.
import re
m = re.search(r'PVE_SERVICES="([^"]+)"', self.src)
self.assertIsNotNone(m)
self.assertEqual(m.group(1).split()[0], "pve-cluster")
def test_the_firewall_is_never_started_from_outside(self):
"""Le seul constat que trois lentilles ont trouvé indépendamment.
La configuration de pve-firewall vit dans
/var/lib/pve-cluster/config.db : elle est donc INVISIBLE tant que
/etc/pve n'est pas monté — c'est-à-dire exactement dans l'état qu'on
répare. Le démarrer, c'est appliquer des règles qu'on ne peut pas lire
sur la seule voie d'accès à la machine ; ce script tourne au bout d'un
ssh, et une VM imbriquée n'a pas d'autre porte.
Il n'est pas nécessaire au but : le stockage et le suivi demandent
pve-cluster et pvestatd, l'interface web pveproxy."""
import re
m = re.search(r'PVE_SERVICES="([^"]+)"', self.src)
self.assertIsNotNone(m)
self.assertNotIn("pve-firewall", m.group(1).split())
def test_the_freeze_is_guarded_on_content(self):
"""« printf … > fichier » TRONQUE avant d'écrire.
Une coupure au mauvais moment laisse zéro octet, et une garde à
l'EXISTENCE annonce « déjà gelé » pour toujours : cloud-init continue
de remettre 127.0.1.1 à chaque démarrage et le défaut redevient
invisible."""
bloc = self.src[self.src.index("freeze_cloud_hosts() {") :]
bloc = bloc[: bloc.index("\nfix_hosts()")]
self.assertIn("manage_etc_hosts:[[:space:]]*false", bloc)
self.assertNotIn('[ -f "${fichier}" ]', bloc)
def test_the_first_apt_survives_the_boot_time_lock(self):
"""Mesuré une seconde après le premier ssh d'une image cloud :
E: Could not get lock /var/lib/apt/lists/lock.
It is held by process 1026 (apt-get)
Ce n'est pas cloud-init — « status --wait » avait rendu la main. C'est
apt-daily, qui se déclenche au démarrage. Et le verrou des LISTES
n'est pas couvert par « DPkg::Lock::Timeout », qui ne vaut que pour
celui de dpkg."""
self.assertIn("prepare_apt", self.src)
bloc = self.src[self.src.index("prepare_apt() {") :]
bloc = bloc[: bloc.index("\ninstall_pve()")]
self.assertIn("apt-daily", bloc)
# Arrêter le minuteur n'interrompt pas l'apt-get déjà en vol : il faut
# RÉESSAYER, pas seulement stopper.
self.assertIn("for i in", bloc)
self.assertIn("nouvel essai", bloc)
def test_the_retry_loop_really_retries(self):
"""Exécutée, apt_get bouchonné : elle doit insister puis rendre 0."""
import re
import subprocess
fonction = re.search(
r"^prepare_apt\(\) \{.*?^\}", self.src, re.M | re.S
)
self.assertIsNotNone(fonction)
shell = (
"say() { :; }; die() { exit 9; }; run() { :; }; sudo() { :; }; "
"sleep() { :; }; N=0; "
"apt_get() { N=$((N+1)); [ $N -ge 3 ] && return 0 || return 100; };"
+ fonction.group(0)
+ '\nprepare_apt && echo "ESSAIS $N"'
)
res = subprocess.run(
["bash", "-c", shell], capture_output=True, text=True, timeout=60
)
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("ESSAIS 3", res.stdout)
def test_the_retry_loop_gives_up_loudly(self):
# Une boucle qui abandonne en silence laisserait « apt update » échoué
# passer pour un succès.
import re
import subprocess
fonction = re.search(
r"^prepare_apt\(\) \{.*?^\}", self.src, re.M | re.S
)
shell = (
"say() { :; }; die() { echo ABANDON; exit 9; }; run() { :; }; "
"sudo() { :; }; sleep() { :; }; apt_get() { return 100; };"
+ fonction.group(0)
+ "\nprepare_apt"
)
res = subprocess.run(
["bash", "-c", shell], capture_output=True, text=True, timeout=60
)
self.assertEqual(res.returncode, 9)
self.assertIn("ABANDON", res.stdout)
def test_the_mount_is_verified_not_assumed(self):
self.assertIn("/etc/pve/.version", self.src)
def test_the_script_is_valid_shell(self):
import subprocess
res = subprocess.run(
["bash", "-n", "script/proxmox/install_proxmox.sh"],
capture_output=True,
text=True,
)
self.assertEqual(res.returncode, 0, res.stderr)
class TestReparerEtcHosts(unittest.TestCase):
"""La réécriture de /etc/hosts, EXÉCUTÉE sur de faux fichiers.
Trois hôtes de suite sont tombés sur la même panne, et le conseil
« rejouer install_proxmox.sh » ne pouvait pas la corriger : la VM clone le
dépôt distant, donc sa copie du script est celle qui ne corrige rien.
L'outil répare donc lui-même — et une réécriture de /etc/hosts sur une
machine qu'on ne joint que par ssh doit être ÉPROUVÉE, pas relue.
Aucun bouchon de vérification ici : la commande relit elle-même ce qu'elle
a écrit. La première version s'en remettait à « getent hosts $short », qui
réussit via mDNS même quand rien n'a été écrit — et les tests bouchonnaient
getent à « return 0 », donc ils mesuraient le bouchon."""
def _joue(self, contenu, court="pve", passages=3, ecrivable=True):
"""Rejoue la commande RÉELLE `passages` fois sur un faux /etc/hosts."""
import os
import subprocess
import tempfile
d = tempfile.mkdtemp()
hosts = os.path.join(d, "hosts")
with open(hosts, "w", encoding="utf-8") as fh:
fh.write(contenu)
cmd = pve.hosts_repair_cmd("10.10.10.150").replace("/etc/hosts", hosts)
if not ecrivable:
os.chmod(d, 0o500)
verdicts = []
try:
for _ in range(passages):
res = subprocess.run(
["sh", "-c", f"hostname() {{ echo {court}; }}; " + cmd],
capture_output=True,
text=True,
)
verdicts.append(res.stdout.strip())
finally:
os.chmod(d, 0o700)
with open(hosts, encoding="utf-8") as fh:
brut = fh.read()
restes = [f for f in os.listdir(d) if f != "hosts"]
return {
"lignes": [ligne for ligne in brut.splitlines() if ligne.strip()],
"verdicts": verdicts,
"brut": brut,
"restes": restes,
}
def test_the_cloud_init_line_is_replaced(self):
vu = self._joue("127.0.1.1 pve pve\n127.0.0.1 localhost\n")
self.assertEqual(vu["verdicts"], ["HOSTS-OK"] * 3)
self.assertIn("10.10.10.150\tpve pve\t# erplibre-hosts", vu["lignes"])
self.assertFalse(
[ligne for ligne in vu["lignes"] if ligne.startswith("127.0.1.1")]
)
def test_a_refused_write_leaves_the_file_ALONE(self):
"""Le constat le plus grave de l'attaque, mesuré sur trois états
réels : /etc en lecture seule, fichier immuable, quota atteint.
« sed -i » puis « printf >> » étaient DEUX écritures. Sed refusé et
ajout réussi, la ligne 127.0.1.1 survivait EN PREMIER et notre ligne
s'ajoutait une fois par tentative. Sed réussi et ajout refusé, l'hôte
perdait l'entrée de son nom — et sur une machine qu'on ne joint que
par ssh, chaque sudo attend ensuite le résolveur.
Une seule écriture, la dernière, et elle est vérifiée avant."""
vu = self._joue(
"127.0.1.1 pve.lan pve\n127.0.0.1 localhost\n", ecrivable=False
)
self.assertEqual(vu["verdicts"], ["HOSTS-KO"] * 3)
self.assertEqual(
vu["lignes"], ["127.0.1.1 pve.lan pve", "127.0.0.1 localhost"]
)
self.assertEqual(vu["restes"], [], "aucun temporaire ne doit rester")
def test_a_file_without_a_final_newline(self):
"""cloud-init « write_files » n'en met pas.
sed PRÉSERVE l'absence — vérifié — et notre ligne se collait à la
précédente : « 192.168.1.9 autre-machine10.10.10.150 pve », donc le
nom du nœud résolvait vers l'adresse d'une AUTRE machine. awk émet un
saut de ligne par enregistrement, donc il normalise."""
vu = self._joue(
"127.0.0.1 localhost\n127.0.1.1 pve\n192.168.1.9 autre-machine"
)
self.assertEqual(vu["verdicts"], ["HOSTS-OK"] * 3)
self.assertIn("192.168.1.9 autre-machine", vu["lignes"])
self.assertIn("10.10.10.150\tpve\t# erplibre-hosts", vu["lignes"])
self.assertTrue(vu["brut"].endswith("\n"))
def test_a_real_fqdn_survives_every_pass(self):
"""Le défaut que le TROISIÈME passage a révélé.
Rejouée, la commande ne trouve plus de ligne 127.0.1.1 — c'est elle
qui l'a retirée — et retombait sur « <court>.local ». Un vrai FQDN
était donc remplacé par un nom réservé au mDNS, au deuxième passage,
par la réparation elle-même."""
vu = self._joue(
"127.0.1.1\tpve.lan.example.com pve\n127.0.0.1 localhost\n"
)
self.assertIn(
"10.10.10.150\tpve.lan.example.com pve\t# erplibre-hosts",
vu["lignes"],
)
self.assertNotIn("pve.local", " ".join(vu["lignes"]))
def test_nothing_accumulates(self):
# En DHCP l'adresse change : sans marqueur, une ligne s'ajoutait à
# chaque passage sans que la précédente soit retirée.
for contenu in (
"127.0.1.1 pve pve\n",
"10.0.0.9\tpve.lan.example.com pve\t# erplibre-hosts\n",
):
with self.subTest(depart=contenu.strip()):
vu = self._joue(contenu, passages=4)
marquees = [
ligne
for ligne in vu["lignes"]
if "erplibre-hosts" in ligne
]
self.assertEqual(len(marquees), 1, vu["lignes"])
def test_tabs_everywhere_do_not_duplicate_the_short_name(self):
"""L'installeur Debian écrit /etc/hosts avec des TABULATIONS.
Le test du nom court cherchait des ESPACES : « pve.example.com\tpve »
ne contenait pas « pve » entouré d'espaces, et le rejeu écrivait
« pve.example.com pve pve »."""
vu = self._joue(
"127.0.0.1\tlocalhost\n127.0.1.1\tpve.example.com\tpve\n"
)
self.assertIn(
"10.10.10.150\tpve.example.com pve\t# erplibre-hosts",
vu["lignes"],
)
def test_a_trailing_comment_is_stripped(self):
vu = self._joue("127.0.1.1 pve # posé à la main\n")
self.assertEqual(vu["lignes"], ["10.10.10.150\tpve\t# erplibre-hosts"])
def test_the_short_name_is_always_there(self):
# C'est lui que pmxcfs résout : une ligne sans lui ne sert à rien.
vu = self._joue("127.0.1.1 autre-nom\n", court="pve")
self.assertIn("pve", vu["lignes"][0].split())
def test_an_unusable_address_produces_no_command(self):
for mauvaise in (
"",
"127.0.0.1",
"fe80::1",
"169.254.3.4",
"pas-une-ip",
):
with self.subTest(ip=mauvaise):
self.assertEqual(pve.hosts_repair_cmd(mauvaise), "")
def test_no_sudo_in_the_body(self):
"""wrap_privilege porte le privilège, pas le corps.
Sur un hôte root@ il n'enrobe rien — et un Proxmox installé par l'ISO
n'a pas forcément le paquet sudo : « sh: 1: sudo: not found », code
127, au milieu d'une réécriture de /etc/hosts."""
for cmd in (
pve.hosts_repair_cmd("10.0.0.1"),
pve.cloud_hosts_freeze_cmd(),
pve.mount_wait_cmd(),
) + tuple(pve.pve_unit_cmd(u) for u in pve.PVE_UNITS):
with self.subTest(cmd=cmd[:40]):
self.assertNotIn("sudo", cmd)
class TestGelerCloudInit(unittest.TestCase):
"""Le gel EXÉCUTÉ, y compris sur le fichier tronqué à zéro octet."""
def _joue(self, etat):
import os
import subprocess
import tempfile
racine = tempfile.mkdtemp()
dossier = os.path.join(racine, "cloud.cfg.d")
fichier = os.path.join(dossier, "99-erplibre-hosts.cfg")
if etat != "sans-cloud":
os.makedirs(dossier)
if etat == "vide":
open(fichier, "w").close()
elif etat == "gele":
with open(fichier, "w", encoding="utf-8") as fh:
fh.write("manage_etc_hosts: false\n")
cmd = (
pve.cloud_hosts_freeze_cmd()
.replace("/etc/cloud/cloud.cfg.d", dossier)
.replace(
"/etc/cloud", racine if etat != "sans-cloud" else "/nexistepas"
)
)
res = subprocess.run(["sh", "-c", cmd], capture_output=True, text=True)
contenu = ""
if os.path.exists(fichier):
with open(fichier, encoding="utf-8") as fh:
contenu = fh.read()
return res.stdout.strip(), contenu
def test_a_fresh_host_gets_frozen(self):
verdict, contenu = self._joue("neuf")
self.assertEqual(verdict, "FREEZE-OK")
self.assertIn("manage_etc_hosts: false", contenu)
def test_an_empty_file_is_rewritten(self):
"""Le défaut que la garde à l'EXISTENCE laissait passer.
« printf … > » TRONQUE avant d'écrire : une coupure laisse zéro octet,
et la garde annonçait « déjà gelé » pour toujours. cloud-init
continuait de remettre 127.0.1.1 à chaque démarrage."""
verdict, contenu = self._joue("vide")
self.assertEqual(verdict, "FREEZE-OK")
self.assertIn("manage_etc_hosts: false", contenu)
def test_an_already_frozen_host_is_left_alone(self):
verdict, _c = self._joue("gele")
self.assertEqual(verdict, "FREEZE-DEJA")
def test_a_host_without_cloud_init_says_so(self):
verdict, _c = self._joue("sans-cloud")
self.assertEqual(verdict, "FREEZE-SANS-OBJET")
class TestQuelleAdressePourLeNoeud(unittest.TestCase):
"""L'adresse écrite doit être celle par laquelle on JOINT l'hôte.
Mesuré sur une Proxmox imbriquée : « hostname -I » rend
« 10.10.10.150 10.10.20.1 », et la seconde est le pont interne que notre
propre code vient de créer. La poser ferait s'identifier le nœud par une
adresse que personne ne joint."""
def test_the_server_field_of_ssh_connection(self):
self.assertEqual(
pve.ssh_server_ip("10.10.10.1 33580 10.10.10.150 22"),
"10.10.10.150",
)
def test_ssh_noise_does_not_shift_the_fields(self):
brut = (
"Warning: Permanently added 'x' (ED25519) to the list of known"
" hosts.\n10.10.10.1 33580 10.10.10.150 22"
)
self.assertEqual(pve.ssh_server_ip(brut), "10.10.10.150")
def test_an_empty_or_short_value_gives_nothing(self):
for brut in ("", "10.0.0.1 22", "n'importe quoi"):
with self.subTest(brut=brut):
self.assertEqual(pve.ssh_server_ip(brut), "")
def test_a_loopback_server_field_is_refused(self):
# Un tunnel local peut faire de l'hôte « 127.0.0.1 » : l'écrire dans
# /etc/hosts ne réglerait rien.
self.assertEqual(pve.ssh_server_ip("127.0.0.1 5555 127.0.0.1 22"), "")
class TestRelancerLesUnites(unittest.TestCase):
"""Chaque unité à part, jamais fatale, et le journal quand ça échoue.
Les bouchons ÉCHOUENT ici. La première version ne faisait jamais rater un
« start » : le journalctl bouchonné n'était donc jamais atteint, et
retirer complètement « reset-failed » de la commande laissait tous les
tests verts."""
def _joue(self, unite, etat, monte, existe=True, start_ok=True, **kw):
import os
import subprocess
import tempfile
temoin = os.path.join(tempfile.mkdtemp(), "version")
if monte:
open(temoin, "w").close()
bouchons = (
"systemctl() { "
' case "$1" in '
f" list-unit-files) return {0 if existe else 1};; "
f" is-active) echo {etat};; "
' reset-failed) echo "RESET $2";; '
f' start|restart) echo "STARTED $1 $2"; '
f" return {0 if start_ok else 1};; "
" esac; }; "
"journalctl() { echo LIGNE-DE-JOURNAL; }; "
)
cmd = pve.pve_unit_cmd(unite, **kw).replace(
"/etc/pve/.version", temoin
)
res = subprocess.run(
["sh", "-c", bouchons + cmd], capture_output=True, text=True
)
return res.returncode, res.stdout
def test_an_absent_unit_is_skipped_not_fatal(self):
code, out = self._joue("pveproxy", "failed", False, existe=False)
self.assertEqual(code, 0)
self.assertIn("SKIP pveproxy", out)
def test_a_failed_unit_is_RESET_then_started(self):
# Le reset débloque la limite de démarrage : sans lui, systemd refuse
# le start sans même le tenter. Son absence doit faire ROUGIR le test.
code, out = self._joue("pvestatd", "failed", False)
self.assertEqual(code, 0)
self.assertIn("RESET pvestatd", out)
self.assertIn("STARTED start", out)
self.assertLess(out.index("RESET"), out.index("STARTED"))
def test_a_start_that_fails_names_the_unit_and_shows_the_journal(self):
"""La seule façon de dire la cause à quelqu'un dont l'unique accès à
l'hôte est cet outil."""
code, out = self._joue("pve-cluster", "failed", False, start_ok=False)
self.assertEqual(code, 0, "jamais fatale")
self.assertIn("KO pve-cluster", out)
self.assertIn("LIGNE-DE-JOURNAL", out)
def test_a_stale_mount_gets_a_restart_not_a_start(self):
"""« start » sur une unité ACTIVE est un no-op qui rend 0.
pmxcfs tué par l'OOM killer laisse /etc/pve monté mais mort, l'unité
pouvant rester « active » : la réparation ne convergeait jamais et ne
nommait rien."""
code, out = self._joue("pve-cluster", "active", False)
self.assertEqual(code, 0)
self.assertIn("STARTED restart", out)
def test_an_active_unit_with_the_mount_is_left_alone(self):
code, out = self._joue("pve-cluster", "active", True)
self.assertEqual(code, 0)
self.assertIn("DEJA pve-cluster", out)
def test_the_dependents_are_restarted_when_the_mount_was_absent(self):
"""Leur état actif ne prouve rien sur leur lien à pmxcfs.
pvestatd, pvedaemon et pveproxy tournaient pendant toute la panne, en
échouant sur ipcc_send_rec. Les laisser après avoir remonté /etc/pve
donnait une GUI qui répond « communication failure » juste après le ✓
de la réparation."""
for unite in ("pvestatd", "pvedaemon", "pveproxy"):
with self.subTest(unite=unite):
_c, out = self._joue(unite, "active", True, remonte=True)
self.assertIn("STARTED restart", out)
_c, sans = self._joue(unite, "active", True)
self.assertIn(f"DEJA {unite}", sans)
def test_the_firewall_is_not_in_the_list(self):
# Sa configuration vit dans config.db, invisible tant que /etc/pve
# n'est pas monté : on appliquerait des règles illisibles sur la seule
# voie d'accès à la machine.
self.assertNotIn("pve-firewall", pve.PVE_UNITS)
def test_rrdcached_comes_before_pve_cluster(self):
# pve-cluster le requiert : une limite atteinte sur rrdcached fait
# échouer pve-cluster sur « dependency », et reset-failed sur
# pve-cluster n'y change rien.
units = list(pve.PVE_UNITS)
self.assertLess(units.index("rrdcached"), units.index("pve-cluster"))
class TestConstaterLeMontage(unittest.TestCase):
"""Une seule observation ne prouve rien, et un silence n'est pas une
absence."""
def test_a_mount_that_holds(self):
lu = pve.parse_mount_wait("MONTE\nNRESTARTS 0\n")
self.assertEqual((lu["verdict"], lu["relances"]), ("MONTE", 0))
def test_a_mount_that_flaps_is_not_a_success(self):
# reset-failed vient d'effacer la limite de relance : un pmxcfs qui
# battait repart pour une salve entière, et le ✓ serait suivi d'un
# « pvesm ne répond plus » dix secondes après.
lu = pve.parse_mount_wait("BATTEMENT\nNRESTARTS 4\n")
self.assertEqual((lu["verdict"], lu["relances"]), ("BATTEMENT", 4))
def test_silence_is_not_absence(self):
# Conclure « /etc/pve n'est pas monté » d'une perte de contact envoie
# chercher dans journalctl une panne qui n'existe pas.
for brut in ("", "timeout", "ssh: connect to host … port 22"):
with self.subTest(brut=brut):
self.assertEqual(
pve.parse_mount_wait(brut)["verdict"], "INCONNU"
)
def test_the_wait_is_a_single_round_trip(self):
cmd = pve.mount_wait_cmd()
self.assertIn("while", cmd)
self.assertIn("sleep", cmd)
self.assertEqual(cmd.count("NRESTARTS"), 1)
def _attends(self, present, disparait=False):
"""Exécute la commande RÉELLE, sentinelle créée puis retirée."""
import os
import subprocess
import tempfile
temoin = os.path.join(tempfile.mkdtemp(), "version")
if present:
open(temoin, "w").close()
cmd = pve.mount_wait_cmd(tours=2, repos=1).replace(
"/etc/pve/.version", temoin
)
if disparait:
# Retiré PENDANT la pause de reconfirmation — la SECONDE, celle
# qui suit « then ». La première est dans la boucle d'attente.
cmd = cmd.replace(
"then sleep 1;", f"then rm -f {temoin}; sleep 1;", 1
)
res = subprocess.run(
["sh", "-c", "systemctl() { echo 3; }; " + cmd],
capture_output=True,
text=True,
)
return pve.parse_mount_wait(res.stdout)
def test_a_mount_that_holds_is_measured(self):
self.assertEqual(self._attends(True)["verdict"], "MONTE")
def test_a_mount_that_disappears_is_a_flap(self):
"""La raison d'être de la reconfirmation.
reset-failed vient d'effacer la limite de relance, donc un pmxcfs qui
battait repart pour une salve entière : vu une fois, il peut mourir
dix secondes après notre ✓."""
self.assertEqual(
self._attends(True, disparait=True)["verdict"], "BATTEMENT"
)
def test_a_mount_that_never_comes_is_absent(self):
self.assertEqual(self._attends(False)["verdict"], "ABSENT")
if __name__ == "__main__":
unittest.main(verbosity=1)

View file

@ -686,6 +686,82 @@ class TestUnParcMixte(unittest.TestCase):
self.assertEqual(vu["kw"]["desktop"], "")
class TestLaVmCloneLeDepotDistant(unittest.TestCase):
"""« Le problème est revenu » — alors qu'il était corrigé.
La VM ne reçoit pas le checkout d'ici : elle CLONE la branche depuis le
dépôt DISTANT. Tout ce qui tourne dedans — install_proxmox.sh, les
scripts d'installation, le Makefile — vient donc de là. Un correctif
commité ici et non poussé lui est invisible.
Vécu deux fois de suite : la correction de /etc/hosts était dans le
checkout depuis la veille, absente du distant, et chaque VM déployée
ensuite recevait l'ancien script. Il a fallu comparer les deux versions à
la main pour le voir. Rien ne le disait."""
def _todo(self, sortie, code=0):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
faux = mock.Mock(returncode=code, stdout=sortie)
return todo, faux
def test_the_gap_is_counted_and_named(self):
todo, faux = self._todo(
"abc1234 [FIX] un correctif\ndef5678 [ADD] autre chose\n"
)
with mock.patch("subprocess.run", return_value=faux):
nombre, sujets = todo._qemu_branch_gap("develop")
self.assertEqual(nombre, 2)
self.assertIn("[FIX] un correctif", sujets[0])
def test_nothing_to_say_when_the_remote_is_up_to_date(self):
todo, faux = self._todo("")
with mock.patch("subprocess.run", return_value=faux):
self.assertEqual(todo._qemu_branch_gap("develop"), (0, []))
self.assertEqual(todo._qemu_branch_gap_lines("develop"), [])
def test_an_unknown_remote_branch_is_not_a_gap(self):
# « origin/xyz » inconnu fait échouer git : ce n'est pas un écart à
# signaler, c'est une question qui ne se pose pas. Le dire quand même
# serait un avertissement à chaque déploiement d'une branche neuve.
todo, faux = self._todo("", code=128)
with mock.patch("subprocess.run", return_value=faux):
self.assertEqual(todo._qemu_branch_gap("nouvelle"), (0, []))
def test_no_branch_asks_nothing(self):
todo, _faux = self._todo("")
self.assertEqual(todo._qemu_branch_gap(""), (0, []))
def test_the_long_list_is_trimmed_but_counted(self):
todo, faux = self._todo(
"\n".join(f"c{i} sujet {i}" for i in range(10))
)
with mock.patch("subprocess.run", return_value=faux):
lignes = todo._qemu_branch_gap_lines("develop", limite=2)
texte = " ".join(lignes)
self.assertIn("10", texte, "le nombre TOTAL doit rester lisible")
self.assertIn("8", texte, "et ce qui n'est pas montré, dit")
self.assertIn("git push", texte)
def test_both_screens_say_it_before_deploying(self):
# L'avertissement ne vaut que là où on peut encore renoncer.
import inspect
from script.todo.proxmox_menu import ProxmoxMenuMixin
from script.todo.qemu_deploy import QemuDeployMixin
for fn in (
ProxmoxMenuMixin._pve_confirm_spec,
QemuDeployMixin._qemu_print_recap,
):
with self.subTest(fonction=fn.__name__):
self.assertIn("_qemu_branch_gap_lines", inspect.getsource(fn))
class TestLePontQuiNeMeneraitNullePart(unittest.TestCase):
"""Le pont NAT était écrit AVANT qu'on sache si le NAT existe.
@ -943,6 +1019,124 @@ class TestUnSeulNomDansSshConfig(unittest.TestCase):
)
class TestNeRienPerdreDansSshConfig(unittest.TestCase):
"""~/.ssh/config contient les entrées PERSONNELLES de l'utilisateur.
Ce fichier est réécrit en entier à chaque déploiement de VM. Deux pertes
de données y ont été constatées, l'une capable de désactiver la
vérification de clé d'hôte sur un serveur de production."""
def setUp(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
self.retirer = TODO._ssh_config_drop_hosts
def test_a_block_with_an_unindented_body_goes_entirely(self):
"""L'indentation est COSMÉTIQUE dans ce format, et un fichier écrit à
la main s'en passe souvent. La règle d'avant la prenait pour de la
syntaxe : seule la ligne « Host » partait."""
avant = (
"Host prod\n"
" HostName prod.example.com\n"
" User root\n"
"\n"
"Host deep-1\n"
"HostName 10.0.0.1\n"
"User erplibre\n"
"StrictHostKeyChecking no\n"
"UserKnownHostsFile /dev/null\n"
"IdentityFile ~/.ssh/id_deep\n"
)
apres = self.retirer(avant, ["deep-1"])
# Rien du bloc retiré ne subsiste : sans Host au-dessus, ssh
# rattacherait ces lignes à « prod » et la production perdrait sa
# vérification de clé d'hôte.
for orphelin in (
"10.0.0.1",
"StrictHostKeyChecking",
"UserKnownHostsFile",
"id_deep",
):
self.assertNotIn(orphelin, apres, orphelin)
# Et le bloc de l'utilisateur est intact.
self.assertIn("HostName prod.example.com", apres)
self.assertIn("User root", apres)
def test_a_shared_host_line_keeps_the_names_not_dropped(self):
"""« Host prod-db vm-a » perdait le prod-db de l'utilisateur : le bloc
partait en entier dès qu'UN de ses noms était repris."""
avant = (
"Host prod-db vm-a\n"
" HostName db.interne\n"
" ProxyJump pve9\n"
)
apres = self.retirer(avant, ["vm-a"])
self.assertIn("Host prod-db\n", apres)
self.assertNotIn("vm-a", apres)
# Le corps suit le nom qui reste : sinon prod-db perd son rebond.
self.assertIn("HostName db.interne", apres)
self.assertIn("ProxyJump pve9", apres)
def test_a_nickname_added_by_hand_survives_a_redeploy(self):
avant = "Host pve9+vm-a webtest\n HostName 10.10.10.5\n"
apres = self.retirer(avant, ["pve9+vm-a"])
self.assertIn("Host webtest\n", apres)
self.assertIn("HostName 10.10.10.5", apres)
def test_all_names_dropped_removes_the_block(self):
avant = "Host a b\n HostName 1.2.3.4\n\nHost garde\n User x\n"
apres = self.retirer(avant, ["a", "b"])
self.assertNotIn("1.2.3.4", apres)
self.assertIn("Host garde", apres)
def test_a_match_section_is_never_swallowed(self):
avant = (
"Host part\n"
" HostName 10.0.0.9\n"
"\n"
"Match host *.interne\n"
" User admin\n"
)
apres = self.retirer(avant, ["part"])
self.assertIn("Match host *.interne", apres)
self.assertIn("User admin", apres)
self.assertNotIn("10.0.0.9", apres)
def test_comments_before_the_next_block_are_not_swallowed(self):
avant = (
"Host part\n"
" HostName 10.0.0.9\n"
"\n"
"# la machine du client, ne pas toucher\n"
"Host client\n"
" HostName 10.0.0.10\n"
)
apres = self.retirer(avant, ["part"])
self.assertIn("# la machine du client, ne pas toucher", apres)
self.assertIn("Host client", apres)
def test_global_directives_above_the_first_host_stay(self):
avant = "ServerAliveInterval 60\n\nHost part\n HostName 10.0.0.9\n"
apres = self.retirer(avant, ["part"])
self.assertIn("ServerAliveInterval 60", apres)
self.assertNotIn("10.0.0.9", apres)
def test_the_keyword_is_read_case_insensitively(self):
# ssh lit ses mots-clés sans égard à la casse ; nous aussi, sinon un
# « host » minuscule échappe au retrait et le nom vit deux fois.
avant = "host part\n HostName 10.0.0.9\n"
self.assertNotIn("10.0.0.9", self.retirer(avant, ["part"]))
def test_hostname_is_not_mistaken_for_a_host_line(self):
avant = "Host garde\n HostName part\n"
apres = self.retirer(avant, ["part"])
self.assertIn("Host garde", apres)
self.assertIn("HostName part", apres)
class TestLAncienNomSEnVa(unittest.TestCase):
"""La convention a changé : les entrées écrites AVANT portent le nom
court, et rien ne les retirerait — elles ne portent pas le nom qu'on
@ -981,6 +1175,48 @@ class TestLAncienNomSEnVa(unittest.TestCase):
ligne.rstrip() for ligne in fh if ligne.startswith("Host ")
]
def test_dropping_the_last_entry_writes_no_nameless_block(self):
"""Retirer sans réécrire est un appel légitime : les machines
n'existent plus.
Constaté dans le vrai ~/.ssh/config de l'utilisateur : l'appel écrivait
« Host » NU, suivi d'un « HostName » vide, puis mourait sur un
IndexError en annonçant l'ajout. Le bloc sans nom s'applique à rien et
brouille la lecture du fichier."""
import os
self.todo._write_ssh_config_entry(
["deep-1"], "erplibre", "10.10.10.150"
)
self.todo._write_ssh_config_entry(
["deep-2"], "erplibre", "10.10.10.151", proxy_jump="deep-1"
)
self.todo._write_ssh_config_entry(
[], "erplibre", "", also_drop=("deep-1", "deep-2")
)
self.assertEqual(self._hosts(), [])
with open(
os.path.join(self.maison, ".ssh/config"), encoding="utf-8"
) as fh:
reste = fh.read()
self.assertNotIn("Host", reste)
self.assertNotIn("HostName", reste)
# Et le fichier garde ses droits : ssh refuse un config trop ouvert.
self.assertEqual(
oct(os.stat(os.path.join(self.maison, ".ssh/config")).st_mode)[
-3:
],
"600",
)
def test_dropping_one_entry_leaves_the_others_untouched(self):
for nom, ip in (("garde-a", "10.0.0.1"), ("part", "10.0.0.2")):
self.todo._write_ssh_config_entry([nom], "erplibre", ip)
self.todo._write_ssh_config_entry(
[], "erplibre", "", also_drop=("part",)
)
self.assertEqual(self._hosts(), ["Host garde-a"])
def test_the_old_short_entry_is_retired(self):
# L'état d'avant : une entrée écrite sous l'ancienne convention.
self.todo._write_ssh_config_entry(

View file

@ -0,0 +1,368 @@
#!/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):
"""Le plan se dimensionne DEPUIS LE BAS, et c'est une correction.
De haut en bas, chaque étage recevait ce que son parent pouvait céder.
Mesuré sur une descente réelle : l'étage 4 se retrouvait avec 44 Go de RAM
et deux vCPU sur un hôte qui en avait deux — cent pour cent de
surengagement, à chaque étage. Son installation dépassait deux heures et
demie contre treize minutes pour l'étage 3, et l'extrapolation donnait cinq
ANS pour le dixième.
Le plus profond reçoit donc ce qu'un Proxmox de test demande, et chaque
parent ajoute son propre surcoût — un vCPU, deux gibioctets, dix
gigaoctets. Rien de plus."""
# La machine réelle sur laquelle l'algorithme a été réglé.
HOTE = dict(cpu_hote=28, ram_dispo_mo=58000, disque_libre_go=165)
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_the_deepest_level_gets_exactly_the_target(self):
"""C'est de là qu'on part : ce qu'un Proxmox de test demande, pas ce
qui reste."""
plan = nesting.nesting_plan(10, **self.HOTE)
fond = plan["niveaux"][-1]
self.assertEqual(fond["ram"], nesting.PVE_RAM_CIBLE_MO)
self.assertEqual(fond["disque"], nesting.PVE_DISQUE_CIBLE_GO)
self.assertEqual(fond["vcpu"], nesting.VCPU_IMBRIQUE)
def test_each_parent_adds_exactly_its_own_overhead(self):
# Ni plus ni moins : un parent plus large que nécessaire ralentit tout
# ce qu'il héberge, un parent trop juste ne le fait pas tourner. Vaut
# pour la mémoire et le disque — le processeur, lui, ne croît pas avec
# la profondeur, voir test_no_nested_level_is_ever_wide.
niveaux = nesting.nesting_plan(8, **self.HOTE)["niveaux"]
for parent, enfant in zip(niveaux, niveaux[1:]):
self.assertEqual(parent["ram"] - enfant["ram"], nesting.PVE_RAM_MO)
self.assertEqual(
parent["disque"] - enfant["disque"], nesting.PVE_DISQUE_GO
)
def test_no_nested_level_is_ever_wide(self):
"""MESURÉ, deux fois : au quatrième étage un invité large GÈLE.
Douze vCPU d'abord, sur un parent qui en avait deux : on avait imputé
le gel au surengagement. Puis huit vCPU sur un parent qui en avait
NEUF, charge 1,47, aucun surengagement — 32 Mio lus en 106 minutes,
même RIP à trois relevés espacés de cinq minutes. C'est le nombre de
vCPU de l'invité imbriqué, et rien d'autre.
Une version de ce module donnait un vCPU de plus à chaque parent, ce
qui rendait l'étage 4 large de huit : exactement le cas gelé. Aucun
étage imbriqué ne doit dépasser VCPU_INTERMEDIAIRE, à AUCUNE
profondeur demandée."""
for profondeur in range(1, 13):
niveaux = nesting.nesting_plan(profondeur, **self.HOTE)["niveaux"]
for n in niveaux[1:]:
self.assertLessEqual(
n["vcpu"],
nesting.VCPU_INTERMEDIAIRE,
f"profondeur {profondeur}, étage {n['niveau']}",
)
def test_the_three_widths_are_where_they_belong(self):
"""Le coût d'un vCPU dépend de la PROFONDEUR de l'étage, pas d'une
largeur absolue.
Mesuré : le troisième vCPU ne coûte rien aux étages 2 et 3 — ssh en
37 s et 93 s, comme à deux vCPU — et coûte 4 h 20 au quatrième, contre
1 664 s à deux. Un seul vCPU de plus, l'amorçage ×9,4."""
niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"]
largeurs = {n["niveau"]: n["vcpu"] for n in niveaux}
# Le métal : aucun risque de gel, onze vCPU y ont démarré en 42 s.
self.assertEqual(largeurs[1], nesting.VCPU_METAL)
# Peu profonds : le troisième vCPU est gratuit, et il enlève le
# surengagement là où l'installation s'effondrait.
for niveau in range(2, nesting.SEUIL_ETROIT):
self.assertEqual(
largeurs[niveau], nesting.VCPU_INTERMEDIAIRE, f"étage {niveau}"
)
# À partir du seuil : le strict minimum, sans exception.
for niveau in range(nesting.SEUIL_ETROIT, 7):
self.assertEqual(
largeurs[niveau], nesting.VCPU_IMBRIQUE, f"étage {niveau}"
)
def test_the_level_above_the_threshold_keeps_its_headroom(self):
"""C'est le seul endroit de la descente où le surengagement disparaît,
et c'est celui qui compte : l'étage 4 est le premier dont
l'installation s'effondrait, faute d'un parent plus large que lui.
Les deux combinaisons mesurées étaient (parent 2, enfant 2) — démarre
en 1 664 s puis l'installation ne finit pas — et (parent 3, enfant 3) —
démarre en 15 608 s. Celle-ci est (parent 3, enfant 2)."""
niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"]
largeurs = {n["niveau"]: n["vcpu"] for n in niveaux}
parent = largeurs[nesting.SEUIL_ETROIT - 1]
enfant = largeurs[nesting.SEUIL_ETROIT]
self.assertGreater(parent, enfant)
def test_a_single_level_descent_runs_on_metal(self):
niveaux = nesting.nesting_plan(1, **self.HOTE)["niveaux"]
self.assertEqual(len(niveaux), 1)
self.assertEqual(niveaux[0]["vcpu"], nesting.VCPU_METAL)
def test_a_parent_is_never_narrower_than_its_child(self):
"""Deux vCPU hébergeant deux vCPU, c'est cent pour cent de
surengagement — et l'hyperviseur à servir en plus. Mesuré : une VM
démarrée au quatrième étage a lu DEUX KILO-OCTETS en onze minutes,
affamée par l'installation qui tournait à côté. L'installation de
l'étage 4 dépassait alors 2 h 50 contre 793 s pour l'étage 3.
« Jamais plus étroit », et non « toujours plus large » : deux étages
imbriqués voisins ont la même largeur, ce que le gel du quatrième
étage impose. C'est le PLUS PROFOND qui descend à deux."""
for coeurs in (6, 8, 12, 28):
with self.subTest(coeurs=coeurs):
niveaux = nesting.nesting_plan(
6,
cpu_hote=coeurs,
ram_dispo_mo=64000,
disque_libre_go=400,
)["niveaux"]
for parent, enfant in zip(niveaux, niveaux[1:]):
self.assertGreaterEqual(parent["vcpu"], enfant["vcpu"])
self.assertGreater(parent["ram"], enfant["ram"])
self.assertGreater(parent["disque"], enfant["disque"])
def test_the_cpu_only_ever_refuses_outright(self):
"""Le processeur ne borne plus une profondeur INTERMÉDIAIRE : les
étages imbriqués gardent une largeur fixe, seul le premier compte sur
le métal. Ou la machine peut le porter, ou elle ne peut rien.
Une version d'avant faisait croître la demande avec la profondeur —
onze vCPU pour dix étages — et bornait donc à trois étages sur huit
cœurs. Elle rendait aussi le quatrième étage large de huit, ce qui le
gelait : la borne cachait un défaut."""
large = nesting.nesting_plan(
10, cpu_hote=8, ram_dispo_mo=64000, disque_libre_go=400
)
self.assertEqual(large["atteignable"], 10)
self.assertEqual(large["arret"], "")
# Trop petite pour le premier étage : zéro, et le dire.
for coeurs in (1, 2, 4):
with self.subTest(coeurs=coeurs):
petite = nesting.nesting_plan(
10,
cpu_hote=coeurs,
ram_dispo_mo=64000,
disque_libre_go=400,
)
self.assertEqual(petite["atteignable"], 0)
self.assertEqual(petite["arret"], "vcpu")
# Et le premier étage laisse à l'hôte ce qui lui est réservé.
self.assertLessEqual(
large["niveaux"][0]["vcpu"], 8 - nesting.HOTE_RESERVE_VCPU
)
def test_the_named_resource_is_the_one_that_really_binds(self):
"""La version d'avant prenait la première d'une chaîne figée
ram > disque > vcpu, évaluée à la profondeur DEMANDÉE. Sur deux cœurs
et 20 Go elle annonçait « manque de ram » quand le processeur bornait à
zéro étage : l'opérateur doublait la mémoire et n'y gagnait rien."""
for cpu, ram, disque, attendu in (
(2, 20000, 5000, "vcpu"),
(2, 200000, 100, "vcpu"),
(4, 16384, 200, "vcpu"),
(28, 12288, 500, "ram"),
(28, 200000, 60, "disque"),
):
with self.subTest(cpu=cpu, ram=ram, disque=disque):
plan = nesting.nesting_plan(10, cpu, ram, disque)
self.assertEqual(plan["arret"], attendu)
# Et c'est bien le plus BAS des trois plafonds.
self.assertEqual(
plan["plafonds"][attendu], min(plan["plafonds"].values())
)
self.assertEqual(
plan["atteignable"], min(10, *plan["plafonds"].values())
)
def test_doubling_the_named_resource_gains_a_level(self):
"""L'épreuve utile du diagnostic : ce qu'il nomme, ajouté, PAIE."""
base = dict(cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500)
avant = nesting.nesting_plan(10, **base)
self.assertEqual(avant["arret"], "ram")
apres = nesting.nesting_plan(
10, **{**base, "ram_dispo_mo": base["ram_dispo_mo"] * 2}
)
self.assertGreater(apres["atteignable"], avant["atteignable"])
def test_a_huge_depth_costs_nothing(self):
# Le balayage décroissant tournait autant de tours que la profondeur
# demandée pour rendre exactement le même plan.
plan = nesting.nesting_plan(10**6, 28, 58000, 165)
self.assertEqual(plan["atteignable"], min(plan["plafonds"].values()))
self.assertEqual(len(plan["niveaux"]), plan["atteignable"])
def test_running_out_of_ram_is_named(self):
plan = nesting.nesting_plan(
10, cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500
)
self.assertEqual(plan["arret"], "ram")
self.assertLess(plan["atteignable"], 10)
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=28, 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_depth_of_zero_asks_for_nothing(self):
for profondeur in (0, -1, -7):
with self.subTest(profondeur=profondeur):
plan = nesting.nesting_plan(profondeur, **self.HOTE)
self.assertEqual(plan["niveaux"], [])
self.assertEqual(plan["atteignable"], 0)
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.assertTrue(plan["arret"])
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(-2, 13):
plan = nesting.nesting_plan(profondeur, **self.HOTE)
self.assertEqual(len(plan["niveaux"]), plan["atteignable"])
self.assertLessEqual(plan["atteignable"], max(0, profondeur))
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)

View file

@ -37,9 +37,18 @@ class TestLaLecture(unittest.TestCase):
def setUp(self):
self.releves = mon.parse_pvestats(REPONSE)
def test_the_vm_is_keyed_by_its_name(self):
# Le suivi raisonne en NOMS : c'est ce que porte le manifeste.
self.assertEqual(list(self.releves), ["pve-suivi"])
def test_the_vm_is_keyed_by_its_vmid(self):
"""Par VMID, et c'est tout le sujet.
« /cluster/resources » est bâti par pvestatd ; celui-ci arrêté, l'hôte
rend une entrée SQUELETTIQUE — ni nom, ni mémoire, ni disque, et
« status: unknown ». Indexée par nom, elle disparaissait : la VM
passait pour absente du relevé alors que l'hôte venait de la nommer.
Trois tours plus tard, 🗑 — état TERMINAL — et le suivi annonçait
« 1/1 terminées » au bout de neuf secondes. Vécu sur une VM Arch dans
un Proxmox imbriqué."""
self.assertEqual(list(self.releves), [100])
self.assertEqual(self.releves[100]["name"], "pve-suivi")
def test_the_shape_matches_the_virsh_one(self):
# Même forme exprès : `ram_pair`, `WriteWindow` et les colonnes
@ -52,10 +61,10 @@ class TestLaLecture(unittest.TestCase):
"disk_used",
"disk_total",
}
self.assertTrue(attendus <= set(self.releves["pve-suivi"]))
self.assertTrue(attendus <= set(self.releves[100]))
def test_the_measures_are_the_ones_the_host_gave(self):
rec = self.releves["pve-suivi"]
rec = self.releves[100]
self.assertEqual(rec["ram_used"], 385351680)
self.assertEqual(rec["ram_total"], 536870912)
self.assertEqual(rec["wr_bytes"], 328233472)
@ -65,13 +74,13 @@ class TestLaLecture(unittest.TestCase):
def test_a_zero_disk_falls_back_to_the_real_size(self):
# Sur un stockage en fichiers, Proxmox NE CALCULE PAS la taille
# occupée et rapporte 0 : la colonne aurait affiché « 0/4G ».
self.assertEqual(self.releves["pve-suivi"]["disk_used"], 4294971392)
self.assertEqual(self.releves["pve-suivi"]["disk_total"], 4294967296)
self.assertEqual(self.releves[100]["disk_used"], 4294971392)
self.assertEqual(self.releves[100]["disk_total"], 4294967296)
def test_the_reading_is_fresh_so_the_ram_is_shown(self):
# `ram_pair` refuse un relevé périmé : sans horodatage, la RAM d'une
# VM distante ne s'afficherait jamais.
rec = self.releves["pve-suivi"]
rec = self.releves[100]
self.assertNotEqual(mon.ram_pair(rec, rec["ram_at"]), "-")
def test_garbage_yields_nothing_rather_than_raising(self):
@ -336,6 +345,87 @@ class TestLeRedemarrageQuiFaitPartieDeLInstallation(unittest.TestCase):
self.assertIn("_reboot_steps(log_q, reboot) if reboot else", src)
class TestUnHoteQuiNeNommePasSesVm(unittest.TestCase):
"""pvestatd arrêté, l'hôte rend une entrée SQUELETTIQUE par VM.
Vécu sur une VM Arch dans un Proxmox imbriqué :
{"id":"qemu/100","node":"…","status":"unknown","type":"qemu",
"vmid":100}
Le nom manque, donc la VM passait pour absente du relevé — alors que
l'hôte venait de la nommer. Trois tours plus tard : 🗑, état TERMINAL, et
le suivi annonçait « 1/1 terminées » au bout de neuf secondes sur une
installation qui tournait. Une cause, deux symptômes."""
SQUELETTE = (
'[{"id":"qemu/100","node":"n","status":"unknown","type":"qemu",'
'"vmid":100}]\n'
"---ERPLIBRE-DU---\n"
"2248339456\t/var/lib/vz/images/100/\n"
"---ERPLIBRE-ODOO---\n"
)
def _lit(self, sortie, nom="vm-arch", vmid=100):
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
vm = {
"name": nom,
"pve": {
"target": "h",
"sudo": "",
"vmid": vmid,
"addr": "1.2.3.4",
},
}
with mock.patch(
"script.proxmox.proxmox_deploy.run", return_value=(1, sortie)
):
return mon.read_pvestats_detail([vm], now=50.0)
def test_the_vm_is_still_found(self):
stats, ok = self._lit(self.SQUELETTE)
self.assertTrue(ok)
self.assertIn("vm-arch", stats, "trouvée par son VMID, pas par un nom")
def test_an_unknown_status_is_not_a_deletion(self):
stats, _ok = self._lit(self.SQUELETTE)
etat = stats["vm-arch"]["state"]
self.assertEqual(mon.PVE_ETATS.get(etat, "running"), "running")
def test_what_the_host_does_know_is_kept(self):
# Le « du » est indexé par VMID : la taille occupée survit même quand
# tout le reste manque.
stats, _ok = self._lit(self.SQUELETTE)
self.assertEqual(stats["vm-arch"]["disk_used"], 2248339456)
def test_a_vmid_of_another_host_is_not_borrowed(self):
# Deux hôtes peuvent avoir un VMID 100. La correspondance ne vaut que
# pour les VM de CET hôte.
mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False})
vms = [
{
"name": "ici",
"pve": {"target": "h", "sudo": "", "vmid": 100},
},
{
"name": "ailleurs",
"pve": {"target": "autre", "sudo": "", "vmid": 100},
},
]
# Le bouchon répond PAR HÔTE : sans cela, la même sortie servirait
# aux deux et le test ne prouverait rien.
def par_hote(info, _cmd, _timeout=40):
if info.get("target") == "h":
return 1, self.SQUELETTE
return 1, "[]\n---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n"
with mock.patch("script.proxmox.proxmox_deploy.run", par_hote):
stats, _ok = mon.read_pvestats_detail(vms, now=60.0)
self.assertIn("ici", stats)
self.assertNotIn("ailleurs", stats)
class TestTroisVmSurUnProxmox(unittest.TestCase):
"""Rapporté à l'usage : sur trois VM d'un même Proxmox, une seule avait
ses colonnes vides — et les deux autres montraient les chiffres d'une
@ -701,7 +791,7 @@ class TestLeDisque(unittest.TestCase):
"1268518912\t/var/lib/vz/images/101/\n"
"4294967296\t/var/lib/vz/images/999/\n"
)
rec = mon.parse_pvestats(texte)["vm-a"]
rec = mon.parse_pvestats(texte)[101]
self.assertEqual(rec["disk_used"], 1268518912)
self.assertEqual(rec["disk_total"], 6442450944)
self.assertEqual(

1659
test/test_todo_longtest.py Normal file

File diff suppressed because it is too large Load diff