[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:
parent
b197cc01c5
commit
e687b874d6
2 changed files with 108 additions and 25 deletions
|
|
@ -1259,11 +1259,21 @@ class TODO:
|
|||
# profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM,
|
||||
# donc un parc à deux niveaux est le cas courant.
|
||||
_QEMU_SSH_DEPTH = 2
|
||||
# Sonde exécutée SUR une machine : un couple « nom<TAB>ip » par VM
|
||||
# libvirt. Une seule connexion SSH par niveau plutôt qu'une par VM. Le
|
||||
# bail dnsmasq peut manquer, d'où le repli sur l'agent invité.
|
||||
# Sonde exécutée SUR une machine. Première ligne « LIBVIRT<TAB>yes|no »,
|
||||
# puis un couple « nom<TAB>ip » par VM. Une seule connexion SSH par
|
||||
# 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 = (
|
||||
"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 '
|
||||
"| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); "
|
||||
'if [ -z "$ip" ]; then '
|
||||
|
|
@ -1322,6 +1332,14 @@ class TODO:
|
|||
deploy_key = self._is_yes_default_yes(
|
||||
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)
|
||||
written = []
|
||||
for name in names:
|
||||
|
|
@ -1329,7 +1347,9 @@ class TODO:
|
|||
if not ip:
|
||||
print(f" ⏭ {name}: {t('no IP')}")
|
||||
continue
|
||||
self._write_ssh_config_entry(name, "erplibre", ip)
|
||||
self._write_ssh_config_entry(
|
||||
name, "erplibre", ip, identity_file=identity
|
||||
)
|
||||
written.append(name)
|
||||
print(
|
||||
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.')}")
|
||||
|
||||
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
|
||||
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 = [
|
||||
"ssh",
|
||||
"-o",
|
||||
|
|
@ -1544,12 +1569,17 @@ class TODO:
|
|||
return None
|
||||
if res.returncode != 0:
|
||||
return None
|
||||
has_libvirt = False
|
||||
found = []
|
||||
for line in res.stdout.splitlines():
|
||||
parts = line.strip().split("\t")
|
||||
if len(parts) == 2 and parts[0]:
|
||||
found.append((parts[0], parts[1].strip()))
|
||||
return found
|
||||
if len(parts) != 2 or not parts[0]:
|
||||
continue
|
||||
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):
|
||||
"""Descend le parc en profondeur et écrit un ProxyJump par niveau.
|
||||
|
|
@ -1572,6 +1602,14 @@ class TODO:
|
|||
deploy_key = self._is_yes_default_yes(
|
||||
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.
|
||||
ip_map = self._qemu_resolve_ips(roots, timeout=60)
|
||||
|
|
@ -1583,7 +1621,9 @@ class TODO:
|
|||
if not ip:
|
||||
print(f" ⏭ {name}: {t('no IP')}")
|
||||
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)
|
||||
frontier.append(name)
|
||||
|
||||
|
|
@ -1601,13 +1641,19 @@ class TODO:
|
|||
)
|
||||
next_frontier = []
|
||||
for parent in frontier:
|
||||
found = self._qemu_ssh_probe_remote(parent)
|
||||
if found is None:
|
||||
print(f" ⏭ {parent}: {t('unreachable or no libvirt')}")
|
||||
probed = self._qemu_ssh_probe_remote(parent)
|
||||
if probed is None:
|
||||
print(f" ⏭ {parent}: {t('unreachable')}")
|
||||
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)
|
||||
if not found:
|
||||
print(f" · {parent}: {t('no nested VM')}")
|
||||
print(f" · {parent}: {t('QEMU present, no VM')}")
|
||||
continue
|
||||
for child, ip in found:
|
||||
if not ip:
|
||||
|
|
@ -1622,7 +1668,11 @@ class TODO:
|
|||
if alias in aliases:
|
||||
continue # déjà vu (cycle)
|
||||
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)
|
||||
next_frontier.append(alias)
|
||||
|
|
@ -2924,12 +2974,28 @@ class TODO:
|
|||
print(f"{t('Will execute:')} {cmd}")
|
||||
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.
|
||||
|
||||
`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
|
||||
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")
|
||||
os.makedirs(os.path.dirname(cfg), exist_ok=True)
|
||||
existing = ""
|
||||
|
|
@ -2953,6 +3019,14 @@ class TODO:
|
|||
f" StrictHostKeyChecking no\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:
|
||||
block += f" ProxyJump {proxy_jump}\n"
|
||||
content = (existing + "\n\n" + block) if existing else block
|
||||
|
|
@ -5057,10 +5131,15 @@ class TODO:
|
|||
ip_map = self._qemu_resolve_ips(deployed, labels)
|
||||
|
||||
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:
|
||||
ip = ip_map.get(name)
|
||||
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.
|
||||
if install:
|
||||
|
|
|
|||
|
|
@ -1444,13 +1444,17 @@ TRANSLATIONS = {
|
|||
"fr": "machines à sonder",
|
||||
"en": "machines to probe",
|
||||
},
|
||||
"unreachable or no libvirt": {
|
||||
"fr": "injoignable ou sans libvirt",
|
||||
"en": "unreachable or no libvirt",
|
||||
"unreachable": {
|
||||
"fr": "injoignable",
|
||||
"en": "unreachable",
|
||||
},
|
||||
"no nested VM": {
|
||||
"fr": "aucune VM imbriquée",
|
||||
"en": "no nested VM",
|
||||
"no QEMU/libvirt here": {
|
||||
"fr": "pas de QEMU/libvirt ici",
|
||||
"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): ": {
|
||||
"fr": "Créer la clé SSH si absente et la déployer ? "
|
||||
|
|
|
|||
Loading…
Reference in a new issue