[FIX] script qemu/todo: bonne IP quand la VM a plusieurs baux DHCP

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) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-28 08:33:50 +00:00
parent 0930f30cd3
commit bd94c277d3
2 changed files with 97 additions and 22 deletions

View file

@ -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:

View file

@ -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...')}")