[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:
parent
8ff88f03d2
commit
032556544e
4 changed files with 498 additions and 27 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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): ",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue