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)