[FIX] nettoyage : les enfants s'en vont avec leur rebond

La VM Proxmox locale effacée, le nettoyage a retiré son entrée ssh — c'était
juste — et GARDÉ les trois entrées qui rebondissaient par elle, en les
annonçant « mènent encore quelque part ». Trois culs-de-sac, désignés comme
vivants.

Deux fautes. Un ProxyJump valait preuve de vie À LUI SEUL, au motif qu'il
désigne une VM imbriquée que virsh ne connaîtra jamais : le raisonnement
oubliait que le rebond, lui, peut avoir disparu. Et chaque entrée était jugée
ISOLÉMENT, alors que retirer le parent orpheline ses enfants — qui
orphelinent les leurs. D'où un point fixe, et non une passe.

Un rebond qu'on ne gère pas — hôte personnel, adresse, nom DNS — reste
supposé vivant : on n'efface pas sur une supposition. Mais un nom de NOTRE
nommage sans entrée et sans domaine ne mène nulle part, et c'est exactement
l'état qu'un nettoyage précédent laisse derrière lui.

La liste des orphelines dit maintenant POURQUOI. « Son rebond n'existe
plus : erplibre-proxmox-9 » est la seule chose qui permet de répondre non en
connaissance de cause.

--- EN ---

With the local Proxmox VM deleted, the cleanup removed its ssh entry — rightly
— and KEPT the three entries hopping through it, announcing them as "still
lead somewhere". Three dead ends, labelled alive.

Two defects. A ProxyJump counted as proof of life ON ITS OWN, on the grounds
that it names a nested VM virsh will never know: the reasoning forgot the jump
itself can be gone. And each entry was judged IN ISOLATION, while removing a
parent orphans its children — which orphan theirs. Hence a fixed point, not a
single pass.

A jump we do not manage — personal host, address, DNS name — stays presumed
alive: we do not delete on a guess. But a name of OUR OWN convention with no
entry and no domain leads nowhere, and that is exactly the state a previous
cleanup leaves behind.

The orphan list now says WHY. "Its jump host is gone: erplibre-proxmox-9" is
the only thing that lets you answer no knowingly.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-25 01:00:46 -04:00
parent c96f679858
commit 5e6c976ecb
3 changed files with 237 additions and 19 deletions

View file

@ -15,6 +15,94 @@ import time
from script.todo.todo_i18n import t from script.todo.todo_i18n import t
def parse_ssh_blocks(content) -> dict:
"""{nom: {"hostname": …, "proxyjump": …}} pour CHAQUE nom déclaré.
Une ligne « Host » peut en porter plusieurs : ils partagent alors le même
corps, donc la même entrée. Les motifs (« * », « ? ») sont écartés — ce
sont des règles, pas des machines."""
blocs, courant = {}, []
for ligne in (content or "").splitlines():
if re.match(r"^[ \t]*Host[ \t]+", ligne):
corps = {}
courant = [
n for n in ligne.split()[1:] if "*" not in n and "?" not in n
]
for nom in courant:
blocs[nom] = corps
continue
if not courant:
continue
if ligne.strip() and not ligne[:1].isspace():
courant = []
continue
mots = ligne.split()
if len(mots) >= 2 and mots[0].lower() in ("hostname", "proxyjump"):
blocs[courant[0]][mots[0].lower()] = mots[1]
return blocs
def ssh_orphans(blocs, juge, prefixe="erplibre-"):
"""(gardées, orphelines) — chacune [(nom, raison)].
Un ProxyJump valait preuve de vie À LUI SEUL : « écrite pour une VM
imbriquée, que virsh ne connaîtra jamais ». Le raisonnement oubliait que
le rebond, lui, peut avoir disparu. Vécu : la VM Proxmox locale effacée,
le nettoyage a retiré son entrée — correctement — et GARDÉ les trois
entrées qui rebondissaient par elle. Trois culs-de-sac, présentés comme
« mènent encore quelque part ».
D'où le point fixe : retirer un parent peut orpheliner ses enfants, et
ceux-ci peuvent en orpheliner d'autres. On tourne jusqu'à ce que plus
rien ne bouge.
`juge(nom)` ne rend que les preuves DIRECTES — un domaine vivant, une
adresse qui mène à l'un d'eux, une VM de l'hôte Proxmox. Si le rebond
comptait comme preuve directe, une chaîne de rebonds morts se soutiendrait
toute seule."""
noms = [n for n in blocs if n.startswith(prefixe)]
raisons = {n: juge(n) for n in noms}
def rebond_vivant(saut):
if saut in blocs:
# Une entrée qu'on ne gère PAS — hôte personnel — n'est jamais
# notre affaire : on la suppose vivante plutôt que d'effacer sur
# une supposition.
return (
bool(raisons.get(saut)) if saut.startswith(prefixe) else True
)
# Plus AUCUNE entrée de ce nom. Deux lectures, et il faut les
# séparer : une adresse ou un nom DNS, ssh saura le joindre et ce
# n'est pas notre affaire ; un nom de NOTRE nommage, en revanche,
# n'existe que par son entrée — celle-ci partie, le rebond ne mène
# nulle part. C'est l'état exact laissé par un nettoyage précédent,
# qui avait retiré le parent et gardé les enfants.
if not saut.startswith(prefixe):
return True
return bool(juge(saut))
bouge = True
while bouge:
bouge = False
for nom in noms:
if raisons[nom]:
continue
saut = blocs[nom].get("proxyjump")
if saut and rebond_vivant(saut):
raisons[nom] = t("reached through a jump host")
bouge = True
gardes, orphelines = [], []
for nom in noms:
if raisons[nom]:
gardes.append((nom, raisons[nom]))
else:
saut = blocs[nom].get("proxyjump")
orphelines.append(
(nom, f"{t('its jump host is gone:')} {saut}" if saut else "")
)
return gardes, orphelines
class QemuManageMixin: class QemuManageMixin:
"""Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici.""" """Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici."""
@ -1978,9 +2066,13 @@ class QemuManageMixin:
preuves valent mieux : preuves valent mieux :
* son adresse est celle d'un domaine vivant ; * son adresse est celle d'un domaine vivant ;
* elle porte un ProxyJump, donc elle a été écrite pour une VM
imbriquée ou distante, que virsh ne connaîtra jamais ;
* son nom est celui d'une VM de l'hôte Proxmox retenu. * son nom est celui d'une VM de l'hôte Proxmox retenu.
Le ProxyJump n'en fait PAS partie, et c'est le second défaut de cette
fonction : il valait preuve à lui seul, sans qu'on regarde jamais si
le rebond existait encore. Cette question-là se traite dans
`ssh_orphans`, qui seule peut la poser — la réponse dépend des autres
entrées, et de celles qu'on s'apprête à retirer.
""" """
if nom in domains: if nom in domains:
return nom return nom
@ -1993,8 +2085,6 @@ class QemuManageMixin:
ip = re.search(r"(?mi)^[ \t]*HostName[ \t]+(\S+)", corps) ip = re.search(r"(?mi)^[ \t]*HostName[ \t]+(\S+)", corps)
if ip and ip.group(1) in adresses: if ip and ip.group(1) in adresses:
return adresses[ip.group(1)] return adresses[ip.group(1)]
if re.search(r"(?mi)^[ \t]*ProxyJump[ \t]+\S+", corps):
return t("reached through a jump host")
if nom in distantes: if nom in distantes:
return t("a VM of the Proxmox host") return t("a VM of the Proxmox host")
return "" return ""
@ -2023,23 +2113,25 @@ class QemuManageMixin:
} }
except Exception: except Exception:
pass pass
hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content) gardes, orphelines = ssh_orphans(
orphans, gardes = [], [] parse_ssh_blocks(content),
for h in hosts: lambda h: self._ssh_entry_alive(
if not h.startswith("erplibre-"):
continue
raison = self._ssh_entry_alive(
content, h, domains, adresses, distantes content, h, domains, adresses, distantes
) ),
(gardes if raison else orphans).append((h, raison)) )
if gardes: if gardes:
print(f"\n{t('Kept (still leads somewhere):')}") print(f"\n{t('Kept (still leads somewhere):')}")
for h, raison in gardes: for h, raison in gardes:
print(f" {h} ← {raison}") print(f" {h} ← {raison}")
orphans = [h for h, _r in orphans] if not orphelines:
if not orphans:
return return
print(f"\n{t('Orphan ~/.ssh/config entries:')} {', '.join(orphans)}") print(f"\n{t('Orphan ~/.ssh/config entries:')}")
# Avec la RAISON : « son rebond n'existe plus » explique pourquoi une
# entrée qu'on croyait bonne s'en va, et c'est la seule chose qui
# permet de répondre non en connaissance de cause.
for h, raison in orphelines:
print(f" {h}" + (f" ← {raison}" if raison else ""))
orphans = [h for h, _r in orphelines]
if not self._is_yes( if not self._is_yes(
input(t("Remove these ~/.ssh/config entries? (y/N): ")) input(t("Remove these ~/.ssh/config entries? (y/N): "))
): ):

View file

@ -4137,6 +4137,10 @@ TRANSLATIONS = {
"fr": "Effacer ces images en double ? (o/N, défaut : non) : ", "fr": "Effacer ces images en double ? (o/N, défaut : non) : ",
"en": "Delete these duplicate images? (y/N, default: no): ", "en": "Delete these duplicate images? (y/N, default: no): ",
}, },
"its jump host is gone:": {
"fr": "son rebond n'existe plus :",
"en": "its jump host is gone:",
},
"Orphan ~/.ssh/config entries:": { "Orphan ~/.ssh/config entries:": {
"fr": "Entrées ~/.ssh/config orphelines :", "fr": "Entrées ~/.ssh/config orphelines :",
"en": "Orphan ~/.ssh/config entries:", "en": "Orphan ~/.ssh/config entries:",

View file

@ -223,10 +223,12 @@ class TestLesEntreesSsh(unittest.TestCase):
def test_a_name_that_matches_a_domain_is_kept(self): def test_a_name_that_matches_a_domain_is_kept(self):
self.assertTrue(self._juge("erplibre-ubuntu-2404-MIGRATION")) self.assertTrue(self._juge("erplibre-ubuntu-2404-MIGRATION"))
def test_a_jump_entry_is_kept(self): def test_a_jump_is_not_a_direct_proof(self):
# Écrite pour une VM imbriquée ou distante : virsh ne la connaîtra # Un ProxyJump valait preuve À LUI SEUL. Il n'en est plus une ICI :
# jamais, et son adresse n'est pas routable d'ici. # la réponse dépend du rebond, donc des AUTRES entrées, et elle se
self.assertTrue(self._juge("erplibre-imbriquee")) # décide dans ssh_orphans. Sinon une chaîne de rebonds morts se
# soutiendrait toute seule.
self.assertEqual(self._juge("erplibre-imbriquee"), "")
def test_a_vm_of_the_proxmox_host_is_kept(self): def test_a_vm_of_the_proxmox_host_is_kept(self):
self.assertTrue( self.assertTrue(
@ -237,6 +239,126 @@ class TestLesEntreesSsh(unittest.TestCase):
self.assertEqual(self._juge("erplibre-partie"), "") self.assertEqual(self._juge("erplibre-partie"), "")
class TestLeRebondQuiNExistePlus(unittest.TestCase):
"""Le nettoyage a effacé la VM Proxmox locale, retiré son entrée — c'était
juste — et GARDÉ les trois entrées qui rebondissaient par elle.
Trois culs-de-sac, présentés comme « mènent encore quelque part ». Deux
fautes : un ProxyJump valait preuve de vie sans qu'on regarde jamais si le
rebond existait, et chaque entrée était jugée ISOLÉMENT — retirer le
parent ne faisait pas réexaminer les enfants."""
CONFIG = """
Host erplibre-vivante
HostName 192.168.123.170
Host erplibre-proxmox-9
HostName 192.168.123.208
Host erplibre-proxmox-9+enfant
HostName 10.10.10.150
ProxyJump erplibre-proxmox-9
Host erplibre-petit-enfant
HostName 10.10.20.1
ProxyJump erplibre-proxmox-9+enfant
Host erplibre-par-hote-personnel
HostName 10.10.30.1
ProxyJump mon-serveur-perso
Host mon-serveur-perso
HostName 203.0.113.9
"""
def _passe(self, vivants):
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
return ssh_orphans(
parse_ssh_blocks(self.CONFIG),
lambda nom: nom if nom in vivants else "",
)
def test_the_children_go_with_their_jump(self):
_gardes, orphelines = self._passe({"erplibre-vivante"})
noms = [n for n, _r in orphelines]
self.assertIn("erplibre-proxmox-9", noms)
self.assertIn("erplibre-proxmox-9+enfant", noms)
def test_and_so_do_the_grandchildren(self):
# Le point fixe : retirer un parent orpheline ses enfants, qui
# orphelinent les leurs. Une seule passe n'aurait vu que le premier
# étage.
_gardes, orphelines = self._passe({"erplibre-vivante"})
self.assertIn("erplibre-petit-enfant", [n for n, _r in orphelines])
def test_the_reason_names_the_missing_jump(self):
# Seule chose qui permet de répondre non en connaissance de cause.
_gardes, orphelines = self._passe({"erplibre-vivante"})
raison = dict(orphelines)["erplibre-proxmox-9+enfant"]
self.assertIn("erplibre-proxmox-9", raison)
def test_a_living_jump_keeps_its_children(self):
gardes, _orphelines = self._passe(
{"erplibre-vivante", "erplibre-proxmox-9"}
)
noms = [n for n, _r in gardes]
self.assertIn("erplibre-proxmox-9+enfant", noms)
self.assertIn("erplibre-petit-enfant", noms)
def test_a_jump_whose_entry_is_already_gone(self):
"""L'état laissé par le nettoyage précédent : le parent RETIRÉ, les
enfants gardés.
Le rebond ne désigne alors plus rien du tout — ni entrée, ni domaine.
Le prendre pour « un rebond qu'on ne gère pas » laissait les trois
culs-de-sac en place une seconde fois."""
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
sans_parent = """
Host erplibre-vivante
HostName 192.168.123.170
Host erplibre-proxmox-9+enfant
HostName 10.10.10.150
ProxyJump erplibre-proxmox-9
"""
_gardes, orphelines = ssh_orphans(
parse_ssh_blocks(sans_parent),
lambda nom: nom if nom == "erplibre-vivante" else "",
)
self.assertEqual(
[n for n, _r in orphelines], ["erplibre-proxmox-9+enfant"]
)
def test_a_jump_that_is_a_living_domain_without_an_entry(self):
# Le nom est du nôtre et n'a pas d'entrée, mais le domaine TOURNE :
# on ne coupe pas.
from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans
cfg = """
Host erplibre-enfant
HostName 10.10.10.150
ProxyJump erplibre-hote
"""
gardes, _o = ssh_orphans(
parse_ssh_blocks(cfg),
lambda nom: nom if nom == "erplibre-hote" else "",
)
self.assertEqual([n for n, _r in gardes], ["erplibre-enfant"])
def test_a_jump_we_do_not_manage_is_never_our_call(self):
# Hôte personnel, adresse, nom DNS : on le suppose vivant plutôt que
# d'effacer sur une supposition.
gardes, _orphelines = self._passe({"erplibre-vivante"})
self.assertIn("erplibre-par-hote-personnel", [n for n, _r in gardes])
def test_entries_outside_the_prefix_are_never_judged(self):
gardes, orphelines = self._passe({"erplibre-vivante"})
tous = [n for n, _r in gardes] + [n for n, _r in orphelines]
self.assertNotIn("mon-serveur-perso", tous)
class TestLAdresseDUneVm(unittest.TestCase): class TestLAdresseDUneVm(unittest.TestCase):
"""« --source arp » remonte les passerelles des ponts : la dernière """« --source arp » remonte les passerelles des ponts : la dernière
candidate n'est pas la bonne. candidate n'est pas la bonne.