From 7199a7cbb21c5b61e4a205b12513b95b265ad6c8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 06:20:52 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20LongTest=20:=20jusqu'=C3=A0=20quel=20?= =?UTF-8?q?=C3=A9tage=20un=20Proxmox=20imbriqu=C3=A9=20tient-il?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une mesure à la main a trouvé, au quatrième étage, un invité 36 fois plus lent que le temps réel — 583 secondes d'horloge pour 16 secondes de temps invité, chaque ligne d'ACPI prenant une seconde — puis un noyau gelé au MÊME octet quelles que soient les ressources. Un chiffre obtenu une fois, sur une machine, n'est pas un chiffre. D'où trois choses. L'algorithme, en fonctions pures. Deux ressources s'épuisent en descendant : la mémoire, chaque étage gardant de quoi faire tourner ses propres démons, et le disque, celui de l'enfant vivant DANS celui du parent. Une troisième se dégrade, et elle borne le vCPU à deux au-delà du premier étage : douze ont gelé le noyau invité, les mêmes deux avançaient. La mémoire n'est PAS bornée — la même VM gelait au même octet avec 9 Go et avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. Le plan est annoncé avant toute création, et jamais au-delà de ce qui tient. Le garde-fou dans l'écran. Il lisait la capacité de l'HÔTE et l'offrait en entier : sur un troisième étage à 14 cœurs, il a proposé 12 vCPU à une VM qui n'a jamais démarré. Le nombre n'était pas absurde pour la machine ; il l'était pour sa profondeur, que l'écran ignorait. Elle se compte maintenant sur la chaîne de ProxyJump — un rebond par étage, et c'est nous qui écrivons ces entrées. Le test long, dans LongTest/ et non dans test/ : le lanceur unitaire doit rester lançable en quelques secondes, partout, y compris sans virtualisation. La descente est uniforme — créer, attendre le ssh, installer, redémarrer et vérifier le noyau, remettre pmxcfs debout, contrôler le stockage — et s'arrête au premier étage qui échoue en NOMMANT l'étape. Il envoie notre install_proxmox.sh par scp plutôt que de laisser la VM cloner le dépôt : c'est notre code qu'on éprouve, et un correctif absent du distant a fait revenir le même défaut sur trois VM. --- EN --- The practicable nesting depth cannot be deduced, only measured. A manual measurement found, at the fourth level, a guest 36 times slower than real time — 583 seconds of wall clock for 16 seconds of guest time, each ACPI line taking a second — then a kernel frozen at the SAME byte whatever the resources. A number obtained once, on one machine, is not a number. Hence three things. The algorithm, in pure functions. Two resources run out going down: memory, each level keeping what its own daemons need, and disk, the child's living INSIDE the parent's. A third degrades, and it caps the vCPU at two beyond the first level: twelve froze the guest kernel, the same two progressed. Memory is NOT capped — the same VM froze at the same byte with 9 GB and with 2 GB, so trimming it would gain nothing and starve the level below. The plan is announced before anything is created, and never beyond what fits. The guard in the screen. It read the HOST's capacity and offered all of it: on a third level with 14 cores it proposed 12 vCPU to a VM that never booted. The number was not absurd for the machine; it was for its depth, which the screen did not know. It is now counted on the ProxyJump chain — one hop per level, and we are the ones writing those entries. The long test, in LongTest/ and not test/: the unit runner must stay runnable in seconds, anywhere, including without virtualisation. The descent is uniform — create, wait for ssh, install, reboot and check the kernel, bring pmxcfs back, check the storage — and stops at the first level that fails, NAMING the step. It sends our install_proxmox.sh over scp instead of letting the VM clone the repository: it is our code being exercised, and a fix absent from the remote made the same defect return on three VMs. Assisted-by: Claude Opus 5 (cherry picked from commit 4f70c461330cac6f46783a60e0f33052a979fa23) --- LongTest/README.base.md | 125 +++++++ LongTest/README.fr.md | 62 ++++ LongTest/README.md | 57 ++++ LongTest/deep_proxmox.py | 609 +++++++++++++++++++++++++++++++++++ script/proxmox/nesting.py | 148 +++++++++ script/todo/longtest_menu.py | 86 +++++ script/todo/proxmox_menu.py | 46 +++ script/todo/todo.py | 31 ++ script/todo/todo_i18n.py | 44 +++ test/test_proxmox_nesting.py | 205 ++++++++++++ test/test_todo_longtest.py | 142 ++++++++ 11 files changed, 1555 insertions(+) create mode 100644 LongTest/README.base.md create mode 100644 LongTest/README.fr.md create mode 100644 LongTest/README.md create mode 100755 LongTest/deep_proxmox.py create mode 100644 script/proxmox/nesting.py create mode 100644 script/todo/longtest_menu.py create mode 100644 test/test_proxmox_nesting.py create mode 100644 test/test_todo_longtest.py diff --git a/LongTest/README.base.md b/LongTest/README.base.md new file mode 100644 index 0000000..20a85f4 --- /dev/null +++ b/LongTest/README.base.md @@ -0,0 +1,125 @@ + + + + + + +# LongTest — tests that create real machines + +These are not unit tests. They create virtual machines, install systems on +them, and take hours. They live here and **not** in `test/`, which the unit +runner sweeps: `./script/test/run_unit_test.sh` must stay runnable in seconds +on any machine, including one without virtualisation. + +Run them from the menu — `TODO › Execute › Test › Long tests` — or directly. + +## deep_proxmox.py — how deep does Proxmox-in-Proxmox go? + +The practicable nesting depth cannot be deduced, only measured. A manual +measurement found, at the fourth level, a guest **36 times slower than real +time** — 583 seconds of wall clock for 16 seconds of guest time, each ACPI +line taking a second — then a guest kernel frozen at the **same byte** +whatever the resources. A number obtained once, on one machine, is not a +number: this script redoes it on demand and says exactly where it breaks. + +``` +./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created +./LongTest/deep_proxmox.py --depth 10 # hours +./LongTest/deep_proxmox.py --detruire # undo it +``` + +The descent is **uniform**. Every level, the first included, goes through the +same six steps: create, wait for ssh, install Proxmox, reboot and check the +kernel, bring pmxcfs back up, check the storage. Only creation differs — +libvirt locally, `qm` afterwards. + +It sends **our** `install_proxmox.sh` over scp instead of letting the VM clone +the repository: it is our code we want to exercise, and the remote is often +behind the checkout — a fix absent from the remote made the same defect "come +back" on three VMs in a row. + +### The resource algorithm + +Two things run out going down, and a third degrades. What runs out is +arithmetic, and `script/proxmox/nesting.py` computes it: + +* **memory** — each level keeps what its own daemons need (`pve-cluster`, + `pvestatd`, `pvedaemon`, `pveproxy`) before handing the rest down; +* **disk** — the child's disk lives *inside* the parent's, which must also + hold its own system. + +What degrades is measured, not assumed: past the second level, vendors +document nothing. Hence one capped number — **2 vCPU** for every nested +level. Twelve vCPU at the fourth level froze the guest kernel in early boot; +the same two progressed. Bringing twelve processors online costs as many +round trips through the whole stack. + +Memory is **not** capped: the same VM froze at the same byte with 9 GB and +with 2 GB, so trimming it would gain nothing and starve the level below. + +The plan is printed **before** anything is created, and the script never +promises a depth it knows will not fit — better to announce six levels and +reach six than to promise ten and die at the seventh without knowing why. + + +# LongTest — des tests qui créent de vraies machines + +Ce ne sont pas des tests unitaires. Ils créent des machines virtuelles, y +installent des systèmes, et durent des heures. Ils vivent ici et **non** dans +`test/`, que le lanceur unitaire balaie : `./script/test/run_unit_test.sh` +doit rester lançable en quelques secondes, sur n'importe quelle machine, y +compris sans virtualisation. + +Ils se lancent depuis le menu — `TODO › Execute › Test › Tests longs` — ou +directement. + +## deep_proxmox.py — jusqu'à quel étage un Proxmox dans un Proxmox tient-il ? + +La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une +mesure à la main a trouvé, au quatrième étage, un invité **36 fois plus lent +que le temps réel** — 583 secondes d'horloge pour 16 secondes de temps +invité, chaque ligne d'ACPI prenant une seconde — puis un noyau invité gelé au +**même octet** quelles que soient les ressources. Un chiffre obtenu une fois, +sur une machine, n'est pas un chiffre : ce script le refait à la demande et +dit exactement où ça casse. + +``` +./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé +./LongTest/deep_proxmox.py --depth 10 # des heures +./LongTest/deep_proxmox.py --detruire # défaire +``` + +La descente est **uniforme**. Chaque étage, le premier compris, passe par les +mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et +vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la +création diffère — libvirt en local, `qm` ensuite. + +Il envoie **notre** `install_proxmox.sh` par scp au lieu de laisser la VM +cloner le dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant +est souvent en retard sur le checkout — un correctif absent du distant a fait +« revenir » le même défaut sur trois VM de suite. + +### L'algorithme de ressources + +Deux choses s'épuisent en descendant, et une troisième se dégrade. Ce qui +s'épuise est de l'arithmétique, et `script/proxmox/nesting.py` la calcule : + +* **la mémoire** — chaque étage garde de quoi faire tourner ses propres démons + (`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) avant de céder le + reste ; +* **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit + aussi contenir son propre système. + +Ce qui se dégrade est mesuré, pas supposé : au-delà du deuxième étage, les +fabricants ne documentent rien. D'où un seul nombre borné — **2 vCPU** pour +tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invité en +tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs +en ligne coûte autant d'allers-retours à travers toute la pile. + +La mémoire n'est **pas** bornée : la même VM gelait au même octet avec 9 Go et +avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. + +Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script +ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer +six étages et en réussir six que d'en promettre dix et mourir au septième sans +savoir pourquoi. diff --git a/LongTest/README.fr.md b/LongTest/README.fr.md new file mode 100644 index 0000000..46f48d4 --- /dev/null +++ b/LongTest/README.fr.md @@ -0,0 +1,62 @@ + +# LongTest — des tests qui créent de vraies machines + +Ce ne sont pas des tests unitaires. Ils créent des machines virtuelles, y +installent des systèmes, et durent des heures. Ils vivent ici et **non** dans +`test/`, que le lanceur unitaire balaie : `./script/test/run_unit_test.sh` +doit rester lançable en quelques secondes, sur n'importe quelle machine, y +compris sans virtualisation. + +Ils se lancent depuis le menu — `TODO › Execute › Test › Tests longs` — ou +directement. + +## deep_proxmox.py — jusqu'à quel étage un Proxmox dans un Proxmox tient-il ? + +La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une +mesure à la main a trouvé, au quatrième étage, un invité **36 fois plus lent +que le temps réel** — 583 secondes d'horloge pour 16 secondes de temps +invité, chaque ligne d'ACPI prenant une seconde — puis un noyau invité gelé au +**même octet** quelles que soient les ressources. Un chiffre obtenu une fois, +sur une machine, n'est pas un chiffre : ce script le refait à la demande et +dit exactement où ça casse. + +``` +./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé +./LongTest/deep_proxmox.py --depth 10 # des heures +./LongTest/deep_proxmox.py --detruire # défaire +``` + +La descente est **uniforme**. Chaque étage, le premier compris, passe par les +mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et +vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la +création diffère — libvirt en local, `qm` ensuite. + +Il envoie **notre** `install_proxmox.sh` par scp au lieu de laisser la VM +cloner le dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant +est souvent en retard sur le checkout — un correctif absent du distant a fait +« revenir » le même défaut sur trois VM de suite. + +### L'algorithme de ressources + +Deux choses s'épuisent en descendant, et une troisième se dégrade. Ce qui +s'épuise est de l'arithmétique, et `script/proxmox/nesting.py` la calcule : + +* **la mémoire** — chaque étage garde de quoi faire tourner ses propres démons + (`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) avant de céder le + reste ; +* **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit + aussi contenir son propre système. + +Ce qui se dégrade est mesuré, pas supposé : au-delà du deuxième étage, les +fabricants ne documentent rien. D'où un seul nombre borné — **2 vCPU** pour +tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invité en +tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs +en ligne coûte autant d'allers-retours à travers toute la pile. + +La mémoire n'est **pas** bornée : la même VM gelait au même octet avec 9 Go et +avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. + +Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script +ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer +six étages et en réussir six que d'en promettre dix et mourir au septième sans +savoir pourquoi. \ No newline at end of file diff --git a/LongTest/README.md b/LongTest/README.md new file mode 100644 index 0000000..3da0560 --- /dev/null +++ b/LongTest/README.md @@ -0,0 +1,57 @@ + +# LongTest — tests that create real machines + +These are not unit tests. They create virtual machines, install systems on +them, and take hours. They live here and **not** in `test/`, which the unit +runner sweeps: `./script/test/run_unit_test.sh` must stay runnable in seconds +on any machine, including one without virtualisation. + +Run them from the menu — `TODO › Execute › Test › Long tests` — or directly. + +## deep_proxmox.py — how deep does Proxmox-in-Proxmox go? + +The practicable nesting depth cannot be deduced, only measured. A manual +measurement found, at the fourth level, a guest **36 times slower than real +time** — 583 seconds of wall clock for 16 seconds of guest time, each ACPI +line taking a second — then a guest kernel frozen at the **same byte** +whatever the resources. A number obtained once, on one machine, is not a +number: this script redoes it on demand and says exactly where it breaks. + +``` +./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created +./LongTest/deep_proxmox.py --depth 10 # hours +./LongTest/deep_proxmox.py --detruire # undo it +``` + +The descent is **uniform**. Every level, the first included, goes through the +same six steps: create, wait for ssh, install Proxmox, reboot and check the +kernel, bring pmxcfs back up, check the storage. Only creation differs — +libvirt locally, `qm` afterwards. + +It sends **our** `install_proxmox.sh` over scp instead of letting the VM clone +the repository: it is our code we want to exercise, and the remote is often +behind the checkout — a fix absent from the remote made the same defect "come +back" on three VMs in a row. + +### The resource algorithm + +Two things run out going down, and a third degrades. What runs out is +arithmetic, and `script/proxmox/nesting.py` computes it: + +* **memory** — each level keeps what its own daemons need (`pve-cluster`, + `pvestatd`, `pvedaemon`, `pveproxy`) before handing the rest down; +* **disk** — the child's disk lives *inside* the parent's, which must also + hold its own system. + +What degrades is measured, not assumed: past the second level, vendors +document nothing. Hence one capped number — **2 vCPU** for every nested +level. Twelve vCPU at the fourth level froze the guest kernel in early boot; +the same two progressed. Bringing twelve processors online costs as many +round trips through the whole stack. + +Memory is **not** capped: the same VM froze at the same byte with 9 GB and +with 2 GB, so trimming it would gain nothing and starve the level below. + +The plan is printed **before** anything is created, and the script never +promises a depth it knows will not fit — better to announce six levels and +reach six than to promise ten and die at the seventh without knowing why. diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py new file mode 100755 index 0000000..1bed45f --- /dev/null +++ b/LongTest/deep_proxmox.py @@ -0,0 +1,609 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ? + +Ce n'est pas un test unitaire : il crée de vraies machines et prend des +HEURES. Il vit donc hors de `test/`, que le lanceur unitaire balaie. + +Ce qu'il établit, et pourquoi cela valait un script : la profondeur +d'imbrication praticable ne se déduit pas, elle se mesure. Une mesure à la +main a montré, au quatrième étage, un invité 36 fois plus lent que le temps +réel — 583 secondes d'horloge pour 16 secondes de temps invité — puis un noyau +gelé au MÊME octet quelles que soient les ressources. Un chiffre obtenu une +fois, sur une machine, n'est pas un chiffre : ce script le refait à la demande +et dit exactement OÙ ça casse. + +La descente est UNIFORME. Chaque étage, le premier compris, passe par les +mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et +vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la +création diffère — libvirt en local, « qm » ensuite. + +Il envoie NOTRE install_proxmox.sh par scp au lieu de laisser la VM cloner le +dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant est souvent +en retard sur le checkout — un correctif absent du distant a fait « revenir » +le même défaut sur trois VM de suite. + + ./LongTest/deep_proxmox.py --depth 10 --dry-run + ./LongTest/deep_proxmox.py --depth 10 + ./LongTest/deep_proxmox.py --detruire # défait ce que la descente a posé +""" + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys +import time + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, RACINE) + +from script.proxmox import nesting # noqa: E402 +from script.proxmox import proxmox_deploy as pve # noqa: E402 + +# L'image des étages imbriqués. Debian parce que install_proxmox.sh s'installe +# SUR une Debian — Proxmox ne publie pas d'image cloud. +DISTRO = "proxmox" +NOM_BASE = "deep-pve" + +# Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le +# journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent +# que le précédent — c'est précisément ce qu'on mesure. +DELAIS = { + "creation": 1200, + "ssh": 2400, + "install": 7200, + "reboot": 2400, + "reparation": 600, + "controle": 180, +} + + +def dire(msg, journal=None): + ligne = f"[{time.strftime('%H:%M:%S')}] {msg}" + print(ligne, flush=True) + if journal: + with open(journal, "a", encoding="utf-8") as fh: + fh.write(ligne + "\n") + + +def capacite_hote(): + """(cœurs, RAM disponible en Mo, disque libre en Go) de la machine réelle. + + « available » et non « free » : c'est ce que le noyau promet de rendre sans + mettre la machine à genoux. + """ + coeurs = os.cpu_count() or 2 + ram = 0 + try: + with open("/proc/meminfo", encoding="utf-8") as fh: + for ligne in fh: + if ligne.startswith("MemAvailable:"): + ram = int(ligne.split()[1]) // 1024 + break + except OSError: + pass + disque = 0 + try: + st = os.statvfs("/var/lib/libvirt/images") + disque = (st.f_bavail * st.f_frsize) // (1024**3) + except OSError: + pass + return coeurs, ram, disque + + +def module_qemu(): + """deploy_qemu.py chargé comme module : il porte le catalogue d'images.""" + import importlib.util + + chemin = os.path.join(RACINE, "script/qemu/deploy_qemu.py") + spec = importlib.util.spec_from_file_location("deploy_qemu", chemin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def cle_publique(): + for nom in ("id_ed25519.pub", "id_rsa.pub"): + chemin = os.path.expanduser(f"~/.ssh/{nom}") + if os.path.exists(chemin): + return chemin + return "" + + +def nom_etage(niveau): + return f"{NOM_BASE}-{niveau}" + + +def alias_etage(niveau, parent_alias): + """« parent+enfant », la convention du dépôt : elle dit où la machine vit + et ne peut rien voler à un homonyme.""" + if not parent_alias: + return nom_etage(niveau) + court = re.sub(r"[^A-Za-z0-9._-]", "-", parent_alias) + return f"{court}+{nom_etage(niveau)}" + + +class Descente: + """Un étage après l'autre, et ce qu'on en sait.""" + + def __init__(self, plan, journal, dry_run=False): + self.plan = plan + self.journal = journal + self.dry_run = dry_run + self.etages = [] + + def dire(self, msg): + dire(msg, self.journal) + + # ---------------------------------------------------------------- # + # Parler aux machines + # ---------------------------------------------------------------- # + def executer(self, hote, remote, delai, etiquette, montrer=False): + if self.dry_run: + argv = pve.ssh_argv( + hote, pve.wrap_privilege(remote, hote.get("sudo") or "") + ) + print(" " + " ".join(shlex.quote(a) for a in argv)[:200]) + return 0, "" + debut = time.time() + code, sortie = pve.run(hote, remote, delai) + if code or montrer: + self.dire( + f" {etiquette} : code {code}" + f" en {int(time.time() - debut)} s" + ) + if code: + for ligne in pve.strip_ssh_noise(sortie).strip().splitlines()[-5:]: + self.dire(f" {ligne}") + return code, sortie + + def attendre_ssh(self, hote, delai): + """Attend que la machine réponde. Rend les secondes, ou None. + + Des connexions COURTES successives : cloud-init régénère les clés + d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une + session longue. + """ + if self.dry_run: + return 0 + debut = time.time() + while time.time() - debut < delai: + code, _o = pve.run(hote, "true", 30) + if code == 0: + return int(time.time() - debut) + time.sleep(15) + return None + + # ---------------------------------------------------------------- # + # Les six étapes, les mêmes à chaque étage + # ---------------------------------------------------------------- # + def installer_proxmox(self, hote): + """Envoie NOTRE script et l'exécute. Rend True si Proxmox est posé.""" + local = os.path.join(RACINE, "script/proxmox/install_proxmox.sh") + distant = "/tmp/install_proxmox.sh" + if self.dry_run: + print(f" scp {local} :{distant}") + print(f" sh {distant}") + return True + argv = pve.ssh_argv(hote, "")[:-1] # les options, sans la commande + cible = argv[-1] + options = argv[1:-1] + res = subprocess.run( + ["scp", "-q"] + options + [local, f"{cible}:{distant}"], + capture_output=True, + text=True, + timeout=300, + ) + if res.returncode: + self.dire(f" ✗ scp : {res.stderr.strip()[:200]}") + return False + code, _o = self.executer( + dict(hote, sudo=""), + f"sh {distant}", + DELAIS["install"], + "install_proxmox.sh", + montrer=True, + ) + return code == 0 + + def redemarrer_et_verifier(self, hote): + """Redémarre, attend le retour, exige le noyau Proxmox. + + Le script pose le noyau sans redémarrer — lancé par ssh, un reboot + couperait sa session et ferait passer l'installation pour un échec. + Sans ce redémarrage, la machine reste sur le noyau cloud de Debian, + dépouillé de tout netfilter : ni pont NAT, ni invité. + """ + if self.dry_run: + print(" reboot puis attente de *-pve dans uname -r") + return True + pve.run(hote, "systemctl reboot", 60) + debut = time.time() + while time.time() - debut < DELAIS["reboot"]: + time.sleep(20) + code, out = pve.run(dict(hote, sudo=""), "uname -r", 30) + noyau = pve.strip_ssh_noise(out).strip() + if code == 0 and "-pve" in noyau: + self.dire( + f" noyau {noyau} après {int(time.time() - debut)} s" + ) + return True + self.dire(" ✗ pas revenue sur un noyau -pve") + return False + + def reparer_pmxcfs(self, hote): + """Gel de cloud-init, /etc/hosts, unités, constat du montage.""" + if self.dry_run: + print(" gel cloud-init + /etc/hosts + unités + montage") + return True + _c, out = pve.run( + dict(hote, sudo=""), 'printf %s "$SSH_CONNECTION"', 30 + ) + ip = pve.ssh_server_ip(out) + if not ip: + self.dire(" ✗ adresse d'accès inconnue") + return False + for cmd, etiquette in ( + (pve.cloud_hosts_freeze_cmd(), "gel cloud-init"), + (pve.hosts_repair_cmd(ip), "/etc/hosts"), + ): + code, sortie = self.executer( + hote, cmd, DELAIS["reparation"], etiquette + ) + if code or "-KO" in pve.strip_ssh_noise(sortie): + self.dire(f" ✗ {etiquette}") + return False + for unite in pve.PVE_UNITS: + self.executer( + hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite + ) + _c, out = self.executer( + hote, pve.mount_wait_cmd(), DELAIS["reparation"], "montage" + ) + vu = pve.parse_mount_wait(out) + self.dire(f" /etc/pve : {vu['verdict']}") + return vu["verdict"] == "MONTE" + + def preparer_parent(self, parent): + """Stockage, pont et réseau interne du parent, ou None.""" + _c, out = self.executer( + parent, + "pvesm status --content images", + DELAIS["controle"], + "pvesm", + ) + stockage = pve.pick_storage(pve.parse_storages(out)) + if not stockage and not self.dry_run: + self.dire(" ✗ aucun stockage sur le parent") + return None + _c, out = self.executer( + parent, "ip -o link show type bridge", DELAIS["controle"], "ponts" + ) + ponts = pve.parse_bridges(out) + if not ponts: + _c, nets = self.executer( + parent, pve.USED_NETS_CMD, DELAIS["controle"], "réseaux" + ) + cidr = pve.pick_internal_cidr(nets) or pve.INTERNAL_CIDR + _c, rt = self.executer( + parent, + "ip -o -4 route show default", + DELAIS["controle"], + "uplink", + ) + trouve = re.search(r"dev\s+(\S+)", rt or "") + uplink = trouve.group(1) if trouve else "" + self.dire(f" pont {cidr}, NAT par {uplink or '—'}") + for cmd in pve.bridge_setup_cmds(cidr=cidr, uplink=uplink): + code, _o = self.executer( + parent, cmd, DELAIS["reparation"], "pont" + ) + if code and not self.dry_run: + return None + ponts = [pve.INTERNAL_BRIDGE] + _c, cfg = self.executer( + parent, + "cat /etc/network/interfaces", + DELAIS["controle"], + "interfaces", + ) + return ( + stockage or "local", + ponts[0], + pve.parse_bridge_config(cfg).get(ponts[0], {}), + ) + + def creer_etage1(self, res): + """Une VM locale, par la CLI QEMU/KVM.""" + nom = nom_etage(1) + argv = [ + os.path.join(RACINE, ".venv.erplibre/bin/python"), + os.path.join(RACINE, "script/qemu/deploy_qemu.py"), + "--distro", + DISTRO, + "--name", + nom, + "--vcpus", + str(res["vcpu"]), + "--memory", + str(res["ram"]), + "--disk-size", + f"{res['disque']}G", + ] + pub = cle_publique() + if pub: + argv += ["--ssh-key", pub] + if self.dry_run: + print(" " + " ".join(shlex.quote(a) for a in argv)) + return nom + res_proc = subprocess.run(argv, timeout=DELAIS["creation"] * 3) + if res_proc.returncode: + self.dire(" ✗ la CLI QEMU/KVM a échoué") + return None + return nom + + def creer_enfant(self, parent, niveau, res, prepare): + """« qm create » sur le parent. Rend (vmid, adresse) ou (None, None).""" + stockage, pont, info_pont = prepare + mod = module_qemu() + version = mod.DISTROS[DISTRO][1] + code_img = mod.DISTROS[DISTRO][0][version][0] + url = mod.image_url(DISTRO, code_img, "amd64", version) + image = mod.default_image_name(DISTRO, code_img, "amd64", version) + _c, out = self.executer( + parent, "qm list", DELAIS["controle"], "qm list" + ) + vmid = pve.next_vmid(pve.parse_qm_list(out)) + ipconfig = pve.ipconfig_for(info_pont, vmid) + adresse = pve.ip_from_ipconfig(ipconfig) + spec = { + "name": nom_etage(niveau), + "storage": stockage, + "image": image, + "memory": res["ram"], + "vcpus": res["vcpu"], + "bridge": pont, + "disk": f"{res['disque']}G", + "user": "erplibre", + "ipconfig": ipconfig, + "sshkey_path": "/root/.ssh/longtest.pub", + "start": True, + } + # La clé publique doit être un FICHIER sur le parent : « --sshkeys » + # n'accepte pas la clé en ligne. + pub = cle_publique() + if pub and not self.dry_run: + with open(pub, encoding="utf-8") as fh: + contenu = fh.read().strip() + self.executer( + parent, + f"mkdir -p /root/.ssh && printf '%s\\n'" + f" {shlex.quote(contenu)} > /root/.ssh/longtest.pub", + DELAIS["controle"], + "clé", + ) + for cmd in [pve.image_fetch_cmd(url, image)] + pve.create_cmds( + vmid, spec + ): + code, _o = self.executer( + parent, cmd, DELAIS["creation"], "qm create" + ) + if code and not self.dry_run: + return None, None + return vmid, adresse + + # ---------------------------------------------------------------- # + # La descente + # ---------------------------------------------------------------- # + def parcourir(self): + parent = None + parent_alias = "" + for res in self.plan["niveaux"]: + niveau = res["niveau"] + debut = time.time() + etage = { + "niveau": niveau, + "ressources": res, + "etape": "creation", + "ok": False, + } + self.dire( + f" ── étage {niveau} : {res['vcpu']} vCPU," + f" {res['ram']} Mo, {res['disque']} Go" + ) + if niveau == 1: + nom = self.creer_etage1(res) + if not nom: + self.etages.append(etage) + break + alias = nom + else: + prepare = self.preparer_parent(parent) + if not prepare: + etage["etape"] = "parent" + self.etages.append(etage) + break + vmid, adresse = self.creer_enfant(parent, niveau, res, prepare) + if vmid is None: + self.etages.append(etage) + break + etage["vmid"] = vmid + alias = alias_etage(niveau, parent_alias) + if not self.dry_run: + if not adresse: + # Sur un pont interne l'adresse est FIXE et dérivée du + # VMID. Vide, c'est que le parent n'a pas de pont + # interne — écrire un alias sans HostName donnerait + # une entrée qui ne mène nulle part. + self.dire(" ✗ pas d'adresse fixe pour l'enfant") + etage["etape"] = "adresse" + self.etages.append(etage) + break + self.ecrire_alias(alias, adresse, parent_alias) + cible = {"target": alias, "sudo": "sudo ", "jump": ""} + etage["alias"] = alias + + etage["etape"] = "ssh" + attente = self.attendre_ssh(cible, DELAIS["ssh"]) + if attente is None: + self.dire(" ✗ jamais joignable en ssh") + self.etages.append(etage) + break + etage["ssh_secondes"] = attente + self.dire(f" ssh après {attente} s") + + for etape, action in ( + ("install", lambda: self.installer_proxmox(cible)), + ("reboot", lambda: self.redemarrer_et_verifier(cible)), + ("pmxcfs", lambda: self.reparer_pmxcfs(cible)), + ): + etage["etape"] = etape + if not action(): + self.etages.append(etage) + return self.rapport(interrompu=True) + + etage["etape"] = "termine" + etage["ok"] = True + etage["secondes"] = int(time.time() - debut) + self.etages.append(etage) + self.dire(f" ✓ étage {niveau} en {etage['secondes']} s") + parent, parent_alias = cible, alias + return self.rapport() + + def ecrire_alias(self, alias, adresse, parent_alias): + """Une entrée ~/.ssh/config pour joindre l'enfant à travers le parent.""" + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + prive = cle_publique()[:-4] if cle_publique() else None + todo._write_ssh_config_entry( + [alias], + "erplibre", + adresse, + proxy_jump=parent_alias or None, + identity_file=prive, + ) + + def rapport(self, interrompu=False): + atteint = sum(1 for e in self.etages if e["ok"]) + print("") + self.dire( + f" profondeur atteinte : {atteint} / {self.plan['demandee']}" + ) + for e in self.etages: + marque = "✓" if e["ok"] else "✗" + detail = f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"] + self.dire(f" {marque} étage {e['niveau']:2d} {detail}") + return { + "demandee": self.plan["demandee"], + "atteignable": self.plan["atteignable"], + "atteinte": atteint, + "interrompu": interrompu, + "etages": self.etages, + } + + +def detruire(journal=None): + """Défait ce que la descente a posé, du plus profond au plus haut. + + Du plus profond : détruire un parent d'abord emporterait ses enfants sans + qu'on ait pu les nommer, et laisserait des entrées ssh vers rien. + """ + from script.todo.todo import TODO + + hosts = [h for h in TODO._ssh_config_hosts() if NOM_BASE in h] + hosts.sort(key=lambda h: -h.count("+")) + dire(f" {len(hosts)} entrée(s) ssh à défaire", journal) + for alias in hosts: + bloc = TODO._ssh_config_block(alias) + saut = (bloc or {}).get("proxyjump") + if saut: + parent = {"target": saut, "sudo": "sudo ", "jump": ""} + _c, out = pve.run(parent, "qm list", 120) + for vm in pve.parse_qm_list(out): + if NOM_BASE in (vm.get("name") or ""): + dire(f" qm destroy {vm['vmid']} sur {saut}", journal) + pve.run( + parent, + f"qm stop {vm['vmid']} --skiplock 1 || true;" + f" qm destroy {vm['vmid']} --purge 1", + 300, + ) + for niveau in range(1, 30): + nom = nom_etage(niveau) + if nom in hosts or niveau == 1: + subprocess.run( + ["sudo", "virsh", "destroy", nom], + capture_output=True, + ) + subprocess.run( + [ + "sudo", + "virsh", + "undefine", + nom, + "--nvram", + "--remove-all-storage", + ], + capture_output=True, + ) + dire( + " ✓ défait. Les entrées ssh orphelines : menu de nettoyage.", journal + ) + + +def principal(argv=None): + parseur = argparse.ArgumentParser( + description="Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?" + ) + parseur.add_argument("--depth", type=int, default=10) + parseur.add_argument("--dry-run", action="store_true") + parseur.add_argument("--detruire", action="store_true") + args = parseur.parse_args(argv) + + journal = os.path.expanduser( + f"~/.erplibre/longtest/deep-pve-{time.strftime('%Y%m%d-%H%M%S')}.log" + ) + os.makedirs(os.path.dirname(journal), exist_ok=True) + if args.detruire: + detruire(journal) + return 0 + + coeurs, ram, disque = capacite_hote() + print( + f"\n machine : {coeurs} cœurs, {ram} Mo disponibles," + f" {disque} Go de disque" + ) + plan = nesting.nesting_plan(args.depth, coeurs, ram, disque) + print(f"\n {'étage':>5} {'vCPU':>4} {'RAM':>9} {'disque':>8}") + for n in plan["niveaux"]: + print( + f" {n['niveau']:>5} {n['vcpu']:>4} {n['ram']:>6} Mo" + f" {n['disque']:>5} Go" + ) + if plan["arret"]: + print( + f"\n ⚠ demandée {plan['demandee']}, atteignable" + f" {plan['atteignable']} — manque de {plan['arret']}" + ) + if not plan["niveaux"]: + print("\n ✗ pas même un étage ne tient sur cette machine.\n") + return 1 + print(f"\n journal : {journal}") + if args.dry_run: + print(" --dry-run : rien ne sera créé.\n") + descente = Descente(plan, journal, args.dry_run) + rapport = descente.parcourir() + chemin = journal[:-4] + ".json" + with open(chemin, "w", encoding="utf-8") as fh: + json.dump(rapport, fh, indent=2) + print(f"\n rapport : {chemin}\n") + return 0 if rapport["atteinte"] else 1 + + +if __name__ == "__main__": + sys.exit(principal()) diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py new file mode 100644 index 0000000..dc2ec52 --- /dev/null +++ b/script/proxmox/nesting.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Combien d'étages de Proxmox tiennent, et avec quelles ressources. + +Un Proxmox dans un Proxmox dans un Proxmox : chaque étage est un hyperviseur +qui héberge le suivant. Deux choses s'épuisent en descendant, et une troisième +se dégrade. + +Ce qui s'ÉPUISE — et c'est de l'arithmétique : + +* la mémoire. Chaque étage garde de quoi faire tourner ses propres démons + (pve-cluster, pvestatd, pvedaemon, pveproxy) avant de céder le reste ; +* le disque. Le disque de l'enfant vit DANS celui du parent, qui doit aussi + contenir son propre système. + +Ce qui se DÉGRADE — et c'est mesuré, pas supposé. Au quatrième étage, sur un +hôte AMD, une VM tournait 36 fois moins vite que le temps réel : 583 secondes +d'horloge pour 16 secondes de temps invité, chaque ligne d'ACPI prenant une +seconde. Chaque sortie de VM traverse tous les hyperviseurs empilés, et AMD ne +documente l'imbrication qu'à DEUX niveaux. + +Deux nombres viennent de la même mesure, et méritent d'être dits : + +* 12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début de + démarrage — même RIP à trois relevés, deux minutes d'écart, pas un octet lu + de plus. Les mêmes 2 vCPU avançaient. D'où VCPU_IMBRIQUE = 2 : amener douze + processeurs en ligne demande autant d'allers-retours à travers la pile ; +* la même VM s'arrêtait ensuite au MÊME octet — 33 682 432 — quelles que + soient les ressources, dans la réservation des tables ACPI. Ce mur-là n'est + pas une question de taille, et aucun réglage ici ne le déplacera. La + profondeur RÉELLEMENT atteignable se mesure ; ce module ne calcule que ce + qui est arithmétiquement possible. +""" + +# Ce qu'on laisse à la machine physique : elle fait tourner l'orchestrateur, +# le menu TODO, et le premier QEMU. +HOTE_RESERVE_RAM_MO = 4096 +HOTE_RESERVE_DISQUE_GO = 20 + +# Ce qu'un étage garde pour lui avant de céder le reste. La RAM vient de +# l'observation d'un Proxmox imbriqué au repos ; le disque, de la mesure d'un +# système installé (5,6 Go) plus de la place pour écrire. +PVE_RAM_MO = 2048 +PVE_DISQUE_GO = 10 + +# En dessous, un Proxmox ne démarre pas ses démons ou n'a plus la place +# d'importer une image cloud. +RAM_MIN_MO = 2048 +DISQUE_MIN_GO = 15 + +# Le premier étage tourne sur la machine physique : il peut être large. Les +# suivants non — voir la mesure dans l'en-tête. +VCPU_NIVEAU1_MAX = 4 +VCPU_IMBRIQUE = 2 + +# Au-delà, l'imbrication n'est pas un terrain documenté par les fabricants. +# On ne refuse pas — on le DIT. +PROFONDEUR_SURE = 2 + + +def nesting_plan( + profondeur: int, + cpu_hote: int, + ram_dispo_mo: int, + disque_libre_go: int, +) -> dict: + """Les ressources de chaque étage, et jusqu'où l'arithmétique va. + + Rend {"demandee", "atteignable", "niveaux": [...], "arret"}. `arret` + nomme ce qui a manqué — « ram » ou « disque » — quand la profondeur + demandée n'est pas atteinte, sinon "". + + On ne rend jamais un plan qu'on sait impossible : mieux vaut annoncer six + étages et en réussir six que d'en promettre dix et mourir au septième + sans savoir pourquoi. + """ + # Arrondi au gibioctet inférieur : « --memory 25203 » marche, mais un + # nombre rond se relit, se compare d'un étage à l'autre, et évite de + # traîner les kibioctets du hasard de la mesure jusqu'au dixième étage. + ram = ((int(ram_dispo_mo) - HOTE_RESERVE_RAM_MO) // 1024) * 1024 + disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO + niveaux, arret = [], "" + for niveau in range(1, max(1, int(profondeur)) + 1): + if niveau > 1: + ram -= PVE_RAM_MO + disque -= PVE_DISQUE_GO + if ram < RAM_MIN_MO: + arret = "ram" + break + if disque < DISQUE_MIN_GO: + arret = "disque" + break + niveaux.append( + { + "niveau": niveau, + "vcpu": ( + max(1, min(VCPU_NIVEAU1_MAX, int(cpu_hote) // 4)) + if niveau == 1 + else VCPU_IMBRIQUE + ), + "ram": ram, + "disque": disque, + } + ) + return { + "demandee": int(profondeur), + "atteignable": len(niveaux), + "niveaux": niveaux, + "arret": arret, + } + + +def depth_from_jumps(jumps: int) -> int: + """Profondeur d'un hôte, comptée depuis sa chaîne de rebonds. + + Un hôte joint sans rebond est au niveau 1 ; chaque ProxyJump ajoute un + étage. C'est la seule mesure dont on dispose de l'extérieur, et elle est + exacte pour les hôtes que nous avons nous-mêmes déployés — c'est nous qui + écrivons ces entrées. + """ + return max(1, int(jumps) + 1) + + +def capped_for_depth(profondeur: int, vcpu: int, ram_mo: int) -> tuple: + """Ressources bornées pour cette profondeur, et pourquoi. + + Rend (vcpu, ram, raison). `raison` vide quand rien n'a été touché. + + Seul le vCPU est borné, et la mesure le dit : la même VM au quatrième + étage gelait au MÊME octet avec 9 Go et avec 2 Go — la mémoire n'est pas + le levier. Douze vCPU, en revanche, gelaient plus tôt et plus dur que + deux. La RAM passe donc telle quelle : la rogner ne gagnerait rien et + priverait l'étage suivant. + + Pourquoi borner au lieu d'avertir seulement : l'écran lit la capacité de + l'HÔTE et l'offre en entier. Sur un troisième étage à 14 cœurs et 9 Go, il + a proposé 12 vCPU — et la VM n'a jamais démarré. Le nombre n'était pas + absurde pour la machine ; il l'était pour sa profondeur. + """ + vcpu, ram_mo = int(vcpu), int(ram_mo) + if profondeur <= PROFONDEUR_SURE or vcpu <= VCPU_IMBRIQUE: + return vcpu, ram_mo, "" + return ( + VCPU_IMBRIQUE, + ram_mo, + f"niveau {int(profondeur)} : {vcpu} vCPU -> {VCPU_IMBRIQUE}", + ) diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py new file mode 100644 index 0000000..3d05b2f --- /dev/null +++ b/script/todo/longtest_menu.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les tests LONGS : de vraies machines, des heures. + +Ils vivent dans `LongTest/` et non dans `test/`, et ce n'est pas un rangement +de confort : le lanceur unitaire balaie `test/test_*.py` et doit rester +lançable en quelques secondes, partout. Un test qui crée dix VM n'a rien à y +faire — il le ferait échouer sur toute machine sans virtualisation, et +personne ne l'attendrait. + +Ce menu ne fait que les lancer, en montrant leur sortie en direct : ces +scripts durent des heures, et une sortie capturée jusqu'à la fin ne dirait +rien pendant tout ce temps. +""" + +import os + +import click + +from script.todo.todo_i18n import t + +# Le répertoire des tests longs, à la racine du dépôt. +LONGTEST_DIR = "LongTest" + + +class LongTestMenuMixin: + def _longtest_script(self, nom): + """Chemin d'un test long, ou "" s'il n'est pas là.""" + chemin = os.path.join(os.getcwd(), LONGTEST_DIR, nom) + return chemin if os.path.exists(chemin) else "" + + def _longtest_run(self, nom, args=""): + """Lance un test long, sortie en DIRECT. + + En direct parce qu'il dure des heures : capturer sa sortie pour + l'afficher à la fin, c'est ne rien montrer pendant tout ce temps — + et c'est justement la progression étage par étage qui intéresse. + """ + chemin = self._longtest_script(nom) + if not chemin: + print(f" ✗ {t('Script not found:')} {LONGTEST_DIR}/{nom}") + return + cmd = f"./.venv.erplibre/bin/python {chemin}" + if args: + cmd += f" {args}" + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def prompt_execute_longtest(self): + print(f"⏳ {t('Long tests: real VMs, hours. Not the unit suite.')}") + choices = [ + { + "prompt_description": t( + "Nested Proxmox depth: plan only (dry-run)" + ) + }, + {"prompt_description": t("Nested Proxmox depth: run it")}, + {"prompt_description": t("Undo what the descent created")}, + ] + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + if status == "1": + self._longtest_run( + "deep_proxmox.py", + f"--depth {self._longtest_depth()} --dry-run", + ) + elif status == "2": + # La profondeur est DEMANDÉE : c'est le seul réglage du test, + # et il décide de sa durée — dix étages, c'est une nuit. + self._longtest_run( + "deep_proxmox.py", f"--depth {self._longtest_depth()}" + ) + elif status == "3": + self._longtest_run("deep_proxmox.py", "--detruire") + else: + print(t("Command not found !")) + + def _longtest_depth(self): + """Profondeur demandée. Dix par défaut : c'est ce qu'on veut mesurer.""" + brut = input(f"{t('Depth (default 10): ')}").strip() + return int(brut) if brut.isdigit() and int(brut) > 0 else 10 diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 3d86b7f..f128fc0 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -720,6 +720,46 @@ class ProxmoxMenuMixin: lignes.append(f"→ {t('This screen can repair it (see below).')}") return lignes + def _pve_depth(self, host): + """À quel étage d'imbrication se trouve cet hôte. 1 = machine réelle. + + Comptée sur la chaîne de ProxyJump : un rebond par étage. C'est nous + qui écrivons ces entrées, donc la mesure est exacte pour notre parc. + """ + from script.proxmox import nesting + + return nesting.depth_from_jumps( + self._ssh_jump_depth(host.get("target") or "") + ) + + def _pve_depth_note(self, host, cpu): + """(cpu borné, lignes à dire). Ce que la profondeur impose. + + L'écran lisait la capacité de l'HÔTE et l'offrait en entier. Sur un + troisième étage à 14 cœurs, il a proposé 12 vCPU — et la VM n'a jamais + démarré : même RIP à trois relevés deux minutes d'écart, pas un octet + lu de plus. Le nombre n'était pas absurde pour la machine ; il l'était + pour sa profondeur. + + Un seul levier, le vCPU : la même VM gelait au MÊME octet avec 9 Go et + avec 2 Go, donc rogner la mémoire ne gagnerait rien et priverait + l'étage suivant. + """ + from script.proxmox import nesting + + profondeur = self._pve_depth(host) + borne, _ram, raison = nesting.capped_for_depth(profondeur, cpu, 0) + if profondeur <= nesting.PROFONDEUR_SURE: + return cpu, [] + lignes = [ + f"⚠ {t('Nesting level')} {profondeur} —" + f" {t('vendors document two, not more.')}", + f" {t('Measured at level 4: 36x slower, then a frozen kernel.')}", + ] + if raison: + lignes.append(f" {t('vCPU capped to')} {borne}") + return borne, lignes + def _pve_ssh_ip(self, host): """Adresse par laquelle NOTRE ssh atteint l'hôte, ou "". @@ -1088,6 +1128,12 @@ class ProxmoxMenuMixin: _c, cfg = self._pve_show("cat /etc/network/interfaces", quiet=True) infos = pve.parse_bridge_config(cfg) cpu, ram_libre = self._pve_capacity() + # La profondeur borne ce que l'écran offre. Ici, terminal encore à + # nous : une fois Textual à l'affiche, ces lignes n'auraient nulle + # part où aller. + cpu, notes_profondeur = self._pve_depth_note(host, cpu) + for ligne in notes_profondeur: + print(f" {ligne}") # Le DNS de l'hôte, pour les VM en adresse fixe : sans lui elles # routent mais ne résolvent rien, et « apt update » échoue sans que # rien ne l'explique. Mesuré sur la VM d'essai. diff --git a/script/todo/todo.py b/script/todo/todo.py index 4ac7732..cd9acf1 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -28,6 +28,7 @@ from script.config import config_file from script.execute import execute from script.todo import todo_prefs from script.todo.database_manager import DatabaseManager +from script.todo.longtest_menu import LongTestMenuMixin from script.todo.proxmox_menu import ProxmoxMenuMixin from script.todo.qemu_access import QemuAccessMixin from script.todo.qemu_deploy import QemuDeployMixin @@ -96,6 +97,7 @@ class TODO( QemuManageMixin, QemuAccessMixin, ProxmoxMenuMixin, + LongTestMenuMixin, ): def __init__(self): self.dir_path = None @@ -1752,6 +1754,30 @@ class TODO( bloc[mots[0].lower()] = mots[1] return bloc or {} + @classmethod + def _ssh_jump_depth(cls, cible, maxi=12): + """Nombre de rebonds pour joindre `cible`, en suivant la chaîne. + + C'est la mesure de PROFONDEUR d'un hôte imbriqué, et la seule dont on + dispose de l'extérieur. Elle est exacte pour les hôtes que nous avons + déployés : c'est nous qui écrivons ces entrées, un ProxyJump par + étage. + + `maxi` borne le parcours : une boucle dans ~/.ssh/config — A qui + rebondit par B qui rebondit par A — tournerait sinon sans fin. + """ + vus, sauts = set(), 0 + courant = cible + while sauts < maxi: + bloc = cls._ssh_config_block(courant) + saut = (bloc or {}).get("proxyjump") + if not saut or saut in vus: + break + vus.add(saut) + courant = saut + sauts += 1 + return sauts + @staticmethod def _ssh_config_user(host): """`User` déclaré pour cet hôte dans ~/.ssh/config, ou "". @@ -4134,6 +4160,9 @@ class TODO( {"prompt_description": t("ERPLibre unit tests")}, {"prompt_description": t("Mail unit tests")}, {"prompt_description": t("Analyse unit tests")}, + # Hors de la suite unitaire, et le libellé le dit : ceux-là créent + # de vraies machines et durent des heures. + {"prompt_description": t("Long tests - real VMs, hours")}, ] help_info = self.fill_help_info(choices) @@ -4152,6 +4181,8 @@ class TODO( self.execute_unit_tests("test_mail*.py") elif status == "5": self.execute_unit_tests("test_analyse*.py") + elif status == "6": + self.prompt_execute_longtest() else: print(t("Command not found !")) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 213b843..95d1d13 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3422,6 +3422,50 @@ TRANSLATIONS = { "fr": "L'adresse écrite n'est peut-être pas celle qu'il faut à pmxcfs.", "en": "The address written may not be the one pmxcfs needs.", }, + "Long tests - real VMs, hours": { + "fr": "⏳ Tests longs - vraies VM, des heures", + "en": "⏳ Long tests - real VMs, hours", + }, + "Long tests: real VMs, hours. Not the unit suite.": { + "fr": "Tests longs : de vraies VM, des heures. Pas la suite unitaire.", + "en": "Long tests: real VMs, hours. Not the unit suite.", + }, + "Nested Proxmox depth: plan only (dry-run)": { + "fr": "Profondeur Proxmox imbriqué : le plan seulement (à blanc)", + "en": "Nested Proxmox depth: plan only (dry-run)", + }, + "Nested Proxmox depth: run it": { + "fr": "Profondeur Proxmox imbriqué : le lancer", + "en": "Nested Proxmox depth: run it", + }, + "Undo what the descent created": { + "fr": "Défaire ce que la descente a créé", + "en": "Undo what the descent created", + }, + "Script not found:": { + "fr": "Script introuvable :", + "en": "Script not found:", + }, + "Depth (default 10): ": { + "fr": "Profondeur (défaut 10) : ", + "en": "Depth (default 10): ", + }, + "Nesting level": { + "fr": "Étage d'imbrication", + "en": "Nesting level", + }, + "vendors document two, not more.": { + "fr": "les fabricants en documentent deux, pas plus.", + "en": "vendors document two, not more.", + }, + "Measured at level 4: 36x slower, then a frozen kernel.": { + "fr": "Mesuré au niveau 4 : 36x plus lent, puis noyau gelé.", + "en": "Measured at level 4: 36x slower, then a frozen kernel.", + }, + "vCPU capped to": { + "fr": "vCPU borné à", + "en": "vCPU capped to", + }, "pve-cluster is down: /etc/pve is not mounted.": { "fr": "pve-cluster est à terre : /etc/pve n'est pas monté.", "en": "pve-cluster is down: /etc/pve is not mounted.", diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py new file mode 100644 index 0000000..1cb7eaf --- /dev/null +++ b/test/test_proxmox_nesting.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Combien d'étages de Proxmox tiennent, et avec quelles ressources. + +L'écran de déploiement lisait la capacité de l'HÔTE et l'offrait en entier. +Sur un troisième étage à 14 cœurs et 9 Go de libre, il a proposé 12 vCPU et +9 Go à une VM qui n'a jamais démarré : même RIP à trois relevés deux minutes +d'écart, pas un octet lu de plus. Le nombre n'était pas absurde pour la +machine ; il l'était pour sa profondeur. +""" + +import sys +import unittest + +sys.argv = ["todo.py"] +from script.proxmox import nesting # noqa: E402 + + +class TestLePlanDesEtages(unittest.TestCase): + """Deux ressources s'épuisent en descendant, et le plan doit le dire + AVANT de créer quoi que ce soit.""" + + # La machine réelle sur laquelle l'algorithme a été réglé. + HOTE = dict(cpu_hote=28, ram_dispo_mo=29549, disque_libre_go=139) + + def test_ten_levels_fit_on_this_machine(self): + plan = nesting.nesting_plan(10, **self.HOTE) + self.assertEqual(plan["atteignable"], 10) + self.assertEqual(plan["arret"], "") + + def test_every_level_shrinks(self): + # Le disque de l'enfant vit DANS celui du parent, qui doit aussi + # contenir son propre système : rien ne peut rester constant. + niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"] + for precedent, suivant in zip(niveaux, niveaux[1:]): + self.assertLess(suivant["ram"], precedent["ram"]) + self.assertLess(suivant["disque"], precedent["disque"]) + + def test_the_first_level_may_be_wide_the_others_not(self): + """12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début + de démarrage ; les mêmes 2 vCPU avançaient. Amener douze processeurs + en ligne demande autant d'allers-retours à travers la pile.""" + niveaux = nesting.nesting_plan(4, **self.HOTE)["niveaux"] + self.assertGreater(niveaux[0]["vcpu"], nesting.VCPU_IMBRIQUE - 1) + for n in niveaux[1:]: + self.assertEqual(n["vcpu"], nesting.VCPU_IMBRIQUE) + + def test_running_out_of_ram_is_named(self): + plan = nesting.nesting_plan( + 10, cpu_hote=8, ram_dispo_mo=12288, disque_libre_go=500 + ) + self.assertEqual(plan["arret"], "ram") + self.assertLess(plan["atteignable"], 10) + # Aucun étage sous le plancher : un Proxmox sous 2 Go ne démarre pas + # ses démons. + for n in plan["niveaux"]: + self.assertGreaterEqual(n["ram"], nesting.RAM_MIN_MO) + + def test_running_out_of_disk_is_named(self): + plan = nesting.nesting_plan( + 10, cpu_hote=8, ram_dispo_mo=200000, disque_libre_go=60 + ) + self.assertEqual(plan["arret"], "disque") + for n in plan["niveaux"]: + self.assertGreaterEqual(n["disque"], nesting.DISQUE_MIN_GO) + + def test_a_machine_too_small_for_even_one_level(self): + plan = nesting.nesting_plan( + 3, cpu_hote=2, ram_dispo_mo=4096, disque_libre_go=200 + ) + self.assertEqual(plan["atteignable"], 0) + self.assertEqual(plan["niveaux"], []) + self.assertEqual(plan["arret"], "ram") + + def test_a_plan_is_never_promised_beyond_what_fits(self): + # Mieux vaut annoncer six étages et en réussir six que d'en promettre + # dix et mourir au septième sans savoir pourquoi. + for profondeur in range(1, 13): + plan = nesting.nesting_plan(profondeur, **self.HOTE) + self.assertEqual(len(plan["niveaux"]), plan["atteignable"]) + self.assertLessEqual(plan["atteignable"], profondeur) + + +class TestLaProfondeurDUnHote(unittest.TestCase): + """Comptée depuis la chaîne de rebonds : c'est la seule mesure dont on + dispose de l'extérieur, et elle est exacte pour les hôtes que nous avons + nous-mêmes déployés — c'est nous qui écrivons ces entrées.""" + + def test_no_jump_is_the_first_level(self): + self.assertEqual(nesting.depth_from_jumps(0), 1) + + def test_each_jump_adds_a_level(self): + for sauts, attendu in ((1, 2), (2, 3), (3, 4), (9, 10)): + self.assertEqual(nesting.depth_from_jumps(sauts), attendu) + + +class TestCompterLesRebonds(unittest.TestCase): + """La profondeur se lit dans ~/.ssh/config : un ProxyJump par étage. + + Hermétique — une configuration synthétique. La vraie a été nettoyée entre + deux mesures, et un test qui dépend de la machine qui le lance ne prouve + rien le lendemain.""" + + CONFIG = """ +Host niveau1 + HostName 192.168.1.10 + +Host niveau2 + HostName 10.10.10.150 + ProxyJump niveau1 + +Host niveau3 + HostName 10.10.20.150 + ProxyJump niveau2 + +Host niveau4 + HostName 10.10.10.150 + ProxyJump niveau3 + +Host boucle-a + ProxyJump boucle-b + +Host boucle-b + ProxyJump boucle-a +""" + + def setUp(self): + import os + import tempfile + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + self.maison = tempfile.mkdtemp() + os.makedirs(os.path.join(self.maison, ".ssh")) + with open( + os.path.join(self.maison, ".ssh/config"), "w", encoding="utf-8" + ) as fh: + fh.write(self.CONFIG) + self._vrai = os.environ.get("HOME") + os.environ["HOME"] = self.maison + self.TODO = TODO + + def tearDown(self): + import os + import shutil + + if self._vrai is not None: + os.environ["HOME"] = self._vrai + shutil.rmtree(self.maison, ignore_errors=True) + + def test_each_level_is_counted(self): + for nom, attendu in ( + ("niveau1", 1), + ("niveau2", 2), + ("niveau3", 3), + ("niveau4", 4), + ): + with self.subTest(hote=nom): + sauts = self.TODO._ssh_jump_depth(nom) + self.assertEqual(nesting.depth_from_jumps(sauts), attendu) + + def test_an_unknown_host_is_the_first_level(self): + self.assertEqual(self.TODO._ssh_jump_depth("jamais-vu"), 0) + + def test_a_loop_does_not_spin_forever(self): + # A rebondit par B qui rebondit par A : sans garde, le parcours ne + # s'arrête jamais. + self.assertLessEqual(self.TODO._ssh_jump_depth("boucle-a"), 2) + + +class TestBornerCeQueLEcranOffre(unittest.TestCase): + def test_the_first_two_levels_are_left_alone(self): + # L'imbrication à deux niveaux est documentée par les fabricants : on + # n'a rien à corriger là. + for profondeur in (1, 2): + self.assertEqual( + nesting.capped_for_depth(profondeur, 12, 9216), + (12, 9216, ""), + ) + + def test_beyond_that_the_vcpu_is_capped_and_said(self): + vcpu, ram, raison = nesting.capped_for_depth(3, 12, 9216) + self.assertEqual(vcpu, nesting.VCPU_IMBRIQUE) + self.assertTrue(raison) + self.assertIn("12", raison) + + def test_the_ram_is_never_touched(self): + """La même VM gelait au MÊME octet avec 9 Go et avec 2 Go : la + mémoire n'est pas le levier. La rogner ne gagnerait rien et priverait + l'étage suivant.""" + for profondeur in (1, 3, 8): + _v, ram, _r = nesting.capped_for_depth(profondeur, 12, 9216) + self.assertEqual(ram, 9216) + + def test_a_modest_request_is_not_reported_as_capped(self): + # Rien n'a bougé : ne rien dire. Un avertissement à chaque + # déploiement finit par ne plus être lu. + self.assertEqual(nesting.capped_for_depth(5, 2, 4096), (2, 4096, "")) + self.assertEqual(nesting.capped_for_depth(5, 1, 4096), (1, 4096, "")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py new file mode 100644 index 0000000..0202412 --- /dev/null +++ b/test/test_todo_longtest.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les tests LONGS : qu'ils existent, qu'ils annoncent, et qu'ils ne +polluent pas la suite unitaire. + +Un test qui crée dix VM n'a rien à faire dans `test/` : le lanceur unitaire +doit rester lançable en quelques secondes, partout, y compris sur une machine +sans virtualisation. Ce fichier-ci vérifie la frontière, et que l'essai à +blanc du test long dit quelque chose sans rien créer. +""" + +import os +import subprocess +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PYTHON = os.path.join(RACINE, ".venv.erplibre/bin/python") + + +class TestLaFrontiere(unittest.TestCase): + """LongTest est hors de portée du lanceur unitaire, et ce n'est pas un + rangement de confort.""" + + def test_the_unit_runner_does_not_sweep_LongTest(self): + with open( + os.path.join(RACINE, "script/test/run_unit_test.sh"), + encoding="utf-8", + ) as fh: + lanceur = fh.read() + # Le lanceur ne liste que des fichiers de test/ : rien qui parte de + # LongTest, sinon la suite unitaire créerait des VM. + self.assertNotIn("LongTest", lanceur) + + def test_the_naming_rule_is_written_where_it_is_read(self): + # Un fichier hors préfixe tombe dans le même silence qu'un fichier + # absent : douze tests écrits, jamais lancés. + with open( + os.path.join(RACINE, "script/test/run_unit_test.sh"), + encoding="utf-8", + ) as fh: + self.assertIn("NOMMER UN NOUVEAU FICHIER", fh.read()) + + def test_the_script_is_executable_and_documented(self): + script = os.path.join(RACINE, "LongTest/deep_proxmox.py") + self.assertTrue(os.access(script, os.X_OK), "doit être exécutable") + # La doc est un .base.md : un .md généré se perd au prochain + # « make doc_markdown ». + self.assertTrue( + os.path.exists(os.path.join(RACINE, "LongTest/README.base.md")) + ) + + +class TestLEssaiABlanc(unittest.TestCase): + """L'essai à blanc annonce le plan et n'exécute RIEN. + + C'est ce qui rend un test de plusieurs heures relisable avant de le + lancer : on voit les ressources de chaque étage et les commandes, sans + créer une machine.""" + + @classmethod + def setUpClass(cls): + cls.res = subprocess.run( + [ + PYTHON, + os.path.join(RACINE, "LongTest/deep_proxmox.py"), + "--depth", + "4", + "--dry-run", + ], + capture_output=True, + text=True, + timeout=180, + cwd=RACINE, + env=dict(os.environ, PYTHONPATH=RACINE), + ) + + def test_it_exits_cleanly(self): + self.assertEqual(self.res.returncode, 0, self.res.stderr[-800:]) + + def test_it_announces_the_plan_before_anything(self): + sortie = self.res.stdout + self.assertIn("étage", sortie) + # Quatre étages demandés, quatre lignes de plan. + for niveau in ("1", "2", "3", "4"): + self.assertIn(niveau, sortie) + self.assertIn("dry-run", sortie) + + def test_it_shows_the_commands_it_would_send(self): + # Une étape affichée est une étape rejouable à la main : c'est ainsi + # que les pannes de ce module ont été diagnostiquées. + self.assertIn("qm create", self.res.stdout) + self.assertIn("install_proxmox.sh", self.res.stdout) + + def test_the_first_level_is_wide_and_the_others_are_not(self): + # 12 vCPU au quatrième étage ont gelé un noyau invité ; deux + # avançaient. + # Par expression exacte : la ligne « machine : … Mo … Go » du haut + # contient les mêmes unités et décalait l'index d'un cran. + import re + + plan = re.findall( + r"^\s+(\d+)\s+(\d+)\s+(\d+) Mo\s+(\d+) Go\s*$", + self.res.stdout, + re.M, + ) + self.assertEqual(len(plan), 4, plan) + niveaux = {int(n): int(v) for n, v, _r, _d in plan} + self.assertGreater(niveaux[1], 1, "le premier étage peut être large") + for niveau in (2, 3, 4): + self.assertEqual(niveaux[niveau], 2, f"étage {niveau}") + + +class TestLeMenu(unittest.TestCase): + def test_the_mixin_is_wired_into_TODO(self): + todo = TODO.__new__(TODO) + self.assertTrue(hasattr(todo, "prompt_execute_longtest")) + + def test_the_script_is_found_from_the_repository_root(self): + todo = TODO.__new__(TODO) + ancien = os.getcwd() + try: + os.chdir(RACINE) + self.assertTrue(todo._longtest_script("deep_proxmox.py")) + self.assertFalse(todo._longtest_script("nexiste-pas.py")) + finally: + os.chdir(ancien) + + def test_the_test_menu_offers_it(self): + import inspect + + src = inspect.getsource(TODO.prompt_execute_test) + self.assertIn("prompt_execute_longtest", src) + self.assertIn("Long tests", src) + + +if __name__ == "__main__": + unittest.main(verbosity=2)