Incident sur une descente réelle : un agent de relecture, chargé de vérifier ce que redemarrer_et_verifier PROUVE, l'a appelé sur l'étage 1 vivant. Le reboot a éteint les étages 2, 3 et 4 d'un coup. La descente a alors attendu son délai entier — quarante minutes — un ssh qui ne pouvait plus aboutir, puis a conclu « jamais joignable ». L'attente surveille désormais la MAISON. Le décompte de la destruction mentait dans l'autre sens : les étages injoignables étaient annoncés « il reste des machines » alors que le disque de l'étage 1, effacé, les contenait. Les entrées ~/.ssh/config sont retirées aussi, sinon leur ProxyJump désigne un hôte disparu. Et « arret » nommait la RAM quand le vCPU bornait : la chaîne était figée en ram > disque > vcpu et évaluée à la profondeur demandée. Il nomme maintenant le plus bas des trois plafonds, et les trois sont affichés. --- EN --- Incident on a real descent: a review agent, tasked with checking what redemarrer_et_verifier PROVES, called it on the living level 1. The reboot took levels 2, 3 and 4 down at once. The descent then waited its whole timeout — forty minutes — for an ssh that could no longer land, and concluded "never reachable". The wait now watches the HOUSE. The destroy count lied the other way: unreachable levels were reported as "il reste des machines" when level 1's erased disk contained them. The ~/.ssh/config entries are removed too, else their ProxyJump names a host that is gone. And "arret" named RAM when vCPU was the bound: the chain was frozen as ram > disque > vcpu and evaluated at the requested depth. It now names the lowest of the three ceilings, and all three are shown. Assisted-by: claude-opus-5 (cherry picked from commit 2d84b62bdd9907e9a30383774015bcb1ae379de1)
790 lines
32 KiB
Python
790 lines
32 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 contextlib
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
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_plan_shrinks_towards_the_bottom(self):
|
|
"""Chaque étage annoncé est plus étroit que son parent, sur les trois
|
|
ressources.
|
|
|
|
Deux vCPU à chaque étage imbriqué donnaient un parent aussi étroit que
|
|
son enfant : cent pour cent de surengagement, et l'hyperviseur à servir
|
|
par-dessus. Mesuré : l'installation de l'étage 4 dépassait 2 h 50
|
|
contre 793 s pour l'étage 3."""
|
|
# 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)
|
|
etages = sorted(
|
|
(int(n), int(v), int(r), int(d)) for n, v, r, d in plan
|
|
)
|
|
for parent, enfant in zip(etages, etages[1:]):
|
|
for i, quoi in ((1, "vCPU"), (2, "RAM"), (3, "disque")):
|
|
self.assertGreater(
|
|
parent[i], enfant[i], f"étage {parent[0]} : {quoi}"
|
|
)
|
|
# Et le plus profond reçoit ce qu'un Proxmox de test demande, pas ce
|
|
# qui reste.
|
|
self.assertEqual(etages[-1][1], 2, "vCPU du plus profond")
|
|
|
|
|
|
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 TestUnRapportQuiSurvitAuProcessus(unittest.TestCase):
|
|
"""Le rapport ne s'écrivait qu'à la FIN de la descente.
|
|
|
|
Constaté : une descente de dix étages arrêtée pendant l'installation du
|
|
quatrième laissait quatre machines réelles, et « --detruire » répondait
|
|
« aucun rapport de descente : rien à défaire ». Le seul enregistrement du
|
|
couple (alias du parent, VMID) mourait avec le processus — il fallait
|
|
retrouver ces VM à la main, c'est-à-dire par leur nom, ce que tout le
|
|
reste de ce fichier s'applique à ne pas faire.
|
|
"""
|
|
|
|
def setUp(self):
|
|
sys.path.insert(0, os.path.join(RACINE, "LongTest"))
|
|
import deep_proxmox
|
|
|
|
self.dp = deep_proxmox
|
|
self.dossier = tempfile.mkdtemp(prefix="longtest-rapport-")
|
|
self.addCleanup(shutil.rmtree, self.dossier, ignore_errors=True)
|
|
|
|
def _descente_tuee(self, a_l_etage):
|
|
"""Une descente dont l'installation MEURT à l'étage donné.
|
|
|
|
Rien de réel n'est touché : aucune des méthodes qui créent une machine
|
|
ou écrivent dans ~/.ssh/config n'est appelée pour de vrai.
|
|
"""
|
|
niveaux = [
|
|
{"niveau": n, "vcpu": 2, "ram": 4096, "disque": 25}
|
|
for n in (1, 2, 3)
|
|
]
|
|
plan = {"demandee": 3, "atteignable": 3, "niveaux": niveaux}
|
|
chemin = os.path.join(self.dossier, "rapport.json")
|
|
d = self.dp.Descente(plan, None, False, chemin)
|
|
|
|
appels = []
|
|
d.creer_etage1 = lambda res: "deep-pve-1"
|
|
d.preparer_parent = lambda parent: {"stockage": "local-lvm"}
|
|
d.creer_enfant = lambda parent, niveau, res, prep: (
|
|
100 + niveau,
|
|
f"10.10.10.{niveau}",
|
|
)
|
|
d.ecrire_alias = lambda *a, **k: None
|
|
d.attendre_ssh = lambda cible, delai, parent=None: 1
|
|
d.redemarrer_et_verifier = lambda cible: True
|
|
d.reparer_pmxcfs = lambda cible: True
|
|
|
|
def installer(cible):
|
|
appels.append(cible)
|
|
if len(appels) >= a_l_etage:
|
|
raise KeyboardInterrupt("descente tuée")
|
|
return True
|
|
|
|
d.installer_proxmox = installer
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
d.parcourir()
|
|
with open(chemin, encoding="utf-8") as fh:
|
|
return json.load(fh)
|
|
|
|
def test_a_killed_descent_still_names_what_it_created(self):
|
|
rapport = self._descente_tuee(a_l_etage=3)
|
|
# Le couple (parent, VMID) des étages imbriqués créés : c'est de lui
|
|
# seul que « --detruire » se sert.
|
|
self.assertEqual(
|
|
self.dp.a_defaire(rapport),
|
|
[
|
|
(3, "deep-pve-1+deep-pve-2", 103, self.dp.nom_etage(3)),
|
|
(2, "deep-pve-1", 102, self.dp.nom_etage(2)),
|
|
],
|
|
)
|
|
|
|
def test_the_report_exists_as_soon_as_the_first_vm_does(self):
|
|
"""Tuée pendant l'installation de l'étage 1, il n'y a aucun VMID à
|
|
noter — mais le domaine libvirt existe, et sans rapport « --detruire »
|
|
ne le regardait même pas."""
|
|
rapport = self._descente_tuee(a_l_etage=1)
|
|
self.assertTrue(rapport["etages"])
|
|
self.assertEqual(self.dp.a_defaire(rapport), [])
|
|
|
|
def test_a_partial_report_never_reads_as_a_finished_descent(self):
|
|
rapport = self._descente_tuee(a_l_etage=3)
|
|
self.assertTrue(rapport["interrompu"])
|
|
self.assertLess(rapport["atteinte"], rapport["demandee"])
|
|
# Et il n'est pas écarté comme un essai à blanc : c'est bien de VRAIES
|
|
# machines qu'il parle.
|
|
self.assertFalse(rapport["dry_run"])
|
|
|
|
def test_a_dry_run_writes_no_partial_report(self):
|
|
"""Un plan n'a rien créé : lui laisser écrire un rapport ferait
|
|
détruire d'après un plan."""
|
|
plan = {
|
|
"demandee": 1,
|
|
"atteignable": 1,
|
|
"niveaux": [{"niveau": 1, "vcpu": 2, "ram": 4096, "disque": 25}],
|
|
}
|
|
chemin = os.path.join(self.dossier, "blanc.json")
|
|
d = self.dp.Descente(plan, None, True, chemin)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
d._sauver({"niveau": 1, "vmid": 101, "parent_alias": "x"})
|
|
self.assertFalse(os.path.exists(chemin))
|
|
|
|
|
|
class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase):
|
|
"""Le correctif du rapport partiel a CRÉÉ ce danger.
|
|
|
|
Avant, la descente en cours n'avait aucun rapport sur le disque et
|
|
« --detruire » retombait sur la précédente, terminée. Depuis qu'il s'écrit
|
|
VM par VM, le rapport de la descente VIVANTE est le plus récent : détruire
|
|
aurait emporté l'arbre sous le processus qui installait encore."""
|
|
|
|
def setUp(self):
|
|
sys.path.insert(0, os.path.join(RACINE, "LongTest"))
|
|
import deep_proxmox
|
|
|
|
self.dp = deep_proxmox
|
|
self.maison = tempfile.mkdtemp(prefix="longtest-maison-")
|
|
self.dossier = os.path.join(self.maison, ".erplibre/longtest")
|
|
os.makedirs(self.dossier)
|
|
self._vrai = os.environ.get("HOME")
|
|
os.environ["HOME"] = self.maison
|
|
self.addCleanup(shutil.rmtree, self.maison, ignore_errors=True)
|
|
# Un bouchon posé par un test et non repris fausse les SUIVANTS : la
|
|
# première version de ce fichier remplaçait dernier_rapport et le
|
|
# laissait en place, et le test d'après lisait le bouchon.
|
|
self._vrais = {
|
|
nom: getattr(deep_proxmox, nom)
|
|
for nom in ("autre_deep_proxmox", "dernier_rapport")
|
|
}
|
|
|
|
def tearDown(self):
|
|
if self._vrai is not None:
|
|
os.environ["HOME"] = self._vrai
|
|
for nom, vrai in self._vrais.items():
|
|
setattr(self.dp, nom, vrai)
|
|
|
|
def _ecrire(self, nom, rapport):
|
|
with open(
|
|
os.path.join(self.dossier, nom), "w", encoding="utf-8"
|
|
) as fh:
|
|
json.dump(rapport, fh)
|
|
|
|
def test_a_living_descent_is_recognised_by_its_pid(self):
|
|
# Ce processus-ci exécute bien un test, pas deep_proxmox.py : c'est
|
|
# justement ce que le contrôle doit savoir distinguer.
|
|
self.assertFalse(self.dp.descente_vivante(os.getpid()))
|
|
self.assertFalse(self.dp.descente_vivante(None))
|
|
self.assertFalse(self.dp.descente_vivante(999999999))
|
|
|
|
def test_a_shell_that_merely_names_the_script_is_not_a_descent(self):
|
|
"""Constaté sur la machine : un « pgrep -f deep_proxmox.py » posé dans
|
|
une boucle de surveillance donnait un shell dont la ligne de commande
|
|
contient le motif, et deux faux positifs sur trois."""
|
|
import subprocess
|
|
|
|
faux = subprocess.Popen(
|
|
[
|
|
"sh",
|
|
"-c",
|
|
"echo deep_proxmox.py --depth 10 >/dev/null; sleep 30",
|
|
]
|
|
)
|
|
self.addCleanup(faux.kill)
|
|
self.assertFalse(self.dp._lance_ce_script(faux.pid))
|
|
self.assertNotIn(faux.pid, self.dp.autre_deep_proxmox())
|
|
|
|
def _fausse_descente(self):
|
|
"""Un processus qui exécute VRAIMENT un « deep_proxmox.py ».
|
|
|
|
Un PID inventé ne prouverait rien : le contrôle lit /proc, et la seule
|
|
façon honnête de l'éprouver est de lui donner un processus à voir."""
|
|
faux = os.path.join(self.maison, "deep_proxmox.py")
|
|
with open(faux, "w", encoding="utf-8") as fh:
|
|
fh.write("import time\ntime.sleep(60)\n")
|
|
proc = subprocess.Popen([sys.executable, faux])
|
|
self.addCleanup(proc.kill)
|
|
for _ in range(60):
|
|
if self.dp._lance_ce_script(proc.pid):
|
|
return proc.pid
|
|
time.sleep(0.05)
|
|
self.skipTest("le processus témoin n'a pas démarré")
|
|
|
|
def test_a_living_descent_is_seen_in_proc(self):
|
|
pid = self._fausse_descente()
|
|
self.assertTrue(self.dp.descente_vivante(pid))
|
|
self.assertIn(pid, self.dp.autre_deep_proxmox())
|
|
|
|
def test_the_report_of_a_living_descent_is_skipped(self):
|
|
"""Sans ce filtre, « --detruire » choisissait le rapport de la
|
|
descente EN COURS — le plus récent — et détruisait l'arbre sous le
|
|
processus qui installait encore."""
|
|
pid = self._fausse_descente()
|
|
self._ecrire(
|
|
"deep-pve-20260101-000000.json",
|
|
{
|
|
"dry_run": False,
|
|
"etages": [{"niveau": 2, "vmid": 102, "parent_alias": "a"}],
|
|
},
|
|
)
|
|
self._ecrire(
|
|
"deep-pve-20260102-000000.json",
|
|
{
|
|
"dry_run": False,
|
|
"pid": pid,
|
|
"etages": [{"niveau": 5, "vmid": 105, "parent_alias": "vif"}],
|
|
},
|
|
)
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
rapport = self.dp.dernier_rapport()
|
|
# Celui de la descente vivante est écarté, et on le DIT.
|
|
self.assertIn("descente EN COURS", sortie.getvalue())
|
|
self.assertEqual(rapport["etages"][0]["vmid"], 102)
|
|
|
|
def test_an_empty_later_report_never_masks_one_that_names_vms(self):
|
|
"""Un second lancement qui meurt à l'étage 1 — « le disque existe
|
|
déjà » — écrivait un rapport VIDE sous un horodatage plus tardif.
|
|
« --detruire » annonçait « 0 VM imbriquée(s) » puis effaçait le disque
|
|
de l'étage 1, où vivaient les étages 2 et suivants : jamais arrêtés,
|
|
jamais nommés."""
|
|
self._ecrire(
|
|
"deep-pve-20260101-000000.json",
|
|
{
|
|
"dry_run": False,
|
|
"etages": [
|
|
{"niveau": 3, "vmid": 103, "parent_alias": "a+b"},
|
|
{"niveau": 2, "vmid": 102, "parent_alias": "a"},
|
|
],
|
|
},
|
|
)
|
|
self._ecrire(
|
|
"deep-pve-20260102-000000.json", {"dry_run": False, "etages": []}
|
|
)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
rapport = self.dp.dernier_rapport()
|
|
self.assertEqual(len(self.dp.a_defaire(rapport)), 2)
|
|
self.assertTrue(rapport["fichier"].endswith("20260101-000000.json"))
|
|
|
|
def test_a_dry_run_report_still_never_wins(self):
|
|
self._ecrire(
|
|
"deep-pve-20260101-000000.json",
|
|
{
|
|
"dry_run": False,
|
|
"etages": [{"niveau": 2, "vmid": 102, "parent_alias": "a"}],
|
|
},
|
|
)
|
|
self._ecrire(
|
|
"deep-pve-20260103-000000.json",
|
|
{
|
|
"dry_run": True,
|
|
"etages": [{"niveau": 9, "vmid": 900, "parent_alias": "z"}],
|
|
},
|
|
)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
rapport = self.dp.dernier_rapport()
|
|
self.assertEqual(rapport["etages"][0]["vmid"], 102)
|
|
|
|
def test_destroying_refuses_while_a_descent_runs(self):
|
|
appels = []
|
|
self.dp.autre_deep_proxmox = lambda: [4242]
|
|
self.dp.dernier_rapport = lambda: appels.append("lu") or {}
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
code = self.dp.detruire(None, dry_run=False)
|
|
self.assertEqual(code, 1)
|
|
# Le rapport n'est même pas LU : on ne demande rien, on ne propose
|
|
# rien, et surtout on n'attend pas un « OUI » sur un arbre vivant.
|
|
self.assertEqual(appels, [])
|
|
self.assertIn("descente tourne", sortie.getvalue())
|
|
|
|
|
|
class TestNePasAttendreUneMaisonDisparue(unittest.TestCase):
|
|
"""L'étage 1 a redémarré pendant l'installation de l'étage 4, éteignant
|
|
les étages 2, 3 et 4 d'un coup.
|
|
|
|
La descente a attendu son délai entier — quarante minutes — un ssh qui ne
|
|
pouvait plus aboutir, puis a conclu « jamais joignable en ssh ». Le
|
|
diagnostic était faux : la machine n'était pas lente, sa MAISON n'existait
|
|
plus."""
|
|
|
|
def setUp(self):
|
|
sys.path.insert(0, os.path.join(RACINE, "LongTest"))
|
|
import deep_proxmox
|
|
|
|
self.dp = deep_proxmox
|
|
self.vrai_run = deep_proxmox.pve.run
|
|
self.addCleanup(setattr, deep_proxmox.pve, "run", self.vrai_run)
|
|
self.d = deep_proxmox.Descente.__new__(deep_proxmox.Descente)
|
|
self.d.dry_run = False
|
|
self.d.journal = None
|
|
|
|
def _cibles(self):
|
|
return (
|
|
{"target": "enfant", "sudo": "sudo ", "jump": ""},
|
|
{"target": "parent", "sudo": "sudo ", "jump": ""},
|
|
)
|
|
|
|
def test_it_gives_up_as_soon_as_the_parent_stops_answering(self):
|
|
appels = []
|
|
|
|
def faux(hote, cmd, timeout=None):
|
|
appels.append(hote["target"])
|
|
# Une borne DURE : si le garde-fou disparaissait, la boucle
|
|
# sonderait l'enfant jusqu'à l'expiration du délai. On la fait
|
|
# éclater au troisième tour plutôt que de laisser le test tourner
|
|
# — et ce test-ci doit échouer vite quand le code régresse.
|
|
if appels.count("enfant") > 2:
|
|
raise AssertionError(f"sondé sans fin : {appels[:6]}")
|
|
return 255, ""
|
|
|
|
self.dp.pve.run = faux
|
|
enfant, parent = self._cibles()
|
|
vrai_sleep = time.sleep
|
|
time.sleep = lambda _s: None
|
|
self.addCleanup(setattr, time, "sleep", vrai_sleep)
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
res = self.d.attendre_ssh(enfant, 45, parent)
|
|
self.assertIsNone(res)
|
|
# DEUX sondes, et c'est tout : l'enfant, puis sa maison. Sans le
|
|
# garde-fou la liste comptait autant d'« enfant » que le délai le
|
|
# permet, et la descente attendait pour rien.
|
|
self.assertEqual(appels, ["enfant", "parent"])
|
|
self.assertIn("ne répond plus", sortie.getvalue())
|
|
|
|
def test_a_slow_child_with_a_living_parent_is_still_waited_for(self):
|
|
"""Le contrôle NÉGATIF : un étage lent n'est pas un étage mort. Sans
|
|
lui, ce garde-fou abandonnerait toute descente profonde."""
|
|
etat = {"tours": 0}
|
|
|
|
def faux(hote, cmd, timeout=None):
|
|
if hote["target"] == "parent":
|
|
return 0, "" # la maison tient
|
|
etat["tours"] += 1
|
|
return (0, "") if etat["tours"] >= 3 else (255, "")
|
|
|
|
self.dp.pve.run = faux
|
|
self.dp.time = time # même horloge
|
|
enfant, parent = self._cibles()
|
|
vrai_sleep = time.sleep
|
|
time.sleep = lambda _s: None
|
|
self.addCleanup(setattr, time, "sleep", vrai_sleep)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
res = self.d.attendre_ssh(enfant, 3600, parent)
|
|
self.assertIsNotNone(res)
|
|
self.assertEqual(etat["tours"], 3)
|
|
|
|
def test_without_a_parent_the_behaviour_is_unchanged(self):
|
|
# L'étage 1 n'a pas de parent : il tourne sur du métal.
|
|
self.dp.pve.run = lambda hote, cmd, timeout=None: (0, "")
|
|
enfant, _ = self._cibles()
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
self.assertEqual(self.d.attendre_ssh(enfant, 60), 0)
|
|
|
|
|
|
class TestLeDecompteDeLaDestruction(unittest.TestCase):
|
|
"""« if not detruire_etage1(…) : faits -= 1 » — un succès de l'étage 1
|
|
n'ajoutait RIEN, alors que le total est len(liste) + 1.
|
|
|
|
Le décompte était décalé de un dans TOUS les cas : une destruction
|
|
complète annonçait « il reste des machines » et sortait 1. Le seul
|
|
avertissement censé prévenir qu'un disque de plusieurs dizaines de Go
|
|
reste alloué s'affichait toujours — on apprend à ne plus le lire."""
|
|
|
|
def setUp(self):
|
|
sys.path.insert(0, os.path.join(RACINE, "LongTest"))
|
|
import deep_proxmox
|
|
|
|
self.dp = deep_proxmox
|
|
self._vrais = {
|
|
nom: getattr(deep_proxmox, nom)
|
|
for nom in (
|
|
"autre_deep_proxmox",
|
|
"dernier_rapport",
|
|
"detruire_une",
|
|
"detruire_etage1",
|
|
"retirer_alias",
|
|
)
|
|
}
|
|
deep_proxmox.autre_deep_proxmox = lambda: []
|
|
deep_proxmox.retirer_alias = lambda *a, **k: None
|
|
deep_proxmox.dernier_rapport = lambda: {
|
|
"fichier": "/x.json",
|
|
"etages": [
|
|
{"niveau": 3, "vmid": 103, "parent_alias": "a+b"},
|
|
{"niveau": 2, "vmid": 102, "parent_alias": "a"},
|
|
],
|
|
}
|
|
self._entree = (
|
|
__builtins__["input"]
|
|
if isinstance(__builtins__, dict)
|
|
else __builtins__.input
|
|
)
|
|
|
|
def tearDown(self):
|
|
for nom, vrai in self._vrais.items():
|
|
setattr(self.dp, nom, vrai)
|
|
import builtins
|
|
|
|
builtins.input = self._entree
|
|
|
|
def _lancer(self, etage1_ok, une=True):
|
|
import builtins
|
|
|
|
builtins.input = lambda _prompt="": "OUI"
|
|
self.dp.detruire_une = lambda *a, **k: une
|
|
self.dp.detruire_etage1 = lambda *a, **k: etage1_ok
|
|
with contextlib.redirect_stdout(io.StringIO()) as sortie:
|
|
code = self.dp.detruire(None, dry_run=False)
|
|
return code, sortie.getvalue()
|
|
|
|
def test_a_complete_destruction_reports_success(self):
|
|
code, texte = self._lancer(etage1_ok=True)
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("3 / 3", texte)
|
|
self.assertNotIn("⚠", texte)
|
|
|
|
def test_unreachable_levels_go_with_the_root_disk(self):
|
|
"""Mesuré sur un arbre réel : les étages 3 et 4 étaient injoignables
|
|
— leur parent était éteint — et le compte disait « 2 / 4, il reste des
|
|
machines ». Or « virsh undefine --remove-all-storage » sur l'étage 1
|
|
efface le disque où ils VIVENT. L'avertissement était faux dans
|
|
l'autre sens, et un avertissement faux ne se lit plus."""
|
|
self.dp.detruire_une = lambda *a, **k: False
|
|
code, texte = self._lancer(etage1_ok=True, une=False)
|
|
self.assertEqual(code, 0)
|
|
self.assertIn("3 / 3", texte)
|
|
self.assertIn("emporté(s) avec le disque de l'étage 1", texte)
|
|
|
|
def test_a_surviving_level_one_is_the_only_real_warning(self):
|
|
# Là, et là seulement, quelque chose vit encore : le disque est
|
|
# debout, et tout ce qu'il contient avec lui.
|
|
code, texte = self._lancer(etage1_ok=False)
|
|
self.assertEqual(code, 1)
|
|
self.assertIn("l'étage 1 est DEBOUT", texte)
|
|
|
|
def test_the_ssh_aliases_of_a_destroyed_descent_are_removed(self):
|
|
"""Elles survivaient aux machines : des entrées mortes dont le
|
|
ProxyJump désigne un hôte qui n'existe plus."""
|
|
retires = []
|
|
self.dp.retirer_alias = lambda rapport, journal=None: retires.append(
|
|
[e.get("alias") for e in rapport["etages"]]
|
|
)
|
|
self._lancer(etage1_ok=True)
|
|
self.assertEqual(len(retires), 1)
|
|
|
|
def test_the_aliases_are_computed_when_the_report_lacks_them(self):
|
|
"""Un étage abandonné avant l'écriture de son alias en a tout de même
|
|
un : il est déterminé par (niveau, alias du parent)."""
|
|
vus = {}
|
|
import script.todo.todo as module_todo
|
|
|
|
vrai = module_todo.TODO._write_ssh_config_entry
|
|
|
|
def espion(self, host, user, ip, **kw):
|
|
vus["drop"] = kw.get("also_drop")
|
|
|
|
module_todo.TODO._write_ssh_config_entry = espion
|
|
self.addCleanup(
|
|
setattr, module_todo.TODO, "_write_ssh_config_entry", vrai
|
|
)
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
# La VRAIE fonction : setUp en a posé un bouchon pour les autres
|
|
# tests de cette classe.
|
|
self._vrais["retirer_alias"](
|
|
{
|
|
"etages": [
|
|
{"niveau": 1, "alias": "deep-pve-1"},
|
|
{"niveau": 2, "parent_alias": "deep-pve-1"},
|
|
]
|
|
}
|
|
)
|
|
self.assertEqual(
|
|
vus["drop"],
|
|
("deep-pve-1", self.dp.alias_etage(2, "deep-pve-1")),
|
|
)
|
|
|
|
|
|
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)
|