[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:
parent
c96f679858
commit
5e6c976ecb
3 changed files with 237 additions and 19 deletions
|
|
@ -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): "))
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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:",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue