[FIX] qemu ssh: only offer virt-manager the hosts that run QEMU

Every reachable machine ended up in virt-manager, not just the ones hosting
VMs. The probe ran « for n in $(sudo virsh list --all --name 2>/dev/null) »:
with no virsh, the command substitution is empty, the loop body never runs and
the snippet exits 0 with no output — indistinguishable from « QEMU is here,
it just has no VM ». Both read as a libvirt host.

The probe now states it outright, « LIBVIRT<TAB>yes|no » as its first line, so
the two cases separate: a machine WITHOUT QEMU is skipped, a machine with QEMU
and no VM is still offered — that is where one would create some.

Verified against the real snippet: this host answers « yes » plus its two VMs,
and with virsh out of PATH it answers « no ». Then on a simulated fleet, only
vm-avec-qemu and vm-qemu-sans-vm are proposed; the plain dev VM and the
unreachable one are not.

Also: the ~/.ssh/config blocks were missing IdentityFile. Every entry now
names the private key it needs — the one cloud-init injected, or the one just
deployed — with IdentitiesOnly yes beside it. Without that flag IdentityFile
ADDS to the agent's identities instead of replacing them, and a slightly full
agent hits « Too many authentication failures » before reaching the right key.
The .pub suffix is stripped: IdentityFile wants the private half.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-02 02:03:05 -04:00
parent b197cc01c5
commit e687b874d6
2 changed files with 108 additions and 25 deletions

View file

@ -1259,11 +1259,21 @@ class TODO:
# profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM, # profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM,
# donc un parc à deux niveaux est le cas courant. # donc un parc à deux niveaux est le cas courant.
_QEMU_SSH_DEPTH = 2 _QEMU_SSH_DEPTH = 2
# Sonde exécutée SUR une machine : un couple « nom<TAB>ip » par VM # Sonde exécutée SUR une machine. Première ligne « LIBVIRT<TAB>yes|no »,
# libvirt. Une seule connexion SSH par niveau plutôt qu'une par VM. Le # puis un couple « nom<TAB>ip » par VM. Une seule connexion SSH par
# bail dnsmasq peut manquer, d'où le repli sur l'agent invité. # niveau plutôt qu'une par VM ; le bail dnsmasq peut manquer, d'où le
# repli sur l'agent invité.
#
# La première ligne est indispensable : sans virsh, la boucle ne tourne
# simplement pas et la sonde sortirait VIDE avec un code 0 — impossible
# alors de distinguer « pas de QEMU ici » de « QEMU présent, aucune VM ».
_QEMU_SSH_PROBE = ( _QEMU_SSH_PROBE = (
"for n in $(sudo virsh list --all --name 2>/dev/null); do " "if ! command -v virsh >/dev/null 2>&1; then "
"printf 'LIBVIRT\\tno\\n'; exit 0; fi; "
"vms=$(sudo virsh list --all --name 2>/dev/null) || "
"{ printf 'LIBVIRT\\tno\\n'; exit 0; }; "
"printf 'LIBVIRT\\tyes\\n'; "
"for n in $vms; do "
'ip=$(sudo virsh domifaddr "$n" --source lease 2>/dev/null ' 'ip=$(sudo virsh domifaddr "$n" --source lease 2>/dev/null '
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); " "| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); "
'if [ -z "$ip" ]; then ' 'if [ -z "$ip" ]; then '
@ -1322,6 +1332,14 @@ class TODO:
deploy_key = self._is_yes_default_yes( deploy_key = self._is_yes_default_yes(
input(t("Create the SSH key if missing and deploy it? (Y/n): ")) input(t("Create the SSH key if missing and deploy it? (Y/n): "))
) )
# La clé est fixée AVANT d'écrire : c'est elle qui va dans
# IdentityFile, et c'est la même qu'on déploiera ensuite.
pub = (
self._ssh_ensure_key()
if deploy_key
else self._qemu_default_ssh_key()
)
identity = self._ssh_private_key(pub)
ip_map = self._qemu_resolve_ips(names, timeout=60) ip_map = self._qemu_resolve_ips(names, timeout=60)
written = [] written = []
for name in names: for name in names:
@ -1329,7 +1347,9 @@ class TODO:
if not ip: if not ip:
print(f"{name}: {t('no IP')}") print(f"{name}: {t('no IP')}")
continue continue
self._write_ssh_config_entry(name, "erplibre", ip) self._write_ssh_config_entry(
name, "erplibre", ip, identity_file=identity
)
written.append(name) written.append(name)
print( print(
f"\n{len(written)} {self._plural(t('entry'), len(written))}" f"\n{len(written)} {self._plural(t('entry'), len(written))}"
@ -1522,11 +1542,16 @@ class TODO:
print(f"{t('Restart virt-manager if it is open.')}") print(f"{t('Restart virt-manager if it is open.')}")
def _qemu_ssh_probe_remote(self, alias): def _qemu_ssh_probe_remote(self, alias):
"""VM libvirt vues DEPUIS `alias` : [(nom, ip)]. """Sonde `alias` : (libvirt_présent, [(nom, ip)]), ou None si
injoignable.
Passe par « ssh <alias> », donc par le bloc ~/.ssh/config qu'on vient Passe par « ssh <alias> », donc par le bloc ~/.ssh/config qu'on vient
d'écrire : le ProxyJump du parent s'applique tout seul et la même d'écrire : le ProxyJump du parent s'applique tout seul et la même
sonde marche à n'importe quelle profondeur.""" sonde marche à n'importe quelle profondeur.
« libvirt présent » et « a des VM » sont deux choses distinctes : une
machine avec QEMU mais sans VM mérite quand même sa connexion
virt-manager, une machine sans QEMU n'en veut aucune."""
cmd = [ cmd = [
"ssh", "ssh",
"-o", "-o",
@ -1544,12 +1569,17 @@ class TODO:
return None return None
if res.returncode != 0: if res.returncode != 0:
return None return None
has_libvirt = False
found = [] found = []
for line in res.stdout.splitlines(): for line in res.stdout.splitlines():
parts = line.strip().split("\t") parts = line.strip().split("\t")
if len(parts) == 2 and parts[0]: if len(parts) != 2 or not parts[0]:
found.append((parts[0], parts[1].strip())) continue
return found if parts[0] == "LIBVIRT":
has_libvirt = parts[1].strip() == "yes"
continue
found.append((parts[0], parts[1].strip()))
return has_libvirt, found
def _qemu_ssh_config_nested(self): def _qemu_ssh_config_nested(self):
"""Descend le parc en profondeur et écrit un ProxyJump par niveau. """Descend le parc en profondeur et écrit un ProxyJump par niveau.
@ -1572,6 +1602,14 @@ class TODO:
deploy_key = self._is_yes_default_yes( deploy_key = self._is_yes_default_yes(
input(t("Create the SSH key if missing and deploy it? (Y/n): ")) input(t("Create the SSH key if missing and deploy it? (Y/n): "))
) )
# La clé est fixée AVANT d'écrire quoi que ce soit : c'est elle qui
# va dans IdentityFile à chaque niveau, et celle qu'on déploiera.
pub = (
self._ssh_ensure_key()
if deploy_key
else self._qemu_default_ssh_key()
)
identity = self._ssh_private_key(pub)
# Niveau 0 : les VM locales, jointes directement. # Niveau 0 : les VM locales, jointes directement.
ip_map = self._qemu_resolve_ips(roots, timeout=60) ip_map = self._qemu_resolve_ips(roots, timeout=60)
@ -1583,7 +1621,9 @@ class TODO:
if not ip: if not ip:
print(f"{name}: {t('no IP')}") print(f"{name}: {t('no IP')}")
continue continue
self._write_ssh_config_entry(name, "erplibre", ip) self._write_ssh_config_entry(
name, "erplibre", ip, identity_file=identity
)
aliases[name] = (name, ip, None) aliases[name] = (name, ip, None)
frontier.append(name) frontier.append(name)
@ -1601,13 +1641,19 @@ class TODO:
) )
next_frontier = [] next_frontier = []
for parent in frontier: for parent in frontier:
found = self._qemu_ssh_probe_remote(parent) probed = self._qemu_ssh_probe_remote(parent)
if found is None: if probed is None:
print(f"{parent}: {t('unreachable or no libvirt')}") print(f"{parent}: {t('unreachable')}")
continue continue
has_libvirt, found = probed
if not has_libvirt:
print(f" · {parent}: {t('no QEMU/libvirt here')}")
continue
# Une machine avec QEMU vaut sa connexion virt-manager, même
# sans VM : c'est là qu'on pourra en créer.
hosts_libvirt.append(parent) hosts_libvirt.append(parent)
if not found: if not found:
print(f" · {parent}: {t('no nested VM')}") print(f" · {parent}: {t('QEMU present, no VM')}")
continue continue
for child, ip in found: for child, ip in found:
if not ip: if not ip:
@ -1622,7 +1668,11 @@ class TODO:
if alias in aliases: if alias in aliases:
continue # déjà vu (cycle) continue # déjà vu (cycle)
self._write_ssh_config_entry( self._write_ssh_config_entry(
alias, "erplibre", ip, proxy_jump=parent alias,
"erplibre",
ip,
proxy_jump=parent,
identity_file=identity,
) )
aliases[alias] = (child, ip, parent) aliases[alias] = (child, ip, parent)
next_frontier.append(alias) next_frontier.append(alias)
@ -2924,12 +2974,28 @@ class TODO:
print(f"{t('Will execute:')} {cmd}") print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False) self.execute.exec_command_live(cmd, source_erplibre=False)
def _write_ssh_config_entry(self, host, user, ip, proxy_jump=None): @staticmethod
def _ssh_private_key(pub_path):
"""Clé PRIVÉE correspondant à une clé publique, ou '' si introuvable.
C'est elle que réclame IdentityFile ; donner le « .pub » ferait
échouer l'authentification."""
if not pub_path:
return ""
priv = pub_path[:-4] if pub_path.endswith(".pub") else pub_path
return priv if os.path.exists(os.path.expanduser(priv)) else ""
def _write_ssh_config_entry(
self, host, user, ip, proxy_jump=None, identity_file=None
):
"""Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config. """Écrit/remplace un bloc « Host <host> » dans ~/.ssh/config.
`proxy_jump` : alias du rebond pour une VM imbriquée, dont l'IP n'est `proxy_jump` : alias du rebond pour une VM imbriquée, dont l'IP n'est
joignable que depuis son hôte. OpenSSH enchaîne les ProxyJump tout joignable que depuis son hôte. OpenSSH enchaîne les ProxyJump tout
seul dès que le parent a lui-même le sien.""" seul dès que le parent a lui-même le sien.
`identity_file` : clé PRIVÉE à présenter. Sans elle, ssh propose
toutes les identités de l'agent et un parc un peu fourni déclenche
« Too many authentication failures » avant d'arriver à la bonne."""
cfg = os.path.expanduser("~/.ssh/config") cfg = os.path.expanduser("~/.ssh/config")
os.makedirs(os.path.dirname(cfg), exist_ok=True) os.makedirs(os.path.dirname(cfg), exist_ok=True)
existing = "" existing = ""
@ -2953,6 +3019,14 @@ class TODO:
f" StrictHostKeyChecking no\n" f" StrictHostKeyChecking no\n"
f" UserKnownHostsFile /dev/null\n" f" UserKnownHostsFile /dev/null\n"
) )
if identity_file:
# IdentitiesOnly : sans lui, IdentityFile s'AJOUTE aux clés de
# l'agent au lieu de les remplacer, et le serveur coupe après
# 5 essais infructueux.
block += (
f" IdentityFile {identity_file}\n"
f" IdentitiesOnly yes\n"
)
if proxy_jump: if proxy_jump:
block += f" ProxyJump {proxy_jump}\n" block += f" ProxyJump {proxy_jump}\n"
content = (existing + "\n\n" + block) if existing else block content = (existing + "\n\n" + block) if existing else block
@ -5057,10 +5131,15 @@ class TODO:
ip_map = self._qemu_resolve_ips(deployed, labels) ip_map = self._qemu_resolve_ips(deployed, labels)
if add_ssh_config: if add_ssh_config:
# La clé injectée par cloud-init est celle de la spec : c'est
# elle que doit présenter ssh, pas la première venue de l'agent.
identity = self._ssh_private_key(ssh_key)
for name in deployed: for name in deployed:
ip = ip_map.get(name) ip = ip_map.get(name)
if ip: if ip:
self._write_ssh_config_entry(name, "erplibre", ip) self._write_ssh_config_entry(
name, "erplibre", ip, identity_file=identity
)
# 7) Installation ERPLibre (clone + make) si demandée. # 7) Installation ERPLibre (clone + make) si demandée.
if install: if install:

View file

@ -1444,13 +1444,17 @@ TRANSLATIONS = {
"fr": "machines à sonder", "fr": "machines à sonder",
"en": "machines to probe", "en": "machines to probe",
}, },
"unreachable or no libvirt": { "unreachable": {
"fr": "injoignable ou sans libvirt", "fr": "injoignable",
"en": "unreachable or no libvirt", "en": "unreachable",
}, },
"no nested VM": { "no QEMU/libvirt here": {
"fr": "aucune VM imbriquée", "fr": "pas de QEMU/libvirt ici",
"en": "no nested VM", "en": "no QEMU/libvirt here",
},
"QEMU present, no VM": {
"fr": "QEMU présent, aucune VM",
"en": "QEMU present, no VM",
}, },
"Create the SSH key if missing and deploy it? (Y/n): ": { "Create the SSH key if missing and deploy it? (Y/n): ": {
"fr": "Créer la clé SSH si absente et la déployer ? " "fr": "Créer la clé SSH si absente et la déployer ? "