Relecture adversaire du commit précédent : il avait CRÉÉ un danger. Le rapport
s'écrivant maintenant VM par VM, celui de la descente EN COURS est le plus
récent, et « --detruire » l'aurait choisi — qm destroy --purge sur l'arbre que
le processus installait encore. Deux garde-fous : un PID dans le rapport, et
un refus net tant qu'un autre deep_proxmox.py tourne. Le second est nécessaire
car une descente déjà lancée a l'ancien module en mémoire.
Reconnu par ARGUMENT, pas par sous-chaîne : mon propre « pgrep -f
deep_proxmox.py » de surveillance donnait deux faux positifs sur trois.
Trois autres, mêmes preuves :
- un rapport vide plus récent masquait celui qui nommait les VM réelles ;
- detruire() ne créditait jamais l'étage 1 : le décompte était décalé de un
dans tous les cas, donc l'avertissement sortait toujours ;
- _ssh_config_drop_hosts prenait l'indentation pour de la syntaxe. Sur un bloc
au corps non indenté, seule la ligne Host partait et ssh rattachait
« StrictHostKeyChecking no » au bloc précédent — un serveur de production.
Et un bloc partagé (« Host prod-db vm-a ») partait en entier.
Les cinq correctifs meurent sous mutation.
--- EN ---
Adversarial review of the previous commit: it had CREATED a hazard. With the
report now written VM by VM, the RUNNING descent's is the most recent, and
"--detruire" would have picked it — qm destroy --purge on the tree the process
was still installing. Two guards: a PID in the report, and a flat refusal
while another deep_proxmox.py runs. The second is needed because an
already-running descent holds the old module in memory.
Matched by ARGUMENT, not substring: my own monitoring "pgrep -f
deep_proxmox.py" produced two false positives out of three.
Three more, same evidence:
- a newer empty report masked the one naming the real VMs;
- detruire() never credited level 1: the count was off by one in every case,
so the warning always fired;
- _ssh_config_drop_hosts took indentation for syntax. On a block with an
unindented body only the Host line went, and ssh attached
"StrictHostKeyChecking no" to the preceding block — a production server.
And a shared block ("Host prod-db vm-a") went entirely.
All five fixes die under mutation.
Assisted-by: claude-opus-5
(cherry picked from commit 7d348d976f96e420b4fec4d879911b658a730ffa)
653 lines
26 KiB
Python
653 lines
26 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: 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 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",
|
|
)
|
|
}
|
|
deep_proxmox.autre_deep_proxmox = lambda: []
|
|
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):
|
|
import builtins
|
|
|
|
builtins.input = lambda _prompt="": "OUI"
|
|
self.dp.detruire_une = lambda *a, **k: True
|
|
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("il reste des machines", texte)
|
|
|
|
def test_a_surviving_level_one_is_still_warned_about(self):
|
|
# L'avertissement doit rester CRÉDIBLE : il ne sort que quand il a
|
|
# quelque chose à dire.
|
|
code, texte = self._lancer(etage1_ok=False)
|
|
self.assertEqual(code, 1)
|
|
self.assertIn("2 / 3", texte)
|
|
self.assertIn("il reste des machines", texte)
|
|
|
|
|
|
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)
|