From 5e6c976ecb7bf7140f736e0e20057cf4e52aae13 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 01:00:46 -0400 Subject: [PATCH] [FIX] nettoyage : les enfants s'en vont avec leur rebond MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/todo/qemu_manage.py | 122 +++++++++++++++++++++++++++++----- script/todo/todo_i18n.py | 4 ++ test/test_qemu_cleanup.py | 130 +++++++++++++++++++++++++++++++++++-- 3 files changed, 237 insertions(+), 19 deletions(-) diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 95f6ea7..50f32b0 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -15,6 +15,94 @@ import time 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: """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 : * 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. + + 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: return nom @@ -1993,8 +2085,6 @@ class QemuManageMixin: ip = re.search(r"(?mi)^[ \t]*HostName[ \t]+(\S+)", corps) if ip and ip.group(1) in adresses: 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: return t("a VM of the Proxmox host") return "" @@ -2023,23 +2113,25 @@ class QemuManageMixin: } except Exception: pass - hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content) - orphans, gardes = [], [] - for h in hosts: - if not h.startswith("erplibre-"): - continue - raison = self._ssh_entry_alive( + gardes, orphelines = ssh_orphans( + parse_ssh_blocks(content), + lambda h: self._ssh_entry_alive( content, h, domains, adresses, distantes - ) - (gardes if raison else orphans).append((h, raison)) + ), + ) if gardes: print(f"\n{t('Kept (still leads somewhere):')}") for h, raison in gardes: print(f" {h} ← {raison}") - orphans = [h for h, _r in orphans] - if not orphans: + if not orphelines: 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( input(t("Remove these ~/.ssh/config entries? (y/N): ")) ): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index dea52b2..5e7bcb6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4137,6 +4137,10 @@ TRANSLATIONS = { "fr": "Effacer ces images en double ? (o/N, défaut : non) : ", "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:": { "fr": "Entrées ~/.ssh/config orphelines :", "en": "Orphan ~/.ssh/config entries:", diff --git a/test/test_qemu_cleanup.py b/test/test_qemu_cleanup.py index eda85a7..20be8ff 100644 --- a/test/test_qemu_cleanup.py +++ b/test/test_qemu_cleanup.py @@ -223,10 +223,12 @@ class TestLesEntreesSsh(unittest.TestCase): def test_a_name_that_matches_a_domain_is_kept(self): self.assertTrue(self._juge("erplibre-ubuntu-2404-MIGRATION")) - def test_a_jump_entry_is_kept(self): - # Écrite pour une VM imbriquée ou distante : virsh ne la connaîtra - # jamais, et son adresse n'est pas routable d'ici. - self.assertTrue(self._juge("erplibre-imbriquee")) + def test_a_jump_is_not_a_direct_proof(self): + # Un ProxyJump valait preuve À LUI SEUL. Il n'en est plus une ICI : + # la réponse dépend du rebond, donc des AUTRES entrées, et elle se + # 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): self.assertTrue( @@ -237,6 +239,126 @@ class TestLesEntreesSsh(unittest.TestCase): 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): """« --source arp » remonte les passerelles des ponts : la dernière candidate n'est pas la bonne.