[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:
commit
b5209ee226
26 changed files with 8563 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
383
long_test/README.base.md
Normal 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
194
long_test/README.fr.md
Normal 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
183
long_test/README.md
Normal 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
412
long_test/deep_proxmox.py
Executable 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
564
long_test/deep_qemu.py
Normal 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
1308
long_test/descente.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
364
script/proxmox/nesting.py
Normal 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}",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
181
script/todo/longtest_menu.py
Normal file
181
script/todo/longtest_menu.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 !"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
569
test/test_deep_qemu.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
368
test/test_proxmox_nesting.py
Normal file
368
test/test_proxmox_nesting.py
Normal 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)
|
||||
|
|
@ -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
1659
test/test_todo_longtest.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue