[ADD] long_test : partir d'un hôte existant, et le menu des deux piles

Créer une VM de tête pour héberger un hyperviseur qu'on possède déjà coûte
cinq minutes ET un étage d'imbrication — donc de la lenteur, puisque c'est
elle qu'on mesure. « --hote » part d'un hôte existant ; le menu le propose
sans le rechercher, l'hôte Proxmox déjà retenu étant lu par _pve_host(ask=False).

Trois conséquences que le code ne tirait pas :

- le plan se dimensionne sur la RACINE, lue par ssh. Le dimensionner sur la
  machine locale quand les étages vivent ailleurs annoncerait des étages qui
  ne tiennent pas ;
- les délais comptent la profondeur ABSOLUE. Un enfant de niveau 1 posé dans
  une racine déjà au troisième étage est en réalité au quatrième, et héritait
  de délais quatre fois trop courts — le défaut même que « delai » raconte
  avoir corrigé ;
- la racine n'est pas un étage atteint. L'y compter décalait de un le total et
  le code de sortie ; elle va dans une clé à part, et jamais « cree ».

« sudo » est DÉDUIT de « id -u » et non supposé, et une racine illisible fait
renoncer au lieu d'inventer une capacité.

Le menu offre les deux piles et défait chacune séparément — elles partagent le
dossier des rapports mais chacune ne connaît que les siens. Un test vérifie que
toute entrée affichée a son branchement : ils sont couplés par position, sans
garde.

80 tests, quatre garde-fous morts sous mutation.

--- EN ---

Creating a head VM to host a hypervisor you already own costs five minutes AND
one level of nesting — that is, slowness, which is the very thing being
measured. "--hote" starts from an existing host; the menu offers it without
searching, reading the already-chosen Proxmox host via _pve_host(ask=False).

Three consequences the code did not draw:

- the plan is sized on the ROOT, read over ssh. Sizing it on the local machine
  while the levels live elsewhere would announce levels that do not fit;
- delays count ABSOLUTE depth. A level-1 child placed in a root already at the
  third level is really at the fourth, and inherited delays four times too
  short — the very defect "delai" recounts having fixed;
- the root is not a level reached. Counting it shifted the total and the exit
  code by one; it goes in its own key, and never as "cree".

"sudo" is DEDUCED from "id -u" rather than assumed, and an unreadable root
makes us give up instead of inventing a capacity.

The menu offers both stacks and undoes each separately — they share the report
directory but each knows only its own. A test checks that every displayed entry
has its branch: they are coupled by position, with no guard.

80 tests, four guards die under mutation.

Assisted-by: claude-opus-5
(cherry picked from commit c2ab1a968346458925f55fc95619eb4ebd9d3efa)
This commit is contained in:
Mathieu Benoit 2026-08-28 03:29:01 -04:00
parent 8ff88f03d2
commit 032556544e
4 changed files with 498 additions and 27 deletions

View file

@ -85,6 +85,50 @@ def capacite_hote():
return coeurs, ram, disque
CAPACITE_CMD = (
"nproc | sed s/^/COEURS=/; "
"grep MemAvailable /proc/meminfo | sed s/^/MEM=/; "
"df --output=avail -BG /var/lib/libvirt/images 2>/dev/null"
" | tail -1 | sed s/^/DISQUE=/"
)
def parse_capacite(texte):
"""(cœurs, RAM en Mo, disque en Go) lus chez l'hôte, ou (0, 0, 0).
Zéro quand la ligne manque, jamais une valeur inventée : un plan
dimensionné sur une capacité supposée annoncerait des étages qui ne
tiennent pas.
"""
propre = pve.strip_ssh_noise(texte or "")
def lire(motif, diviseur=1):
trouve = re.search(motif, propre, re.M)
return int(trouve.group(1)) // diviseur if trouve else 0
return (
lire(r"^COEURS=\s*(\d+)"),
# « MEM=MemAvailable: 7056288 kB » : le sed colle un « = », pas un
# deux-points. L'expression attendait le second et rendait zéro — un
# plan dimensionné sur zéro mébioctet n'annonce aucun étage.
lire(r"^MEM=MemAvailable:\s*(\d+)", 1024),
lire(r"^DISQUE=\s*(\d+)"),
)
def capacite_distante(hote):
"""Ce dont dispose la RACINE quand la descente en emprunte une.
`capacite_hote()` lit la machine LOCALE. Partir d'un hôte distant sans
lire le sien dimensionnerait le plan d'après une machine qui n'héberge
rien — on annoncerait dix étages sur un serveur qui n'en porte pas deux.
"""
code, out = pve.run(dict(hote, sudo=""), CAPACITE_CMD, 120)
if code:
return 0, 0, 0
return parse_capacite(out)
def module_qemu():
"""deploy_qemu.py chargé comme module : il porte le catalogue d'images."""
import importlib.util
@ -126,6 +170,12 @@ class Descente:
demande à la pile qui en hérite — les six crochets plus bas.
"""
# Une descente sans racine part d'une VM qu'elle crée elle-même : c'est
# le cas ordinaire, et ces défauts le disent au niveau de la CLASSE plutôt
# que du constructeur, pour qu'une instance montée à la main les ait aussi.
racine = None
profondeur_racine = 0
# Ce que chaque pile déclare.
OUTIL = "" # « deep_proxmox » : écrit au rapport, filtre --detruire
NOM_BASE = "" # « deep-pve » : préfixe des noms de machines
@ -175,7 +225,25 @@ class Descente:
def alias_etage(self, niveau, parent_alias):
return alias_etage(niveau, parent_alias, self.NOM_BASE)
def __init__(self, plan, journal, dry_run=False, chemin_json=None):
def __init__(
self,
plan,
journal,
dry_run=False,
chemin_json=None,
racine=None,
profondeur_racine=0,
):
# `racine` : un hôte qui EXISTE DÉJÀ, chez qui la descente s'installe
# au lieu de créer sa propre machine de tête. Il n'est pas un étage —
# il n'est pas compté, pas détruit, et son entrée ~/.ssh/config est
# celle de l'utilisateur.
self.racine = racine
# Sa profondeur d'imbrication à LUI. Sans elle, le premier enfant
# d'une racine déjà au troisième étage héritait des délais du premier :
# quatre fois trop courts, exactement le défaut que `delai` raconte
# avoir corrigé.
self.profondeur_racine = profondeur_racine
self.plan = plan
self.journal = journal
self.chemin_json = chemin_json
@ -200,7 +268,11 @@ class Descente:
d'hyperviseur à traverser, mais un facteur illimité rendrait un
échec réel indiscernable d'une attente sans fin.
"""
facteur = min(max(1, self.niveau_courant), 5) ** 2
# La profondeur ABSOLUE : celle de la racine plus celle de l'étage.
# Un enfant de niveau 1 posé dans une racine déjà au troisième étage
# est en réalité au quatrième, et ses délais doivent le savoir.
profondeur = self.profondeur_racine + max(1, self.niveau_courant)
facteur = min(profondeur, 5) ** 2
return DELAIS[etape] * facteur
# ---------------------------------------------------------------- #
@ -404,8 +476,10 @@ class Descente:
return "" if res.returncode else res.stdout.strip()
def parcourir(self):
parent = None
parent_alias = ""
# Sans racine, le premier étage est une VM qu'on crée en local. Avec
# une racine, TOUS les étages sont des enfants — le premier compris.
parent = self.racine
parent_alias = (self.racine or {}).get("target", "")
for res in self.plan["niveaux"]:
niveau = res["niveau"]
self.niveau_courant = niveau
@ -420,7 +494,7 @@ class Descente:
f" ── étage {niveau} : {res['vcpu']} vCPU,"
f" {res['ram']} Mo, {res['disque']} Go"
)
if niveau == 1:
if parent is None:
nom = self.creer_etage1(res)
if not nom:
self.etages.append(etage)
@ -546,6 +620,18 @@ class Descente:
if en_cours is not None and en_cours not in etages:
etages.append(en_cours)
return {
# La racine EMPRUNTÉE, si la descente en avait une. Hors de
# « etages » : elle n'est pas un étage atteint, et l'y mettre
# décalait de un le compte et le code de sortie.
"racine": (
{
"alias": self.racine.get("target"),
"profondeur": self.profondeur_racine,
"cree": False,
}
if self.racine
else None
),
# L'outil qui a écrit ce rapport. Sans lui, « deep_qemu
# --detruire » prenait le rapport le plus récent — qui pouvait
# être celui d'une descente Proxmox — et détruisait d'après lui.
@ -686,6 +772,42 @@ def autre_descente():
return vivants
def joindre_racine(cible, jump=""):
"""Le dict d'hôte d'une racine empruntée, ou None si elle ne répond pas.
`sudo` est DÉDUIT, pas supposé : sur un hôte joint en root, préfixer les
commandes de « sudo » échoue là où l'image n'en a pas, et sur un hôte
joint en utilisateur, ne pas le mettre échoue partout.
"""
hote = {"target": cible, "jump": jump or "", "sudo": ""}
code, out = pve.run(hote, "id -u", 60)
if code:
dire(f" ✗ {cible} : injoignable en ssh.")
return None
if pve.strip_ssh_noise(out).strip() != "0":
hote["sudo"] = "sudo "
code, _o = pve.run(hote, "true", 60)
if code:
dire(f" ✗ {cible} : sudo demande un mot de passe.")
return None
return hote
def profondeur_de(cible):
"""La profondeur d'imbrication de `cible`, d'après sa 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 déployés : c'est nous qui écrivons ces
entrées, un ProxyJump par étage.
"""
try:
from script.todo.todo import TODO
return nesting.depth_from_jumps(TODO._ssh_jump_depth(cible))
except Exception: # noqa: BLE001 - une profondeur inconnue vaut 1
return 1
def mener(argv, description, famille, classe, couts=None):
"""La ligne de commande, le plan, la descente et le rapport.
@ -703,6 +825,18 @@ def mener(argv, description, famille, classe, couts=None):
parseur.add_argument("--depth", type=int, default=3)
parseur.add_argument("--dry-run", action="store_true")
parseur.add_argument("--detruire", action="store_true")
# Partir d'un hôte qu'on POSSÈDE DÉJÀ. Créer une VM de tête pour héberger
# un hyperviseur qu'on a sous la main coûte cinq minutes et un étage
# d'imbrication — donc de la lenteur — pour rien.
parseur.add_argument(
"--hote",
default="",
help="partir d'un hôte existant (alias ssh ou user@adresse)"
" au lieu de créer une VM de premier étage",
)
parseur.add_argument(
"--jump", default="", help="rebond ssh pour joindre --hote"
)
args = parseur.parse_args(argv)
journal = os.path.expanduser(
@ -715,9 +849,25 @@ def mener(argv, description, famille, classe, couts=None):
# destruction détruisait pour de vrai.
return detruire(famille, journal, dry_run=args.dry_run)
coeurs, ram, disque = capacite_hote()
racine, profondeur_racine = None, 0
if args.hote:
racine = joindre_racine(args.hote, args.jump)
if racine is None:
return 1
profondeur_racine = profondeur_de(args.hote)
# La capacité de la RACINE, pas celle d'ici. Dimensionner le plan sur
# la machine locale quand les étages vivent ailleurs annoncerait des
# étages qui ne tiennent pas.
coeurs, ram, disque = capacite_distante(racine)
if not coeurs:
print(f"\n ✗ {args.hote} : capacité illisible.\n")
return 1
print(f"\n racine : {args.hote}, déjà au niveau {profondeur_racine}")
else:
coeurs, ram, disque = capacite_hote()
ou = args.hote or "machine locale"
print(
f"\n machine : {coeurs} cœurs, {ram} Mo disponibles,"
f"\n {ou} : {coeurs} cœurs, {ram} Mo disponibles,"
f" {disque} Go de disque"
)
plan = nesting.nesting_plan(args.depth, coeurs, ram, disque, couts)
@ -749,7 +899,15 @@ def mener(argv, description, famille, classe, couts=None):
if args.dry_run:
print(" --dry-run : rien ne sera créé.\n")
chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json")
descente = classe(plan, journal, args.dry_run, chemin)
descente = classe(
plan, journal, args.dry_run, chemin, racine, profondeur_racine
)
# La racine est-elle en état d'héberger ? Le même contrôle que celui de
# fin d'étage — un hôte emprunté n'a pas été préparé par nous, et rien ne
# garantit que sa pile est debout.
if racine and not args.dry_run and not descente.controler(racine):
print(f"\n ✗ {args.hote} ne peut pas héberger d'étage.\n")
return 1
rapport = descente.parcourir()
with open(chemin, "w", encoding="utf-8") as fh:
json.dump(rapport, fh, indent=2)

View file

@ -56,36 +56,119 @@ class LongTestMenuMixin:
)
},
{"prompt_description": t("Nested Proxmox depth: run it")},
{
"prompt_description": t(
"Nested QEMU depth: plan only (dry-run)"
)
},
{"prompt_description": t("Nested QEMU depth: run it")},
{"prompt_description": t("Undo what the descent created")},
]
# Chaque choix : le script, et s'il faut demander d'où l'on part.
scripts = {
"1": ("deep_proxmox.py", True),
"2": ("deep_proxmox.py", True),
"3": ("deep_qemu.py", True),
"4": ("deep_qemu.py", True),
}
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":
# Le script demande « OUI » avant de détruire, mais il liste
# d'abord : on lui fait faire cette liste À BLANC pour que le
# choix « 3 » d'une touche ne mène pas directement à un
# « qm destroy --purge ».
self._longtest_run("deep_proxmox.py", "--detruire --dry-run")
if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")):
self._longtest_run("deep_proxmox.py", "--detruire")
if status in scripts:
script, demander = scripts[status]
# La profondeur est DEMANDÉE : c'est le réglage qui décide de
# la durée — au-delà de trois étages, tout est 15 à 30 fois
# plus lent, et cinq se comptent en heures.
args = f"--depth {self._longtest_depth()}"
if demander:
args += self._longtest_depart(script)
if status in ("1", "3"):
args += " --dry-run"
self._longtest_run(script, args)
elif status == "5":
self._longtest_defaire()
else:
print(t("Command not found !"))
def _longtest_defaire(self):
"""Défaire, chaque pile la sienne.
Les deux scripts partagent le dossier des rapports mais chacun ne
connaît que les siens : lancer les deux ne peut pas faire détruire à
l'un ce que l'autre a créé.
Le script demande « OUI » avant de détruire, mais il LISTE d'abord :
on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne
mène pas directement à un « qm destroy --purge ».
"""
for script in ("deep_proxmox.py", "deep_qemu.py"):
self._longtest_run(script, "--detruire --dry-run")
if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")):
self._longtest_run(script, "--detruire")
def _longtest_depart(self, script):
"""D'où part la descente : une VM neuve, ou un hôte qu'on a déjà.
Créer une machine de tête pour héberger un hyperviseur qu'on possède
déjà coûte cinq minutes ET un étage d'imbrication — donc de la
lenteur, puisque c'est justement elle qu'on mesure.
L'hôte déjà retenu est proposé sans qu'on ait à le rechercher : c'est
`_pve_host(ask=False)`, qui ne demande rien et ne dit rien s'il n'y en
a pas.
"""
connu = None
if script == "deep_proxmox.py":
try:
connu = self._pve_host(ask=False)
except Exception: # noqa: BLE001 - une préférence illisible
connu = None
print(f"\n{t('Where does the descent start?')}")
print(f" [1] {t('Create a fresh QEMU VM as level one')} *")
if connu:
print(f" [2] {t('Start from:')} {self._pve_label(connu)}")
print(f" [3] {t('Start from another existing host')}")
choix = input(t("Choice (1-3, default 1): ")).strip()
if choix == "2" and connu:
return self._longtest_args_hote(connu)
if choix == "3":
hote = (
self._pve_pick_host()
if script == "deep_proxmox.py"
else self._longtest_hote_manuel()
)
if hote:
return self._longtest_args_hote(hote)
print(t("Cancelled."))
return ""
@staticmethod
def _longtest_args_hote(hote):
"""Les options que le script attend, à partir d'un dict d'hôte."""
args = f" --hote {hote['target']}"
if hote.get("jump"):
args += f" --jump {hote['jump']}"
return args
def _longtest_hote_manuel(self):
"""Un hôte libvirt de départ, saisi à la main.
Pas de sélecteur vérifié comme pour Proxmox : ce qu'on veut ici, c'est
un hôte qui porte KVM, et c'est le script qui le CONSTATE au premier
contrôle — /dev/kvm et l'imbrication — plutôt que le menu qui le
suppose.
"""
cible = input(t("Address (user@host, blank = cancel): ")).strip()
if not cible:
return None
return {
"target": cible,
"jump": input(t("SSH jump host (blank = none): ")).strip(),
}
def _longtest_depth(self):
"""Profondeur demandée. Trois par défaut, parce que trois marche.

View file

@ -3454,6 +3454,38 @@ TRANSLATIONS = {
"fr": "Script introuvable :",
"en": "Script not found:",
},
"Nested QEMU depth: plan only (dry-run)": {
"fr": "Profondeur QEMU imbriqué : le plan seulement (à blanc)",
"en": "Nested QEMU depth: plan only (dry-run)",
},
"Nested QEMU depth: run it": {
"fr": "Profondeur QEMU imbriqué : le lancer",
"en": "Nested QEMU depth: run it",
},
"Where does the descent start?": {
"fr": "D'où part la descente ?",
"en": "Where does the descent start?",
},
"Create a fresh QEMU VM as level one": {
"fr": "Créer une VM QEMU neuve comme premier étage",
"en": "Create a fresh QEMU VM as level one",
},
"Start from:": {
"fr": "Partir de :",
"en": "Start from:",
},
"Start from another existing host": {
"fr": "Partir d'un autre hôte existant",
"en": "Start from another existing host",
},
"Choice (1-3, default 1): ": {
"fr": "Choix (1-3, défaut 1) : ",
"en": "Choice (1-3, default 1): ",
},
"Address (user@host, blank = cancel): ": {
"fr": "Adresse (utilisateur@hôte, vide = annuler) : ",
"en": "Address (user@host, blank = cancel): ",
},
"Depth (default 3): ": {
"fr": "Profondeur (défaut 3) : ",
"en": "Depth (default 3): ",

View file

@ -1032,6 +1032,204 @@ class TestNePasAttendreUneMaisonDisparue(unittest.TestCase):
self.assertEqual(self.d.attendre_ssh(enfant, 60), 0)
class TestLeMenuDesDeuxTests(unittest.TestCase):
"""La liste des choix et le dispatch sont couplés PAR POSITION, sans
garde : ajouter une entrée sans son branchement donne un menu qui affiche
une option et répond « commande inconnue »."""
def setUp(self):
sys.argv = ["todo.py"]
from script.todo.todo import TODO
self.todo = TODO.__new__(TODO)
def test_every_listed_choice_has_a_branch(self):
"""fill_help_info numérote à partir de 1 : le choix n° i doit être
traité, sinon le menu affiche une option et répond « commande
inconnue »."""
import inspect
src = inspect.getsource(self.todo.prompt_execute_longtest)
entrees = src.count('"prompt_description"')
self.assertGreaterEqual(entrees, 5, "le menu a perdu des entrées")
for i in range(1, entrees + 1):
self.assertIn(
f'"{i}"', src, f"le choix {i} est affiché mais pas traité"
)
# Et rien au-delà : un branchement sans entrée est un choix caché.
self.assertNotIn(f'"{entrees + 1}"', src)
def test_both_stacks_are_offered(self):
import inspect
src = inspect.getsource(self.todo.prompt_execute_longtest)
self.assertIn("deep_proxmox.py", src)
self.assertIn("deep_qemu.py", src)
def test_undoing_asks_each_stack_separately(self):
"""Chacun ne connaît que ses rapports : lancer les deux ne peut pas
faire détruire à l'un ce que l'autre a créé."""
import inspect
src = inspect.getsource(self.todo._longtest_defaire)
self.assertIn("deep_proxmox.py", src)
self.assertIn("deep_qemu.py", src)
# À BLANC d'abord, toujours : un choix d'une touche ne doit pas mener
# droit à « qm destroy --purge ».
self.assertLess(
src.index("--detruire --dry-run"), src.index('"--detruire"')
)
def test_the_host_options_are_built_from_the_host_dict(self):
self.assertEqual(
self.todo._longtest_args_hote({"target": "pve9", "jump": ""}),
" --hote pve9",
)
self.assertEqual(
self.todo._longtest_args_hote({"target": "vm", "jump": "porte"}),
" --hote vm --jump porte",
)
def test_a_fresh_vm_is_the_default_answer(self):
"""Toute réponse hors plage retombe sur l'option 1 : le menu ne doit
jamais partir d'un hôte qu'on n'a pas désigné."""
import builtins
vrai = builtins.input
self.addCleanup(setattr, builtins, "input", vrai)
self.todo._pve_host = lambda ask=True: None
for reponse in ("", "1", "n'importe quoi", "9"):
with self.subTest(reponse=reponse):
builtins.input = lambda _p="", r=reponse: r
with contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(
self.todo._longtest_depart("deep_proxmox.py"), ""
)
def test_the_known_host_is_offered_without_being_searched(self):
import builtins
vrai = builtins.input
self.addCleanup(setattr, builtins, "input", vrai)
demande = []
self.todo._pve_host = lambda ask=True: demande.append(ask) or {
"target": "root@10.0.0.5",
"jump": "",
"version": "9.2.11",
}
builtins.input = lambda _p="": "2"
with contextlib.redirect_stdout(io.StringIO()) as sortie:
args = self.todo._longtest_depart("deep_proxmox.py")
self.assertEqual(args, " --hote root@10.0.0.5")
# Sans rien demander : c'est tout l'intérêt de _pve_host(ask=False).
self.assertEqual(demande, [False])
self.assertIn("9.2.11", sortie.getvalue())
def test_a_qemu_descent_does_not_ask_for_a_proxmox(self):
"""Le sélecteur Proxmox VÉRIFIE « pveversion » : le proposer pour une
descente QEMU refuserait un hôte libvirt parfaitement bon."""
import builtins
vrai = builtins.input
self.addCleanup(setattr, builtins, "input", vrai)
reponses = iter(["3", "erplibre@10.0.0.7", ""])
builtins.input = lambda _p="": next(reponses)
self.todo._pve_pick_host = lambda: self.fail(
"sélecteur Proxmox appelé"
)
with contextlib.redirect_stdout(io.StringIO()):
args = self.todo._longtest_depart("deep_qemu.py")
self.assertEqual(args, " --hote erplibre@10.0.0.7")
class TestPartirDunHoteExistant(unittest.TestCase):
"""Créer une VM de tête pour héberger un hyperviseur qu'on possède déjà
coûte cinq minutes ET un étage d'imbrication — donc de la lenteur."""
def test_the_remote_capacity_is_read_as_the_machine_writes_it(self):
# Sortie RÉELLE, prise sur un hôte du parc.
vrai = "COEURS=8\nMEM=MemAvailable: 7056288 kB\nDISQUE= 7G\n"
self.assertEqual(moteur.parse_capacite(vrai), (8, 6890, 7))
def test_a_missing_line_reads_as_zero_not_as_a_guess(self):
"""Un plan dimensionné sur une capacité SUPPOSÉE annoncerait des
étages qui ne tiennent pas."""
self.assertEqual(moteur.parse_capacite(""), (0, 0, 0))
self.assertEqual(moteur.parse_capacite("COEURS=4\n"), (4, 0, 0))
def test_the_capacity_probe_is_written_for_dash(self):
for interdit in ("[[", "pipefail", "$("):
self.assertNotIn(interdit, moteur.CAPACITE_CMD, interdit)
def test_sudo_is_deduced_not_assumed(self):
"""Sur un hôte joint en root, préfixer de « sudo » échoue là où
l'image n'en a pas ; en utilisateur, ne pas le mettre échoue
partout."""
reponses = {}
moteur.pve.run = lambda h, r, t=120: reponses.get(r, (0, ""))
self.addCleanup(setattr, moteur.pve, "run", moteur.pve.run)
vrai = moteur.pve.run
reponses["id -u"] = (0, "0\n")
self.assertEqual(moteur.joindre_racine("h")["sudo"], "")
reponses["id -u"] = (0, "1000\n")
self.assertEqual(moteur.joindre_racine("h")["sudo"], "sudo ")
moteur.pve.run = vrai
def test_an_unreachable_root_is_refused_not_guessed(self):
vrai = moteur.pve.run
moteur.pve.run = lambda h, r, t=120: (255, "")
self.addCleanup(setattr, moteur.pve, "run", vrai)
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertIsNone(moteur.joindre_racine("nulle-part"))
self.assertIn("injoignable", sortie.getvalue())
def test_the_delays_count_the_absolute_depth(self):
"""Un enfant de niveau 1 posé dans une racine DÉJÀ au troisième étage
est en réalité au quatrième. Sans cela il héritait des délais du
premier : quatre fois trop courts."""
d = moteur.Descente.__new__(moteur.Descente)
d.niveau_courant = 1
d.profondeur_racine = 0
seul = d.delai("ssh")
d.profondeur_racine = 3
self.assertEqual(d.delai("ssh"), seul * 16)
def test_a_borrowed_root_is_never_a_level_that_was_reached(self):
"""L'y compter décalerait de un le total ET le code de sortie."""
d = moteur.Descente.__new__(moteur.Descente)
d.plan = {"demandee": 2, "atteignable": 2}
d.etages = [{"niveau": 1, "ok": True}]
d.dry_run = False
d.OUTIL = "deep_proxmox"
d.racine = {"target": "mon-proxmox"}
d.profondeur_racine = 2
etat = d._etat(interrompu=False)
self.assertEqual(etat["atteinte"], 1)
self.assertEqual(len(etat["etages"]), 1)
# Elle est dite, mais à part — et jamais « cree ».
self.assertEqual(etat["racine"]["alias"], "mon-proxmox")
self.assertEqual(etat["racine"]["profondeur"], 2)
self.assertFalse(etat["racine"]["cree"])
def test_without_a_root_the_report_says_so(self):
d = moteur.Descente.__new__(moteur.Descente)
d.plan = {"demandee": 1, "atteignable": 1}
d.etages = []
d.dry_run = False
d.OUTIL = "deep_proxmox"
self.assertIsNone(d._etat(interrompu=False)["racine"])
def test_the_first_level_is_local_only_without_a_root(self):
"""Avec une racine, TOUS les étages sont des enfants — le premier
compris. Sans elle, le premier est une VM créée en local."""
import inspect
src = inspect.getsource(moteur.Descente.parcourir)
self.assertIn("if parent is None:", src)
self.assertNotIn("if niveau == 1:", src)
class TestDeuxPilesNeSeMelangentPas(unittest.TestCase):
"""Le dossier des rapports et le motif « *.json » sont PARTAGÉS.