From bd94c277d370352dd7e98ff5578e296b2f53140f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 28 Jul 2026 08:33:50 +0000 Subject: [PATCH] [FIX] script qemu/todo: bonne IP quand la VM a plusieurs baux DHCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptôme : ~/.ssh/config (et l'install) pointaient sur .31 alors que la VM était joignable sur .32 -> SSH KO (« No route to host »), console OK. Cause : au boot, une image cloud demande d'abord une IP DHCP 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). La MÊME MAC a donc DEUX baux ; le 1er (« ubuntu ») devient périmé. Le code retenait aveuglément le PREMIER IPv4 de « virsh domifaddr » = le bail périmé. Plus visible sur s390x/arm64 émulé (boot lent -> les deux baux coexistent). Fix : - todo._qemu_vm_ip : renvoie en priorité l'IP dont le bail dnsmasq porte le hostname == nom de la VM (bail définitif), sinon une IP JOIGNABLE (sshd up, test TCP:22), sinon le dernier bail — jamais le 1er au hasard. Attente portée à 10 min (boot émulé lent). Helpers _qemu_lease_candidates / _qemu_ip_reachable / _qemu_lease_ip_for_host. - deploy_qemu.wait_for_ip : même logique (IP joignable, sinon la plus récente) pour l'IP affichée en fin de déploiement standalone. Validé sur la VM s390x réelle : candidats [.31, .32] -> choisit .32 (hostname-match + seule joignable) ; ssh via alias OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/qemu/deploy_qemu.py | 26 +++++++++-- script/todo/todo.py | 93 +++++++++++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 22 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 18db5e2..fe5cfae 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -51,6 +51,7 @@ import hashlib import os import re import shutil +import socket import subprocess import sys import tempfile @@ -1170,8 +1171,21 @@ def virt_install( runner.run(cmd, privileged=True) +def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool: + """Vrai si le port SSH répond (distingue le bail actif du bail périmé).""" + try: + with socket.create_connection((ip, port), timeout=timeout): + return True + except OSError: + return False + + def wait_for_ip(name: str, use_sudo: bool, timeout: int) -> str | None: - """Interroge les baux DHCP libvirt jusqu'à obtenir l'IPv4 de la VM.""" + """Interroge les baux DHCP libvirt jusqu'à obtenir l'IPv4 de la VM. Une VM + peut avoir PLUSIEURS baux (l'image demande d'abord une IP avec le hostname + par défaut « ubuntu », puis cloud-init fixe le vrai hostname -> 2e bail) : + on renvoie une IP JOIGNABLE (sshd up), sinon la plus récente, jamais + aveuglément la 1re (souvent le bail précoce périmé, « No route to host »).""" base = (["sudo"] if use_sudo else []) + [ "virsh", "domifaddr", @@ -1180,13 +1194,15 @@ def wait_for_ip(name: str, use_sudo: bool, timeout: int) -> str | None: "lease", ] deadline = time.time() + timeout + ips: list[str] = [] while time.time() < deadline: res = subprocess.run(base, capture_output=True, text=True) - m = re.search(r"(\d+\.\d+\.\d+\.\d+)", res.stdout) - if m: - return m.group(1) + ips = re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout) + for ip in ips: + if _ip_reachable(ip): + return ip time.sleep(3) - return None + return ips[-1] if ips else None def ssh_command(user: str, ip: str, has_key: bool) -> str: diff --git a/script/todo/todo.py b/script/todo/todo.py index 3fe30c7..85d32a6 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1678,25 +1678,84 @@ class TODO: except (OSError, subprocess.SubprocessError): return False - def _qemu_vm_ip(self, name, timeout=90): - """Attend puis renvoie l'IPv4 d'une VM (bail DHCP), sinon None.""" - deadline = time.time() + timeout - while time.time() < deadline: - try: - res = subprocess.run( - ["sudo", "virsh", "domifaddr", name, "--source", "lease"], - capture_output=True, - text=True, - timeout=15, - ) - except (OSError, subprocess.SubprocessError): - return None - m = re.search(r"(\d+\.\d+\.\d+\.\d+)", res.stdout) - if m: - return m.group(1) - time.sleep(3) + @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) + + @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é).""" + import socket + + try: + with socket.create_connection((ip, port), timeout=timeout): + return True + except OSError: + return False + + @staticmethod + def _qemu_lease_ip_for_host(name, candidates): + """Parmi `candidates`, l'IP dont le bail dnsmasq porte le hostname de la + VM (le bail DÉFINITIF, pas le bail précoce « ubuntu »). None sinon.""" + try: + res = subprocess.run( + [ + "sudo", + "sh", + "-c", + "cat /var/lib/libvirt/dnsmasq/*.status 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + # Plusieurs tableaux JSON concaténés : on parse chaque objet {...}. + for obj in re.findall(r"\{[^{}]*\}", res.stdout or ""): + if re.search(rf'"hostname":\s*"{re.escape(name)}"', obj): + m = re.search(r'"ip-address":\s*"([\d.]+)"', obj) + if m and m.group(1) in candidates: + return m.group(1) return None + def _qemu_vm_ip(self, name, timeout=600): + """IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname + changé au boot) : renvoie en priorité le bail dont le hostname == nom + de la VM, sinon une IP JOIGNABLE (sshd up), pour ne jamais retenir le + bail précoce périmé. Attend jusqu'à `timeout` (boot émulé lent).""" + deadline = time.time() + timeout + cands = [] + while time.time() < deadline: + cands = self._qemu_lease_candidates(name) + if cands: + # 1) bail définitif (hostname == nom de la VM) + host_ip = self._qemu_lease_ip_for_host(name, cands) + if host_ip: + return host_ip + # 2) sinon, une IP déjà joignable (sshd up) + for ip in cands: + if self._qemu_ip_reachable(ip): + return ip + time.sleep(3) + # Meilleur effort : le dernier bail (le plus récent) plutôt que le 1er. + return cands[-1] if cands else None + def _qemu_pick_branch(self): """Liste les branches distantes d'ERPLibre et en fait choisir une.""" print(f"\n{t('Fetching ERPLibre branch list...')}")