From d6ec01d289ab5b7e4283b72e152e1271d613e211 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 2 Aug 2026 06:51:54 -0400 Subject: [PATCH] [REF] qemu ssh: one flow, and pick where the machines come from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two entries — « local VMs » and « nested, recursive » — wrote the same thing and differed only by how far they walked. That is a question, not a menu, so there is now a single flow and the depth is asked: Profondeur (1 = ces machines seulement, défaut : 2) Depth counts LEVELS of machines including the roots, so 1 writes the roots and probes nothing. The old code always probed, which is why « local VMs only » needed to be a separate entry at all. The first question is where to look, because the machine to configure is not always a VM of this host: [1] Local QEMU VMs (virsh) the previous behaviour [2] Hosts from ~/.ssh/config pick among what is already known; each is probed over SSH and only those actually running QEMU get their guests configured [3] Type a host or an IP a raw IP gets an alias, without which neither the children's ProxyJump nor virt-manager would have a name to use A root taken from ~/.ssh/config carries no IP: its address is already in the file, so nothing is rewritten — the walk simply starts from it. That is the single change that let the two flows merge. Verified on a config holding a wildcard, a bastion and a hypervisor: local VMs reaching their nested guest, ssh-config hosts skipping the bastion (no QEMU) while configuring the hypervisor's guest and offering only the hypervisor to virt-manager, a raw IP producing qemu-10-9-9-9 and its child, depth 1 probing nothing, and [0] asking nothing further. Co-Authored-By: Claude Opus 4.8 (1M context) --- script/todo/todo.py | 190 +++++++++++++++++++++------------------ script/todo/todo_i18n.py | 41 +++++++-- 2 files changed, 138 insertions(+), 93 deletions(-) diff --git a/script/todo/todo.py b/script/todo/todo.py index cc01953..d20d9e1 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1316,29 +1316,92 @@ class TODO: 'printf "%s\\t%s\\n" "$n" "$ip"; done' ) + def _qemu_ssh_pick_roots(self): + """D'où partir pour configurer ~/.ssh/config. Renvoie une liste de + racines [{alias, ip|None}], ou [] pour renoncer. + + Trois provenances, parce que « la machine à configurer » n'est pas + toujours une VM d'ici : elle peut être un hôte déjà connu de + ~/.ssh/config, ou une adresse qu'on vient d'obtenir.""" + print(f"\n{t('Where should the machines come from?')}") + print(f" [1] {t('Local QEMU VMs (virsh)')} *") + print(f" [2] {t('Hosts from ~/.ssh/config')}") + print(f" [3] {t('Type a host or an IP')}") + print(f" [0] {t('Back')}") + answer = input(t("Choice (0-3, default 1): ")).strip() + if answer == "0": + return [] + + if answer == "2": + hosts = self._ssh_config_hosts() + if not hosts: + print(f" {t('~/.ssh/config holds no host.')}") + return [] + for i, name in enumerate(hosts, 1): + print(f" [{i}] {name}") + raw = input( + t("Which hosts? (numbers, comma-separated; blank = all): ") + ).strip() + chosen = ( + hosts if not raw else self._parse_index_selection(raw, hosts) + ) + # Déjà dans ~/.ssh/config : leur adresse y est, rien à réécrire. + return [{"alias": name, "ip": None} for name in chosen or hosts] + + if answer == "3": + target = input(f"{t('Host or IP:')} ").strip() + if not target: + return [] + if target in self._ssh_config_hosts(): + return [{"alias": target, "ip": None}] + # Une IP brute n'est pas un alias : on lui en donne un, sinon ni + # le ProxyJump des enfants ni virt-manager n'auraient de nom. + default_alias = "qemu-" + target.replace(".", "-").replace( + "@", "-" + ) + alias = ( + input( + f"{t('Name for ~/.ssh/config')} ({default_alias}): " + ).strip() + or default_alias + ) + return [{"alias": alias, "ip": target}] + + names = self._qemu_pick_domains() + if not names: + return [] + ip_map = self._qemu_resolve_ips(names, timeout=60) + roots = [] + for name in names: + ip = ip_map.get(name) + if not ip: + print(f" ⏭ {name}: {t('no IP')}") + continue + roots.append({"alias": name, "ip": ip}) + return roots + def _qemu_ssh_config_menu(self): - """Écrit les entrées ~/.ssh/config du parc QEMU.""" + """Écrit les entrées ~/.ssh/config du parc QEMU. + + Un seul flux : les deux anciennes entrées (« VM locales » et + « imbriquées, récursif ») écrivaient la même chose et ne différaient + que par la profondeur — c'est donc une question, pas un menu.""" print(f"🔑 {t('SSH configuration for QEMU VMs')}") - choices = [ - {"prompt_description": t("Update ~/.ssh/config for local VMs")}, - { - "prompt_description": t( - "Add nested VMs through ProxyJump (recursive)" - ) - }, - ] - help_info = self.fill_help_info(choices) - while True: - status = click.prompt(help_info) - print() - if status == "0": - return False - elif status == "1": - self._qemu_ssh_config_local() - elif status == "2": - self._qemu_ssh_config_nested() - else: - print(t("Command not found !")) + roots = self._qemu_ssh_pick_roots() + if not roots: + return + raw = input( + f"{t('Depth (1 = these machines only, default:')}" + f" {self._QEMU_SSH_DEPTH}): " + ).strip() + try: + max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH + except ValueError: + max_depth = self._QEMU_SSH_DEPTH + deploy_key = self._is_yes_default_yes( + input(t("Create the SSH key if missing and deploy it? (Y/n): ")) + ) + self._qemu_ssh_walk(roots, max_depth, deploy_key) def _qemu_pick_domains(self): """Fait choisir des VM parmi celles définies. Vide = toutes.""" @@ -1356,41 +1419,6 @@ class TODO: chosen = self._parse_index_selection(raw, names) return chosen or names - def _qemu_ssh_config_local(self): - """Une entrée ~/.ssh/config par VM locale, comme le fait déjà le - déploiement — mais sur un parc déjà en place.""" - names = self._qemu_pick_domains() - if not names: - return - 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: - ip = ip_map.get(name) - if not ip: - print(f" ⏭ {name}: {t('no IP')}") - continue - 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))}" - f" {t('written')} ({len(names) - len(written)} {t('skipped')})" - ) - if deploy_key: - self._ssh_deploy_keys(written) - def _ssh_ensure_key(self): """Chemin de la clé PUBLIQUE, générée si aucune n'existe. @@ -1652,27 +1680,17 @@ class TODO: 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. + def _qemu_ssh_walk(self, roots, max_depth, deploy_key): + """Descend le parc depuis `roots` et écrit un ProxyJump par niveau. Une VM du profil « Déploiement » héberge elle-même des VM : celles-ci n'ont pas d'IP joignable depuis l'hôte, seulement depuis leur parent. ProxyJump enchaîne les sauts, et la chaîne se construit d'elle-même puisque le parent est déjà dans ~/.ssh/config quand on écrit l'enfant. + + Une racine sans IP est un hôte DÉJÀ décrit dans ~/.ssh/config : son + adresse y est, on ne la réécrit pas, on part simplement de lui. """ - roots = self._qemu_pick_domains() - if not roots: - return - raw = input( - f"{t('Depth (default:')} {self._QEMU_SSH_DEPTH}): " - ).strip() - try: - max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH - except ValueError: - max_depth = self._QEMU_SSH_DEPTH - 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 = ( @@ -1682,27 +1700,25 @@ class TODO: ) identity = self._ssh_private_key(pub) - # Niveau 0 : les VM locales, jointes directement. - ip_map = self._qemu_resolve_ips(roots, timeout=60) entries = [] # un enregistrement par machine écrite taken = set() # tous les noms d'hôte déjà attribués chain_of = {} # alias -> nom chaîné « parent+enfant » hosts_libvirt = [] # machines qui font tourner QEMU frontier = [] - for name in roots: - ip = ip_map.get(name) - if not ip: - print(f" ⏭ {name}: {t('no IP')}") - continue - self._write_ssh_config_entry( - name, "erplibre", ip, identity_file=identity - ) - entries.append({"names": [name], "ip": ip, "parent": None}) - taken.add(name) - chain_of[name] = name - frontier.append(name) + for root in roots: + alias, ip = root["alias"], root.get("ip") + if ip: + self._write_ssh_config_entry( + alias, "erplibre", ip, identity_file=identity + ) + entries.append({"names": [alias], "ip": ip, "parent": None}) + taken.add(alias) + chain_of[alias] = alias + frontier.append(alias) - for depth in range(1, max_depth + 1): + # `max_depth` compte les NIVEAUX de machines, racines comprises : une + # profondeur de 1 s'arrête donc ici, sans rien sonder. + for depth in range(1, max_depth): if not frontier: break # La clé est déployée AVANT de sonder : la sonde utilise @@ -1711,7 +1727,7 @@ class TODO: if deploy_key: self._ssh_deploy_keys(frontier) print( - f"\n🔎 {t('Level')} {depth} — " + f"\n🔎 {t('Level')} {depth + 1} — " f"{len(frontier)} {t('machines to probe')}" ) next_frontier = [] diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index a85d71a..325b823 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1512,13 +1512,42 @@ TRANSLATIONS = { "fr": "Configuration SSH des VM QEMU", "en": "SSH configuration for QEMU VMs", }, - "Update ~/.ssh/config for local VMs": { - "fr": "📝 Mettre à jour ~/.ssh/config pour les VM locales", - "en": "📝 Update ~/.ssh/config for local VMs", + "Where should the machines come from?": { + "fr": "D'où viennent les machines à configurer ?", + "en": "Where should the machines come from?", }, - "Add nested VMs through ProxyJump (recursive)": { - "fr": "🪜 Ajouter les VM imbriquées via ProxyJump (récursif)", - "en": "🪜 Add nested VMs through ProxyJump (recursive)", + "Local QEMU VMs (virsh)": { + "fr": "VM QEMU locales (virsh)", + "en": "Local QEMU VMs (virsh)", + }, + "Hosts from ~/.ssh/config": { + "fr": "Hôtes de ~/.ssh/config", + "en": "Hosts from ~/.ssh/config", + }, + "Type a host or an IP": { + "fr": "Saisir un hôte ou une IP", + "en": "Type a host or an IP", + }, + "~/.ssh/config holds no host.": { + "fr": "~/.ssh/config ne contient aucun hôte.", + "en": "~/.ssh/config holds no host.", + }, + "Which hosts? (numbers, comma-separated; blank = all): ": { + "fr": "Quels hôtes ? (numéros séparés par des virgules ; " + "vide = tous) : ", + "en": "Which hosts? (numbers, comma-separated; blank = all): ", + }, + "Host or IP:": { + "fr": "Hôte ou IP :", + "en": "Host or IP:", + }, + "Name for ~/.ssh/config": { + "fr": "Nom pour ~/.ssh/config", + "en": "Name for ~/.ssh/config", + }, + "Depth (1 = these machines only, default:": { + "fr": "Profondeur (1 = ces machines seulement, défaut :", + "en": "Depth (1 = these machines only, default:", }, "Which VMs? (numbers, comma-separated; blank = all): ": { "fr": "Quelles VM ? (numéros séparés par des virgules ; "