[FIX] qemu : un alias ssh et une adresse ne se jugent pas sur le nom
Suite du nettoyage. Le tri des entrées ~/.ssh/config comparait lui aussi le NOM aux noms de domaines : après le renommage de la VM, son alias « erplibre-ubuntu-2404 » ne correspondait plus à rien et a été effacé, alors qu'il menait à une machine en marche. Une entrée est conservée si son adresse est celle d'un domaine vivant, si elle porte un rebond — donc écrite pour une VM que virsh ne verra jamais — ou si son nom est une VM de l'hôte Proxmox. Et l'écran nomme ce qu'il garde, comme pour les fichiers. Même racine pour l'adresse affichée : « virsh domifaddr --source arp » remonte les passerelles des ponts, et le repli « la dernière candidate » y tombait dès que le bail portait l'ancien nom d'hôte. La VM renommée était annoncée en 192.168.122.1 au lieu de 192.168.123.170. On préfère désormais le bail, puis l'agent, puis l'ARP, et les adresses de l'hôte sont écartées. --- EN --- More of the same cleanup. Sorting ~/.ssh/config entries also compared the NAME against domain names: after the VM was renamed, its alias "erplibre-ubuntu-2404" matched nothing and was deleted, though it led to a running machine. An entry is kept if its address belongs to a live domain, if it carries a jump — hence written for a VM virsh will never see — or if its name is a VM of the Proxmox host. And the screen names what it keeps, as it does for files. Same root for the displayed address: "virsh domifaddr --source arp" returns the bridges' gateways, and falling back to "the last candidate" landed there as soon as the lease carried the old hostname. The renamed VM was announced as 192.168.122.1 instead of 192.168.123.170. The lease now wins over the agent, which wins over ARP, and the host's own addresses are excluded. Assisted-by: Claude Opus 5
This commit is contained in:
parent
81135f0963
commit
9dd4b3e37a
2 changed files with 245 additions and 9 deletions
|
|
@ -1969,18 +1969,74 @@ class QemuManageMixin:
|
|||
self.execute.exec_command_live(cmd, source_erplibre=False)
|
||||
print(f"✅ {t('Cleanup done.')}")
|
||||
|
||||
def _ssh_entry_alive(self, content, nom, domains, adresses, distantes):
|
||||
"""Cette entrée mène-t-elle encore quelque part ? (raison, ou '')
|
||||
|
||||
Le NOM ne suffit pas à en juger — c'est le défaut qui a coûté cher :
|
||||
après un renommage de VM, l'alias garde l'ancien nom et se faisait
|
||||
effacer alors qu'il menait à une machine EN MARCHE. Trois autres
|
||||
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.
|
||||
"""
|
||||
if nom in domains:
|
||||
return nom
|
||||
bloc = re.search(
|
||||
rf"(?ms)^[ \t]*Host[ \t]+{re.escape(nom)}[ \t]*\n"
|
||||
r"((?:[ \t]+[^\n]*\n?)*)",
|
||||
content,
|
||||
)
|
||||
corps = bloc.group(1) if bloc else ""
|
||||
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 ""
|
||||
|
||||
def _cleanup_ssh_config(self, domains):
|
||||
"""Retire les blocs « Host erplibre-* » sans VM correspondante (on ne
|
||||
touche jamais aux autres hôtes SSH personnels)."""
|
||||
"""Retire les blocs « Host erplibre-* » qui ne mènent plus à rien (on
|
||||
ne touche jamais aux autres hôtes SSH personnels)."""
|
||||
cfg = os.path.expanduser("~/.ssh/config")
|
||||
if not os.path.exists(cfg):
|
||||
return
|
||||
with open(cfg, encoding="utf-8") as fh:
|
||||
content = fh.read()
|
||||
# Les adresses des domaines vivants, et les VM de l'hôte Proxmox
|
||||
# retenu : deux preuves qu'une entrée sert encore, que le nom ignore.
|
||||
adresses = {}
|
||||
for nom in domains:
|
||||
ip = self._qemu_vm_ip_now(nom)
|
||||
if ip:
|
||||
adresses[ip] = nom
|
||||
distantes = set()
|
||||
try:
|
||||
hote = self._pve_host(ask=False)
|
||||
if hote:
|
||||
distantes = {
|
||||
v["name"] for v in self._pve_vms() if v.get("name")
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content)
|
||||
orphans = [
|
||||
h for h in hosts if h.startswith("erplibre-") and h not in domains
|
||||
]
|
||||
orphans, gardes = [], []
|
||||
for h in hosts:
|
||||
if not h.startswith("erplibre-"):
|
||||
continue
|
||||
raison = 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:
|
||||
return
|
||||
print(f"\n{t('Orphan ~/.ssh/config entries:')} {', '.join(orphans)}")
|
||||
|
|
@ -2095,6 +2151,58 @@ class QemuManageMixin:
|
|||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _qemu_host_addresses():
|
||||
"""Adresses IPv4 de L'HÔTE, à écarter des candidates d'une VM.
|
||||
|
||||
« virsh domifaddr --source arp » remonte la table ARP, où figurent les
|
||||
passerelles des ponts libvirt (192.168.122.1, 192.168.123.1…). Une VM
|
||||
n'a jamais l'adresse de son hôte : sans ce filtre, une VM RENOMMÉE —
|
||||
dont le bail porte encore l'ancien nom d'hôte, donc sans
|
||||
correspondance — se voyait attribuer la passerelle. Vécu sur
|
||||
« erplibre-ubuntu-2404-MIGRATION », annoncée en 192.168.122.1 au lieu
|
||||
de 192.168.123.170.
|
||||
"""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["ip", "-4", "-o", "addr", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return set()
|
||||
return set(re.findall(r"inet (\d+\.\d+\.\d+\.\d+)", res.stdout))
|
||||
|
||||
@staticmethod
|
||||
def _qemu_candidates_by_source(name):
|
||||
"""{source: [ip]} — les candidates SANS mélanger les sources.
|
||||
|
||||
Le bail dit ce que dnsmasq a donné, l'agent ce que la VM voit,
|
||||
l'ARP ce qui a parlé sur le réseau (passerelles comprises). Les
|
||||
garder séparées permet de choisir la plus sûre quand le nom d'hôte
|
||||
ne tranche pas.
|
||||
"""
|
||||
siennes = QemuManageMixin._qemu_host_addresses()
|
||||
out = {}
|
||||
for source in ("lease", "agent", "arp"):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["sudo", "virsh", "domifaddr", name, "--source", source],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
vues = []
|
||||
for ip in re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout):
|
||||
if ip != "127.0.0.1" and ip not in siennes and ip not in vues:
|
||||
vues.append(ip)
|
||||
if vues:
|
||||
out[source] = vues
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _qemu_lease_candidates(name):
|
||||
"""Toutes les IPv4 candidates de la VM, agrégées de PLUSIEURS sources :
|
||||
|
|
@ -2108,6 +2216,7 @@ class QemuManageMixin:
|
|||
(cas observé : 30 VM émulées, bail dnsmasq vide alors que la VM a une
|
||||
IP)."""
|
||||
ips = []
|
||||
siennes = QemuManageMixin._qemu_host_addresses()
|
||||
for source in ("lease", "agent", "arp"):
|
||||
try:
|
||||
res = subprocess.run(
|
||||
|
|
@ -2119,8 +2228,10 @@ class QemuManageMixin:
|
|||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
for ip in re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout):
|
||||
# Ignore la loopback (remontée par --source agent).
|
||||
if ip != "127.0.0.1" and ip not in ips:
|
||||
# Ignore la loopback (remontée par --source agent) et les
|
||||
# adresses de l'HÔTE (passerelles des ponts, remontées par
|
||||
# --source arp) : une VM n'a jamais celles-là.
|
||||
if ip != "127.0.0.1" and ip not in ips and ip not in siennes:
|
||||
ips.append(ip)
|
||||
return ips
|
||||
|
||||
|
|
@ -2183,10 +2294,22 @@ class QemuManageMixin:
|
|||
une liste — trois VM figeaient le menu une demi-heure. Ici on lit le
|
||||
bail une fois, en préférant celui dont le hostname est le nom de la VM.
|
||||
"""
|
||||
cands = self._qemu_lease_candidates(name)
|
||||
par_source = self._qemu_candidates_by_source(name)
|
||||
cands = [ip for ips in par_source.values() for ip in ips]
|
||||
if not cands:
|
||||
return None
|
||||
return self._qemu_lease_ip_for_host(name, cands) or cands[-1]
|
||||
trouve = self._qemu_lease_ip_for_host(name, cands)
|
||||
if trouve:
|
||||
return trouve
|
||||
# Sans correspondance de nom d'hôte — le cas d'une VM RENOMMÉE, dont
|
||||
# le bail porte encore l'ancien nom — on prend la source la plus
|
||||
# sûre : le bail, puis l'agent, puis la table ARP. Celle-ci contient
|
||||
# les passerelles des ponts, et « la dernière candidate » y tombait :
|
||||
# la VM était annoncée en 192.168.122.1.
|
||||
for source in ("lease", "agent", "arp"):
|
||||
if par_source.get(source):
|
||||
return par_source[source][-1]
|
||||
return cands[-1]
|
||||
|
||||
def _qemu_vm_ip(self, name, timeout=600):
|
||||
"""IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname
|
||||
|
|
|
|||
|
|
@ -172,6 +172,119 @@ class TestEffacerUneVm(unittest.TestCase):
|
|||
self.assertEqual(todo._qemu_vm_own_files("vm-a"), [])
|
||||
|
||||
|
||||
CONFIG_SSH = """Host exo
|
||||
HostName 132.207.112.51
|
||||
|
||||
Host erplibre-ubuntu-2404
|
||||
HostName 192.168.123.170
|
||||
User erplibre
|
||||
|
||||
Host erplibre-partie
|
||||
HostName 192.168.123.99
|
||||
User erplibre
|
||||
|
||||
Host erplibre-imbriquee
|
||||
HostName 10.10.10.150
|
||||
User erplibre
|
||||
ProxyJump erplibre-proxmox-9
|
||||
|
||||
Host erplibre-sur-proxmox
|
||||
HostName 10.10.10.151
|
||||
User erplibre
|
||||
"""
|
||||
|
||||
|
||||
class TestLesEntreesSsh(unittest.TestCase):
|
||||
"""Le nettoyage des entrées ~/.ssh/config jugeait, lui aussi, sur le NOM.
|
||||
|
||||
Conséquence vécue : après le renommage de la VM, son alias
|
||||
« erplibre-ubuntu-2404 » ne correspondait plus à aucun domaine et a été
|
||||
effacé — alors qu'il menait à une machine EN MARCHE. Trois autres preuves
|
||||
valent mieux qu'un nom.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.todo = TODO.__new__(TODO)
|
||||
self.domaines = {"erplibre-ubuntu-2404-MIGRATION"}
|
||||
self.adresses = {"192.168.123.170": "erplibre-ubuntu-2404-MIGRATION"}
|
||||
|
||||
def _juge(self, nom, distantes=()):
|
||||
return self.todo._ssh_entry_alive(
|
||||
CONFIG_SSH, nom, self.domaines, self.adresses, set(distantes)
|
||||
)
|
||||
|
||||
def test_a_renamed_vm_keeps_its_alias(self):
|
||||
# L'adresse mène à un domaine vivant : le nom n'a plus d'importance.
|
||||
self.assertEqual(
|
||||
self._juge("erplibre-ubuntu-2404"),
|
||||
"erplibre-ubuntu-2404-MIGRATION",
|
||||
)
|
||||
|
||||
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_vm_of_the_proxmox_host_is_kept(self):
|
||||
self.assertTrue(
|
||||
self._juge("erplibre-sur-proxmox", ["erplibre-sur-proxmox"])
|
||||
)
|
||||
|
||||
def test_an_entry_that_leads_nowhere_is_an_orphan(self):
|
||||
self.assertEqual(self._juge("erplibre-partie"), "")
|
||||
|
||||
|
||||
class TestLAdresseDUneVm(unittest.TestCase):
|
||||
"""« --source arp » remonte les passerelles des ponts : la dernière
|
||||
candidate n'est pas la bonne.
|
||||
|
||||
Vécu sur la VM renommée : son bail porte encore l'ancien nom d'hôte, donc
|
||||
aucune correspondance, et le repli sur « la dernière » annonçait
|
||||
192.168.122.1 — la passerelle — au lieu de 192.168.123.170.
|
||||
"""
|
||||
|
||||
def _todo(self, par_source):
|
||||
todo = TODO.__new__(TODO)
|
||||
todo._qemu_candidates_by_source = lambda nom: par_source
|
||||
todo._qemu_lease_ip_for_host = lambda nom, cands: None
|
||||
return todo
|
||||
|
||||
def test_the_lease_wins_over_the_arp_table(self):
|
||||
todo = self._todo(
|
||||
{
|
||||
"lease": ["192.168.123.170"],
|
||||
"agent": ["192.168.123.170", "192.168.122.1"],
|
||||
"arp": ["192.168.123.170", "192.168.122.1"],
|
||||
}
|
||||
)
|
||||
self.assertEqual(todo._qemu_vm_ip_now("x"), "192.168.123.170")
|
||||
|
||||
def test_without_a_lease_the_agent_speaks(self):
|
||||
todo = self._todo({"agent": ["10.0.0.5"], "arp": ["192.168.122.1"]})
|
||||
self.assertEqual(todo._qemu_vm_ip_now("x"), "10.0.0.5")
|
||||
|
||||
def test_the_freshest_lease_of_the_source_is_taken(self):
|
||||
# Bail précoce (« ubuntu ») puis bail définitif : c'est le dernier du
|
||||
# BAIL qui compte, pas le dernier toutes sources confondues.
|
||||
todo = self._todo(
|
||||
{"lease": ["192.168.123.10", "192.168.123.11"], "arp": ["1.2.3.4"]}
|
||||
)
|
||||
self.assertEqual(todo._qemu_vm_ip_now("x"), "192.168.123.11")
|
||||
|
||||
def test_nothing_known_yields_nothing(self):
|
||||
self.assertIsNone(self._todo({})._qemu_vm_ip_now("x"))
|
||||
|
||||
def test_the_hosts_own_addresses_are_never_candidates(self):
|
||||
siennes = TODO._qemu_host_addresses()
|
||||
self.assertIn("127.0.0.1", siennes)
|
||||
for nom in TODO.__new__(TODO)._qemu_list_domains():
|
||||
for ips in TODO._qemu_candidates_by_source(nom).values():
|
||||
self.assertFalse(set(ips) & siennes, nom)
|
||||
|
||||
|
||||
class TestLesFichiersOuverts(unittest.TestCase):
|
||||
"""Le contrôle indépendant : /proc, sans privilège."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue