[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:
Mathieu Benoit 2026-08-27 07:32:38 -04:00
parent c046e027e9
commit 4d26155901
4 changed files with 333 additions and 49 deletions

View file

@ -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:

View file

@ -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,
}

View file

@ -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

View file

@ -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):