From 51a9e7b97c2ad55c1e656bf601293604abc5a81d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 29 Jul 2026 08:54:36 +0000 Subject: [PATCH] =?UTF-8?q?[FIX]=20script=20todo:=20r=C3=A9solution=20d'IP?= =?UTF-8?q?=20robuste=20(ping=20+=20multi-source=20+=20heartbeat)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur un gros parc (30 VM, ~18 émulées), la résolution d'IP « bloquait » 10 min en silence sur les VM lentes. Trois causes traitées : - Joignabilité par PING (ICMP) d'abord, TCP:22 en repli. Le ping répond dès que le réseau de la VM est up, BIEN AVANT sshd : on ne retenait pas une VM qui a déjà son IP juste parce que sshd (lent en émulation) n'était pas prêt. Le ping distingue toujours le bail actif (répond) du bail périmé (non). - IP cherchée sur PLUSIEURS sources : lease (dnsmasq), agent (qemu-guest- agent DANS la VM) et arp (table ARP hôte). Le bail dnsmasq peut être vide sous forte charge alors que la VM a une IP (constaté sur ce parc). - BATTEMENT toutes les 30 s listant les VM encore en attente + timeout PAR VM ramené à 5 min (au lieu de 10) dans la phase de résolution -> plus de silence prolongé, et on n'attend pas indéfiniment une VM sans IP. NB : déployer la matrice complète (30 VM dont ~18 émulées TCG) sature un seul hôte ; certaines VM émulées n'obtiennent pas de bail DHCP à temps (limite de capacité, pas un bug). La résolution dégrade désormais proprement. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 113 ++++++++++++++++++++++++++------------- script/todo/todo_i18n.py | 4 ++ 2 files changed, 81 insertions(+), 36 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index a24ebda..8b8673e 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1566,26 +1566,50 @@ class TODO: @staticmethod def _qemu_lease_candidates(name): - """Toutes les IPv4 du bail DHCP de la VM. Il peut y en avoir PLUSIEURS : - au boot, l'image cloud demande d'abord une IP avec son hostname par - défaut (« ubuntu ») -> 1er bail ; puis cloud-init fixe le vrai hostname - et le client redemande -> 2e bail (IP différente). Le 1er devient - périmé (« No route to host »).""" - try: - res = subprocess.run( - ["sudo", "virsh", "domifaddr", name, "--source", "lease"], - capture_output=True, - text=True, - timeout=15, - ) - except (OSError, subprocess.SubprocessError): - return [] - return re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout) + """Toutes les IPv4 candidates de la VM, agrégées de PLUSIEURS sources : + - lease : base DHCP de dnsmasq (peut manquer sous forte charge, ou + contenir plusieurs baux : bail précoce « ubuntu » périmé + bail + définitif) ; + - agent : qemu-guest-agent DANS la VM (voit l'IP réelle même quand le + bail dnsmasq est absent) ; + - arp : table ARP de l'hôte (VM active sur le réseau). + On combine pour ne jamais rater une IP que le bail seul manquerait + (cas observé : 30 VM émulées, bail dnsmasq vide alors que la VM a une + IP).""" + ips = [] + 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 + 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: + ips.append(ip) + return ips @staticmethod - def _qemu_ip_reachable(ip, port=22, timeout=3): - """Vrai si le port SSH répond (sshd up) : distingue le bail actif du - bail périmé sans dépendre du ping (souvent filtré).""" + def _qemu_ip_reachable(ip, port=22, timeout=2): + """Vrai si la VM répond sur cette IP (bail ACTIF, pas périmé). On teste + le PING d'abord : il répond dès que le réseau de la VM est up, BIEN + AVANT sshd — sinon on attendait le sshd (lent en émulation) et la + résolution semblait « bloquée » alors que la VM a déjà son IP. Repli + TCP:port si l'ICMP est filtré.""" + try: + res = subprocess.run( + ["ping", "-c", "1", "-W", str(int(timeout)), ip], + capture_output=True, + timeout=timeout + 1, + ) + if res.returncode == 0: + return True + except (OSError, subprocess.SubprocessError): + pass import socket try: @@ -1650,39 +1674,56 @@ class TODO: return f"{secs}s" return f"{secs // 60}m{secs % 60:02d}s" - def _qemu_resolve_ips(self, names, labels=None): + def _qemu_resolve_ips(self, names, labels=None, timeout=300): """Résout les IP de plusieurs VM EN PARALLÈLE (le boot émulé est lent), en affichant la progression au fur et à mesure. Renvoie {nom: ip|None}. `labels` : {nom: « k/N »} pour préfixer chaque ligne d'un ID de suivi. - Évite l'attente EN SÉRIE et SANS sortie qui donnait l'impression d'un - blocage (dashboard qui « n'ouvre jamais »).""" + `timeout` : délai max PAR VM (borne l'attente d'une VM sans IP). Un + BATTEMENT toutes les 30 s liste les VM encore en attente -> jamais de + silence prolongé qui donne l'impression d'un blocage.""" from concurrent.futures import ThreadPoolExecutor, as_completed + from concurrent.futures import TimeoutError as _FTimeout labels = labels or {} print(f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}") result = {} t0 = time.time() + starts = {} workers = min(len(names), (os.cpu_count() or 4)) or 1 with ThreadPoolExecutor(max_workers=workers) as pool: - starts = {} futs = {} for n in names: starts[n] = time.time() - futs[pool.submit(self._qemu_vm_ip, n)] = n - # done = ordre de complétion ; prefixe l'ID de suivi + la durée. - for done, fut in enumerate(as_completed(futs), 1): - n = futs[fut] + futs[pool.submit(self._qemu_vm_ip, n, timeout)] = n + pending = set(futs) + done = 0 + while pending: try: - ip = fut.result() - except Exception: - ip = None - result[n] = ip - tag = f"[{labels[n]}] " if n in labels else "" - dur = self._fmt_dur(time.time() - starts[n]) - print( - f" [{done}/{len(names)}] {tag}{n}: " - f"{ip or t('no IP')} ({dur})" - ) + for fut in as_completed(list(pending), timeout=30): + pending.discard(fut) + n = futs[fut] + try: + ip = fut.result() + except Exception: + ip = None + result[n] = ip + done += 1 + tag = f"[{labels[n]}] " if n in labels else "" + dur = self._fmt_dur(time.time() - starts[n]) + print( + f" [{done}/{len(names)}] {tag}{n}: " + f"{ip or t('no IP')} ({dur})" + ) + except _FTimeout: + # Battement : VM encore en attente (boot/DHCP lent). + waiting = [futs[f] for f in pending] + shown = ", ".join(waiting[:5]) + if len(waiting) > 5: + shown += "…" + print( + f" ⏳ {t('still waiting for')} {len(waiting)} VM " + f"({self._fmt_dur(time.time() - t0)}): {shown}" + ) got = sum(1 for ip in result.values() if ip) print( f" {t('IPs resolved:')} {got}/{len(names)} " diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d1b7546..46cef52 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1649,6 +1649,10 @@ TRANSLATIONS = { "fr": "IP résolues :", "en": "IPs resolved:", }, + "still waiting for": { + "fr": "encore en attente de", + "en": "still waiting for", + }, "TOTAL summary": { "fr": "Sommaire TOTAL", "en": "TOTAL summary",