[FIX] imbrication : la ressource qui borne, l'attente, le décompte
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)
This commit is contained in:
parent
c046e027e9
commit
4d26155901
4 changed files with 333 additions and 49 deletions
|
|
@ -180,12 +180,20 @@ class Descente:
|
|||
self.dire(f" {ligne}")
|
||||
return code, sortie
|
||||
|
||||
def attendre_ssh(self, hote, delai):
|
||||
def attendre_ssh(self, hote, delai, parent=None):
|
||||
"""Attend que la machine réponde. Rend les secondes, ou None.
|
||||
|
||||
Des connexions COURTES successives : cloud-init régénère les clés
|
||||
d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une
|
||||
session longue.
|
||||
|
||||
`parent` : si l'hôte qui HÉBERGE la machine attendue cesse de
|
||||
répondre, on abandonne tout de suite. Constaté : l'étage 1 a redémarré
|
||||
pendant l'installation de l'étage 4, ce qui a éteint 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 rendu « jamais
|
||||
joignable en ssh ». Le diagnostic était faux : la machine n'était pas
|
||||
lente, sa MAISON n'existait plus.
|
||||
"""
|
||||
if self.dry_run:
|
||||
return 0
|
||||
|
|
@ -196,10 +204,19 @@ class Descente:
|
|||
# joignable en ssh ». Le transport marchait ; c'est le diagnostic qui
|
||||
# était faux.
|
||||
sonde = dict(hote, sudo="")
|
||||
sonde_parent = dict(parent, sudo="") if parent else None
|
||||
while time.time() - debut < delai:
|
||||
code, _o = pve.run(sonde, "true", 60)
|
||||
if code == 0:
|
||||
return int(time.time() - debut)
|
||||
if sonde_parent is not None:
|
||||
code_parent, _p = pve.run(sonde_parent, "true", 60)
|
||||
if code_parent != 0:
|
||||
self.dire(
|
||||
f" ✗ l'hôte {sonde_parent['target']} ne répond"
|
||||
" plus : l'attente n'aboutira pas"
|
||||
)
|
||||
return None
|
||||
time.sleep(15)
|
||||
return None
|
||||
|
||||
|
|
@ -549,7 +566,7 @@ class Descente:
|
|||
etage["alias"] = alias
|
||||
|
||||
etage["etape"] = "ssh"
|
||||
attente = self.attendre_ssh(cible, self.delai("ssh"))
|
||||
attente = self.attendre_ssh(cible, self.delai("ssh"), parent)
|
||||
if attente is None:
|
||||
self.dire(" ✗ jamais joignable en ssh")
|
||||
self.etages.append(etage)
|
||||
|
|
@ -653,6 +670,19 @@ class Descente:
|
|||
f" profondeur atteinte : {atteint}"
|
||||
f" / {self.plan['demandee']}"
|
||||
)
|
||||
# Deux causes très différentes rendaient le même « 5 / 10 » : la
|
||||
# machine trop petite pour dix, ou un étage tombé en route. La
|
||||
# première n'est pas un défaut du code, la seconde si.
|
||||
if atteint == self.plan["atteignable"] < self.plan["demandee"]:
|
||||
self.dire(
|
||||
f" (plan borné à {self.plan['atteignable']} par le"
|
||||
f" {self.plan['arret']} : tout le plan a tenu)"
|
||||
)
|
||||
elif atteint < self.plan["atteignable"]:
|
||||
self.dire(
|
||||
f" (le plan annonçait {self.plan['atteignable']} :"
|
||||
" un étage est tombé, voir plus haut)"
|
||||
)
|
||||
for e in self.etages:
|
||||
if self.dry_run:
|
||||
marque, detail = "·", "plan"
|
||||
|
|
@ -947,18 +977,64 @@ def detruire(journal=None, dry_run=False):
|
|||
# machines » et sortait 1, si bien que le seul avertissement censé
|
||||
# prévenir qu'un disque de plusieurs dizaines de Go reste alloué
|
||||
# s'affichait toujours, et qu'on apprenait à ne plus le lire.
|
||||
if detruire_etage1(journal):
|
||||
racine = detruire_etage1(journal)
|
||||
if racine:
|
||||
faits += 1
|
||||
retirer_alias(rapport, journal)
|
||||
if racine:
|
||||
# L'étage 1 est un DISQUE, et tout le reste vit dedans. « virsh
|
||||
# undefine --remove-all-storage » l'a effacé : les étages injoignables
|
||||
# — leur parent était éteint — ont disparu avec, qu'on ait pu leur
|
||||
# parler ou non. Annoncer « il reste des machines » dans ce cas était
|
||||
# faux dans l'autre sens, et un avertissement faux ne se lit plus.
|
||||
reste = len(liste) + 1 - faits
|
||||
dire(
|
||||
f"\n {len(liste) + 1} / {len(liste) + 1} défait(s)."
|
||||
+ (
|
||||
f" ({reste} injoignable(s), emporté(s) avec le disque de"
|
||||
" l'étage 1.)"
|
||||
if reste
|
||||
else ""
|
||||
),
|
||||
journal,
|
||||
)
|
||||
return 0
|
||||
dire(
|
||||
f"\n {faits} / {len(liste) + 1} défait(s)."
|
||||
+ (
|
||||
""
|
||||
if faits == len(liste) + 1
|
||||
else " ⚠ il reste des machines : voir plus haut."
|
||||
),
|
||||
" ⚠ l'étage 1 est DEBOUT : ce qu'il contient vit encore.",
|
||||
journal,
|
||||
)
|
||||
return 0 if faits == len(liste) + 1 else 1
|
||||
return 1
|
||||
|
||||
|
||||
def retirer_alias(rapport, journal=None):
|
||||
"""Retire de ~/.ssh/config les entrées de la descente défaite.
|
||||
|
||||
Sans cela, elles survivaient aux machines : des entrées mortes dont le
|
||||
ProxyJump désigne un hôte qui n'existe plus, et qu'on retrouve plus tard
|
||||
sans savoir à quoi elles servaient.
|
||||
"""
|
||||
alias = []
|
||||
for etage in rapport.get("etages") or []:
|
||||
nom = etage.get("alias")
|
||||
if not nom:
|
||||
# L'étage abandonné avant l'écriture de son alias : le calculer,
|
||||
# il est déterminé par (niveau, alias du parent).
|
||||
parent = etage.get("parent_alias")
|
||||
if parent:
|
||||
nom = alias_etage(int(etage["niveau"]), parent)
|
||||
if nom and nom not in alias:
|
||||
alias.append(nom)
|
||||
if not alias:
|
||||
return
|
||||
try:
|
||||
from script.todo.todo import TODO
|
||||
|
||||
TODO.__new__(TODO)._write_ssh_config_entry(
|
||||
[], "erplibre", "", also_drop=tuple(alias)
|
||||
)
|
||||
except Exception as err: # noqa: BLE001 - jamais bloquer la destruction
|
||||
dire(f" ⚠ entrées ~/.ssh/config non retirées : {err}", journal)
|
||||
|
||||
|
||||
def principal(argv=None):
|
||||
|
|
@ -992,9 +1068,16 @@ def principal(argv=None):
|
|||
f" {n['disque']:>5} Go"
|
||||
)
|
||||
if plan["arret"]:
|
||||
# Les TROIS plafonds, pas seulement celui qui borne : sans eux on
|
||||
# ajoute la ressource nommée sans savoir de combien, ni laquelle
|
||||
# bornera ensuite.
|
||||
plafonds = " · ".join(
|
||||
f"{nom} {valeur}" for nom, valeur in plan["plafonds"].items()
|
||||
)
|
||||
print(
|
||||
f"\n ⚠ demandée {plan['demandee']}, atteignable"
|
||||
f" {plan['atteignable']} — manque de {plan['arret']}"
|
||||
f" {plan['atteignable']} — c'est le {plan['arret']} qui borne"
|
||||
f"\n profondeur permise par chaque ressource : {plafonds}"
|
||||
)
|
||||
if not plan["niveaux"]:
|
||||
if args.depth < 1:
|
||||
|
|
|
|||
|
|
@ -90,9 +90,19 @@ DISQUE_MIN_GO = 15
|
|||
# a deux, son parent trois, et ainsi de suite. Le premier étage d'une descente
|
||||
# à dix en demande onze — sur vingt-huit cœurs réels, cela passe.
|
||||
VCPU_IMBRIQUE = 2
|
||||
# Ce qu'on accepte de prendre à la machine physique : la moitié de ses cœurs.
|
||||
# L'orchestrateur tourne dessus, et la suite de tests aussi.
|
||||
VCPU_HOTE_PART = 2
|
||||
# Ce qu'on LAISSE à la machine physique. L'orchestrateur tourne dessus, son
|
||||
# ssh vers chaque étage aussi, et la suite de tests avec.
|
||||
#
|
||||
# Un nombre fixe, et non une fraction : « la moitié des cœurs » gardait
|
||||
# quatorze cœurs inutilisés sur vingt-huit, et sur une machine à deux cœurs le
|
||||
# plancher qui l'accompagnait rendait un budget de deux — soit la machine
|
||||
# entière, hôte compris.
|
||||
#
|
||||
# Deux, et pas plus : l'orchestrateur passe son temps à ATTENDRE du ssh, il ne
|
||||
# calcule rien. Quatre auraient interdit toute descente sur un hôte à quatre
|
||||
# cœurs, où un étage tient très bien. Sur une machine trop petite le plan rend
|
||||
# franchement zéro étage plutôt que de surengager l'hôte.
|
||||
HOTE_RESERVE_VCPU = 2
|
||||
|
||||
# Au-delà, l'imbrication n'est pas un terrain documenté par les fabricants.
|
||||
# On ne refuse pas — on le DIT.
|
||||
|
|
@ -107,9 +117,18 @@ def nesting_plan(
|
|||
) -> dict:
|
||||
"""Les ressources de chaque étage, dimensionnées DEPUIS LE BAS.
|
||||
|
||||
Rend {"demandee", "atteignable", "niveaux": [...], "arret"}. `arret` nomme
|
||||
ce qui a manqué — « ram » ou « disque » — quand la profondeur demandée
|
||||
n'est pas atteinte, sinon "".
|
||||
Rend {"demandee", "atteignable", "niveaux", "arret", "plafonds"}.
|
||||
|
||||
`arret` nomme la ressource qui BORNE réellement la profondeur — "ram",
|
||||
"disque" ou "vcpu" — et "" si la profondeur demandée tient. `plafonds`
|
||||
donne les trois profondeurs, une par ressource, pour qu'on puisse voir
|
||||
d'un coup ce qu'il faudrait ajouter et de combien.
|
||||
|
||||
Nommer la bonne, c'est le sujet : la version d'avant prenait la première
|
||||
d'une chaîne figée ram > disque > vcpu, évaluée à la profondeur DEMANDÉE.
|
||||
Sur une machine à deux cœurs et 20 Go, elle annonçait « manque de ram »
|
||||
quand le processeur bornait à un seul étage ; l'opérateur doublait la
|
||||
mémoire et n'y gagnait pas un étage.
|
||||
|
||||
Depuis le bas, et c'est tout le sujet. De haut en bas, chaque étage
|
||||
recevait ce que son parent pouvait céder : mesuré, l'étage 4 se retrouvait
|
||||
|
|
@ -144,26 +163,25 @@ def nesting_plan(
|
|||
VCPU_IMBRIQUE + d - 1,
|
||||
)
|
||||
|
||||
budget_vcpu = max(VCPU_IMBRIQUE, int(cpu_hote) // VCPU_HOTE_PART)
|
||||
atteignable, arret = 0, ""
|
||||
for d in range(max(0, int(profondeur)), 0, -1):
|
||||
ram1, disque1, vcpu1 = besoin(d)
|
||||
if (
|
||||
ram1 <= budget_ram
|
||||
and disque1 <= budget_disque
|
||||
and vcpu1 <= budget_vcpu
|
||||
):
|
||||
atteignable = d
|
||||
break
|
||||
if atteignable < int(profondeur):
|
||||
# Nommer CE qui a manqué, à la profondeur demandée.
|
||||
ram1, disque1, vcpu1 = besoin(max(1, int(profondeur)))
|
||||
if ram1 > budget_ram:
|
||||
arret = "ram"
|
||||
elif disque1 > budget_disque:
|
||||
arret = "disque"
|
||||
else:
|
||||
arret = "vcpu"
|
||||
budget_vcpu = int(cpu_hote) - HOTE_RESERVE_VCPU
|
||||
# La profondeur que chaque budget permet À LUI SEUL. C'est de l'inverse de
|
||||
# `besoin` : un balayage décroissant donnait le même résultat, mais son
|
||||
# coût suivait la profondeur demandée — nesting_plan(10**6, …) tournait un
|
||||
# million de tours pour rendre le même plan.
|
||||
plafonds = {
|
||||
"ram": (budget_ram - PVE_RAM_CIBLE_MO) // PVE_RAM_MO + 1,
|
||||
"disque": (budget_disque - PVE_DISQUE_CIBLE_GO) // PVE_DISQUE_GO + 1,
|
||||
"vcpu": budget_vcpu - VCPU_IMBRIQUE + 1,
|
||||
}
|
||||
plafonds = {nom: max(0, valeur) for nom, valeur in plafonds.items()}
|
||||
demandee = max(0, int(profondeur))
|
||||
atteignable = min(demandee, *plafonds.values())
|
||||
arret = ""
|
||||
if atteignable < demandee:
|
||||
# La ressource qui BORNE, c'est-à-dire celle dont le plafond est le
|
||||
# plus bas — pas la première d'un ordre figé. En ajouter une autre ne
|
||||
# ferait pas monter la profondeur d'un seul étage.
|
||||
arret = min(plafonds, key=lambda nom: (plafonds[nom], nom))
|
||||
niveaux = [
|
||||
{
|
||||
"niveau": niveau,
|
||||
|
|
@ -171,8 +189,13 @@ def nesting_plan(
|
|||
# enfant, c'est cent pour cent de surengagement — et l'hyperviseur
|
||||
# à servir en plus.
|
||||
"vcpu": VCPU_IMBRIQUE + (atteignable - niveau),
|
||||
"ram": PVE_RAM_CIBLE_MO + (atteignable - niveau) * PVE_RAM_MO,
|
||||
"disque": PVE_DISQUE_CIBLE_GO
|
||||
# Les planchers ne sont pas décoratifs : ils tiennent même si
|
||||
# quelqu'un baisse une CIBLE un jour. Sans eux, ils n'étaient plus
|
||||
# lus par personne et les tests qui les vérifiaient passaient
|
||||
# d'eux-mêmes.
|
||||
"ram": max(RAM_MIN_MO, PVE_RAM_CIBLE_MO)
|
||||
+ (atteignable - niveau) * PVE_RAM_MO,
|
||||
"disque": max(DISQUE_MIN_GO, PVE_DISQUE_CIBLE_GO)
|
||||
+ (atteignable - niveau) * PVE_DISQUE_GO,
|
||||
}
|
||||
for niveau in range(1, atteignable + 1)
|
||||
|
|
@ -182,6 +205,7 @@ def nesting_plan(
|
|||
"atteignable": atteignable,
|
||||
"niveaux": niveaux,
|
||||
"arret": arret,
|
||||
"plafonds": plafonds,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -86,11 +86,51 @@ class TestLePlanDesEtages(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(plan["arret"], "vcpu")
|
||||
self.assertLess(plan["atteignable"], 10)
|
||||
# Et le premier étage ne dépasse pas la part concédée à l'hôte.
|
||||
# Et le premier étage laisse à l'hôte ce qui lui est réservé.
|
||||
self.assertLessEqual(
|
||||
plan["niveaux"][0]["vcpu"], 8 // nesting.VCPU_HOTE_PART
|
||||
plan["niveaux"][0]["vcpu"], 8 - nesting.HOTE_RESERVE_VCPU
|
||||
)
|
||||
|
||||
def test_the_named_resource_is_the_one_that_really_binds(self):
|
||||
"""La version d'avant prenait la première d'une chaîne figée
|
||||
ram > disque > vcpu, évaluée à la profondeur DEMANDÉE. Sur deux cœurs
|
||||
et 20 Go elle annonçait « manque de ram » quand le processeur bornait à
|
||||
zéro étage : l'opérateur doublait la mémoire et n'y gagnait rien."""
|
||||
for cpu, ram, disque, attendu in (
|
||||
(2, 20000, 5000, "vcpu"),
|
||||
(2, 200000, 100, "vcpu"),
|
||||
(4, 16384, 200, "vcpu"),
|
||||
(28, 12288, 500, "ram"),
|
||||
(28, 200000, 60, "disque"),
|
||||
):
|
||||
with self.subTest(cpu=cpu, ram=ram, disque=disque):
|
||||
plan = nesting.nesting_plan(10, cpu, ram, disque)
|
||||
self.assertEqual(plan["arret"], attendu)
|
||||
# Et c'est bien le plus BAS des trois plafonds.
|
||||
self.assertEqual(
|
||||
plan["plafonds"][attendu], min(plan["plafonds"].values())
|
||||
)
|
||||
self.assertEqual(
|
||||
plan["atteignable"], min(10, *plan["plafonds"].values())
|
||||
)
|
||||
|
||||
def test_doubling_the_named_resource_gains_a_level(self):
|
||||
"""L'épreuve utile du diagnostic : ce qu'il nomme, ajouté, PAIE."""
|
||||
base = dict(cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500)
|
||||
avant = nesting.nesting_plan(10, **base)
|
||||
self.assertEqual(avant["arret"], "ram")
|
||||
apres = nesting.nesting_plan(
|
||||
10, **{**base, "ram_dispo_mo": base["ram_dispo_mo"] * 2}
|
||||
)
|
||||
self.assertGreater(apres["atteignable"], avant["atteignable"])
|
||||
|
||||
def test_a_huge_depth_costs_nothing(self):
|
||||
# Le balayage décroissant tournait autant de tours que la profondeur
|
||||
# demandée pour rendre exactement le même plan.
|
||||
plan = nesting.nesting_plan(10**6, 28, 58000, 165)
|
||||
self.assertEqual(plan["atteignable"], min(plan["plafonds"].values()))
|
||||
self.assertEqual(len(plan["niveaux"]), plan["atteignable"])
|
||||
|
||||
def test_running_out_of_ram_is_named(self):
|
||||
plan = nesting.nesting_plan(
|
||||
10, cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase):
|
|||
f"10.10.10.{niveau}",
|
||||
)
|
||||
d.ecrire_alias = lambda *a, **k: None
|
||||
d.attendre_ssh = lambda cible, delai: 1
|
||||
d.attendre_ssh = lambda cible, delai, parent=None: 1
|
||||
d.redemarrer_et_verifier = lambda cible: True
|
||||
d.reparer_pmxcfs = lambda cible: True
|
||||
|
||||
|
|
@ -557,6 +557,89 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase):
|
|||
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.
|
||||
|
|
@ -578,9 +661,11 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase):
|
|||
"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": [
|
||||
|
|
@ -601,11 +686,11 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase):
|
|||
|
||||
builtins.input = self._entree
|
||||
|
||||
def _lancer(self, etage1_ok):
|
||||
def _lancer(self, etage1_ok, une=True):
|
||||
import builtins
|
||||
|
||||
builtins.input = lambda _prompt="": "OUI"
|
||||
self.dp.detruire_une = lambda *a, **k: True
|
||||
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)
|
||||
|
|
@ -615,15 +700,67 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase):
|
|||
code, texte = self._lancer(etage1_ok=True)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("3 / 3", texte)
|
||||
self.assertNotIn("il reste des machines", texte)
|
||||
self.assertNotIn("⚠", 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.
|
||||
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("2 / 3", texte)
|
||||
self.assertIn("il reste des machines", texte)
|
||||
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):
|
||||
|
|
|
|||
Loading…
Reference in a new issue