[FIX] script todo: résolution d'IP robuste (ping + multi-source + heartbeat)

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) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-29 08:54:36 +00:00
parent 1e9e5abd1f
commit 51a9e7b97c
2 changed files with 81 additions and 36 deletions

View file

@ -1566,26 +1566,50 @@ class TODO:
@staticmethod @staticmethod
def _qemu_lease_candidates(name): def _qemu_lease_candidates(name):
"""Toutes les IPv4 du bail DHCP de la VM. Il peut y en avoir PLUSIEURS : """Toutes les IPv4 candidates de la VM, agrégées de PLUSIEURS sources :
au boot, l'image cloud demande d'abord une IP avec son hostname par - lease : base DHCP de dnsmasq (peut manquer sous forte charge, ou
défaut (« ubuntu ») -> 1er bail ; puis cloud-init fixe le vrai hostname contenir plusieurs baux : bail précoce « ubuntu » périmé + bail
et le client redemande -> 2e bail (IP différente). Le 1er devient définitif) ;
périmé (« No route to host »).""" - agent : qemu-guest-agent DANS la VM (voit l'IP réelle même quand le
try: bail dnsmasq est absent) ;
res = subprocess.run( - arp : table ARP de l'hôte (VM active sur le réseau).
["sudo", "virsh", "domifaddr", name, "--source", "lease"], On combine pour ne jamais rater une IP que le bail seul manquerait
capture_output=True, (cas observé : 30 VM émulées, bail dnsmasq vide alors que la VM a une
text=True, IP)."""
timeout=15, ips = []
) for source in ("lease", "agent", "arp"):
except (OSError, subprocess.SubprocessError): try:
return [] res = subprocess.run(
return re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout) ["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 @staticmethod
def _qemu_ip_reachable(ip, port=22, timeout=3): def _qemu_ip_reachable(ip, port=22, timeout=2):
"""Vrai si le port SSH répond (sshd up) : distingue le bail actif du """Vrai si la VM répond sur cette IP (bail ACTIF, pas périmé). On teste
bail périmé sans dépendre du ping (souvent filtré).""" 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 import socket
try: try:
@ -1650,39 +1674,56 @@ class TODO:
return f"{secs}s" return f"{secs}s"
return f"{secs // 60}m{secs % 60:02d}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), """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}. 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. `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 `timeout` : délai max PAR VM (borne l'attente d'une VM sans IP). Un
blocage (dashboard qui « n'ouvre jamais »).""" 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 ThreadPoolExecutor, as_completed
from concurrent.futures import TimeoutError as _FTimeout
labels = labels or {} labels = labels or {}
print(f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}") print(f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}")
result = {} result = {}
t0 = time.time() t0 = time.time()
starts = {}
workers = min(len(names), (os.cpu_count() or 4)) or 1 workers = min(len(names), (os.cpu_count() or 4)) or 1
with ThreadPoolExecutor(max_workers=workers) as pool: with ThreadPoolExecutor(max_workers=workers) as pool:
starts = {}
futs = {} futs = {}
for n in names: for n in names:
starts[n] = time.time() starts[n] = time.time()
futs[pool.submit(self._qemu_vm_ip, n)] = n futs[pool.submit(self._qemu_vm_ip, n, timeout)] = n
# done = ordre de complétion ; prefixe l'ID de suivi + la durée. pending = set(futs)
for done, fut in enumerate(as_completed(futs), 1): done = 0
n = futs[fut] while pending:
try: try:
ip = fut.result() for fut in as_completed(list(pending), timeout=30):
except Exception: pending.discard(fut)
ip = None n = futs[fut]
result[n] = ip try:
tag = f"[{labels[n]}] " if n in labels else "" ip = fut.result()
dur = self._fmt_dur(time.time() - starts[n]) except Exception:
print( ip = None
f" [{done}/{len(names)}] {tag}{n}: " result[n] = ip
f"{ip or t('no IP')} ({dur})" 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) got = sum(1 for ip in result.values() if ip)
print( print(
f" {t('IPs resolved:')} {got}/{len(names)} " f" {t('IPs resolved:')} {got}/{len(names)} "

View file

@ -1649,6 +1649,10 @@ TRANSLATIONS = {
"fr": "IP résolues :", "fr": "IP résolues :",
"en": "IPs resolved:", "en": "IPs resolved:",
}, },
"still waiting for": {
"fr": "encore en attente de",
"en": "still waiting for",
},
"TOTAL summary": { "TOTAL summary": {
"fr": "Sommaire TOTAL", "fr": "Sommaire TOTAL",
"en": "TOTAL summary", "en": "TOTAL summary",