erplibre/test/test_todo_longtest.py
Mathieu Benoit adf0f275d4 [FIX] proxmox : apt-daily tient le verrou au démarrage
Trois pannes trouvées en LANÇANT la descente, aucune vue en la relisant — ni
par moi, ni par l'attaque adversariale.

Le premier « apt update » d'une image cloud échoue sur un verrou qui n'est pas
celui qu'on croit. Mesuré une seconde après le premier ssh :

  E: Could not get lock /var/lib/apt/lists/lock.
     It is held by process 1026 (apt-get)

Ce n'est pas cloud-init — « status --wait » avait rendu la main. C'est
apt-daily, le minuteur de Debian, qui se déclenche au démarrage. Et le verrou
des LISTES n'est pas couvert par « DPkg::Lock::Timeout », que l'installeur
réglait pourtant déjà à 600 s : cette attente ne vaut que pour dpkg. On arrête
donc les minuteurs, puis on RÉESSAIE — arrêter une unité n'interrompt pas
l'apt-get déjà en vol. Le défaut touchait tout déploiement Proxmox, pas
seulement ce test.

Le premier étage n'avait pas d'alias ssh. « deploy_qemu.py » en ligne de
commande n'écrit pas d'entrée ~/.ssh/config — le menu le fait, la CLI non. La
descente aurait attendu son plein délai avant de conclure « jamais joignable »
sur une VM qui répondait à son adresse. Elle l'écrit maintenant elle-même,
depuis l'adresse résolue, et refuse d'avancer si la VM n'en a pas.

Et la réserve de l'hôte est proportionnelle. Quatre gigaoctets sur une machine
de soixante, c'était 6 % laissés au système : le jour où les invités touchent
vraiment leur mémoire, c'est l'hôte qui part en swap — et la mesure serait
celle du swap, pas de l'imbrication. Un huitième, avec le plancher d'avant
pour les petites machines.

Ce que la descente a établi en trois étages : 392 s, 644 s, 1120 s, soit 1,7
fois par étage. Puis la poignée de main ssh passe de 77 à 1664 secondes au
quatrième — vingt fois d'un seul cran. Le coude est là.

Et le mur que j'avais pris pour une limite d'imbrication n'en était pas une.
La VM qui gelait au quatrième étage avait douze vCPU ; celle-ci en a deux et
elle passe, en écrivant. C'était une limite de parallélisme SOUS imbrication —
exactement ce que l'algorithme borne, vérifié pour la première fois plutôt que
supposé.

--- EN ---

Three faults found by RUNNING the descent, none seen by reading it — neither
by me nor by the adversarial attack.

A cloud image's first "apt update" fails on a lock that is not the one you
expect. Measured one second after the first ssh:

  E: Could not get lock /var/lib/apt/lists/lock.
     It is held by process 1026 (apt-get)

It is not cloud-init — "status --wait" had returned. It is apt-daily, Debian's
timer, firing at boot. And the LISTS lock is not covered by
"DPkg::Lock::Timeout", which the installer already set to 600 s: that wait
only applies to dpkg. So we stop the timers, then RETRY — stopping a unit does
not interrupt the apt-get already in flight. The defect affected every Proxmox
deployment, not just this test.

The first level had no ssh alias. "deploy_qemu.py" on the command line does not
write a ~/.ssh/config entry — the menu does, the CLI does not. The descent
would have waited its full timeout before concluding "never reachable" about a
VM answering at its address. It now writes the entry itself, from the resolved
address, and refuses to proceed if the VM has none.

And the host's reserve is proportional. Four gigabytes on a sixty-gigabyte
machine left 6 % to the system: the day the guests really touch their memory,
the host swaps — and the measurement would be of swap, not of nesting. One
eighth now, keeping the old floor for small machines.

What the descent established over three levels: 392 s, 644 s, 1120 s — 1.7x per
level. Then the ssh handshake goes from 77 to 1664 seconds at the fourth:
twenty times in one step. That is the elbow.

And the wall I had taken for a nesting limit was not one. The VM that froze at
the fourth level had twelve vCPU; this one has two and it gets through,
writing. It was a limit of parallelism UNDER nesting — exactly what the
algorithm caps, verified for the first time rather than assumed.

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

299 lines
12 KiB
Python

#!/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_runner_only_looks_under_test(self):
"""Le lanceur balaie TOUT test/test_*.py depuis qu'une liste de
préfixes a laissé 2400 tests hors de la suite.
La frontière n'est donc plus un nom mais un RÉPERTOIRE : ce qui doit
rester hors de la suite doit vivre ailleurs que dans test/. C'est
exactement pourquoi LongTest est à la racine."""
with open(
os.path.join(RACINE, "script/test/run_unit_test.sh"),
encoding="utf-8",
) as fh:
lanceur = fh.read()
self.assertIn("test/test_*.py", lanceur)
# Aucun chemin du lanceur ne sort de test/ : sinon LongTest y
# entrerait par la porte de service.
self.assertNotIn("LongTest", lanceur)
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):
import tempfile
# HOME temporaire : la suite unitaire tourne souvent, et elle n'a pas
# à semer un rapport dans ~/.erplibre à chaque passage.
cls.maison = tempfile.mkdtemp()
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, HOME=cls.maison),
)
@classmethod
def tearDownClass(cls):
import shutil
shutil.rmtree(cls.maison, ignore_errors=True)
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):
"""Les LIGNES du plan, pas les chiffres.
La version d'avant cherchait « 1 », « 2 », « 3 », « 4 » dans la
sortie : l'en-tête « 28 cœurs, 29128 Mo, 138 Go » et l'horodatage du
journal les fournissent tous. Elle passait même à --depth 1, avec une
seule ligne de plan — elle ne prouvait rien."""
import re
plan = re.findall(
r"^\s+(\d+)\s+(\d+)\s+(\d+) Mo\s+(\d+) Go\s*$",
self.res.stdout,
re.M,
)
self.assertEqual([int(p[0]) for p in plan], [1, 2, 3, 4])
self.assertIn("dry-run", self.res.stdout)
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_installer_is_run_by_bash_not_sh(self):
"""Le script porte « set -euo pipefail » et un shebang bash.
Sur Debian /bin/sh est dash, qui répond « set: Illegal option -o
pipefail » et sort à la PREMIÈRE ligne — vérifié. Lancé par sh, chaque
étage aurait échoué sur l'installation, à tous les coups."""
# Sur la LIGNE, pas dans le texte : « bash /tmp/… » contient
# « sh /tmp/… », donc un assertNotIn naïf échouait sur lui-même.
lignes = [
ligne.strip()
for ligne in self.res.stdout.splitlines()
if "install_proxmox.sh" in ligne
and not ligne.strip().startswith("scp")
]
self.assertTrue(lignes)
for ligne in lignes:
self.assertTrue(
ligne.startswith("bash "), f"lancé par autre chose : {ligne}"
)
def test_the_first_level_gets_an_ssh_entry(self):
"""La CLI QEMU/KVM n'écrit PAS d'entrée ~/.ssh/config.
Sans elle, « ssh deep-pve-1 » rend « Name or service not known » et la
descente attendait son plein délai avant de conclure « jamais
joignable » — sur une VM qui répondait parfaitement à son adresse.
Trouvé au premier lancement réel, pas par l'attaque."""
import inspect
import sys as _sys
_sys.path.insert(0, os.path.join(RACINE, "LongTest"))
import deep_proxmox
src = inspect.getsource(deep_proxmox.Descente.creer_etage1)
self.assertIn("_write_ssh_config_entry", src)
self.assertIn("_qemu_vm_ip_now", src)
# Et une VM sans adresse n'est pas déclarée prête.
self.assertIn("créée mais sans adresse", src)
def test_the_dry_run_claims_nothing_reached(self):
"""Le rapport d'un essai à blanc était indiscernable d'une réussite —
JSON compris — et « --detruire » s'en servait."""
import glob
import json
fichiers = glob.glob(
os.path.join(self.maison, ".erplibre/longtest/*.json")
)
self.assertEqual(len(fichiers), 1, fichiers)
self.assertIn("dryrun", fichiers[0])
with open(fichiers[0], encoding="utf-8") as fh:
rapport = json.load(fh)
self.assertTrue(rapport["dry_run"])
self.assertEqual(rapport["atteinte"], 0)
self.assertTrue(all(not e["ok"] for e in rapport["etages"]))
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 TestDefaireSansEffacerAutreChose(unittest.TestCase):
"""« --detruire » effaçait par SOUS-CHAÎNE de nom, dans le mauvais ordre,
sans confirmation et sans honorer --dry-run.
Quatre défauts trouvés en attaquant le code écrit, chacun capable
d'emporter une machine qui n'appartient pas au test. « qm destroy --purge »
emporte les disques ET les entrées de sauvegarde."""
def setUp(self):
sys.path.insert(0, os.path.join(RACINE, "LongTest"))
import deep_proxmox
self.dp = deep_proxmox
def test_the_deepest_level_goes_first(self):
"""Le tri comptait les « + » de l'alias — or alias_etage remplace le
« + » du parent par un « - », donc chaque alias en portait
exactement UN. Le tri ne triait rien, et la destruction partait du
plus HAUT : « qm destroy --purge » sur l'étage 2 emportait le disque
contenant les étages 3 et suivants."""
rapport = {
"etages": [
{"niveau": 2, "vmid": 100, "parent_alias": "a"},
{"niveau": 4, "vmid": 100, "parent_alias": "c"},
{"niveau": 3, "vmid": 100, "parent_alias": "b"},
]
}
niveaux = [n for n, _p, _v, _nom in self.dp.a_defaire(rapport)]
self.assertEqual(niveaux, [4, 3, 2])
def test_a_level_without_a_vmid_is_not_guessed(self):
# Un étage abandonné avant « qm create » n'a rien créé : ne rien
# inventer à sa place.
rapport = {"etages": [{"niveau": 2}, {"niveau": 3, "vmid": 101}]}
self.assertEqual(len(self.dp.a_defaire(rapport)), 0)
def test_the_alias_chain_really_flattens_the_plus(self):
# La cause du tri mort, énoncée pour qu'on ne la réintroduise pas.
alias, precedent = "deep-pve-1", "deep-pve-1"
for niveau in (2, 3, 4):
alias = self.dp.alias_etage(niveau, precedent)
precedent = alias
self.assertEqual(alias.count("+"), 1, alias)
def test_an_exact_name_is_required(self):
"""Le filtre était « NOM_BASE in name » : une VM de labo appelée
« deep-pve-lab » sur un hyperviseur de production tombait dedans."""
import inspect
src = inspect.getsource(self.dp.detruire_une)
self.assertIn("!= nom", src)
self.assertNotIn("in presentes[vmid]", src)
def test_dry_run_reports_are_never_used_to_destroy(self):
"""Un rapport d'essai à blanc n'a rien créé : s'en servir ferait
détruire d'après un plan."""
import inspect
src = inspect.getsource(self.dp.dernier_rapport)
self.assertIn('rapport.get("dry_run")', src)
def test_destruction_honours_dry_run_and_asks(self):
import inspect
src = inspect.getsource(self.dp.detruire)
self.assertIn("dry_run", src)
# Une confirmation explicite, pas un « o/N » : le menu lançait cette
# option d'une seule touche.
self.assertIn("OUI", src)
principal = inspect.getsource(self.dp.principal)
self.assertIn("dry_run=args.dry_run", principal)
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)