diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index d1590a5..7115e65 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -43,6 +43,11 @@ from script.todo.deploy_form_plan import PlanMixin, preview_screen # dans un stockage que seul l'hôte connaît, jamais dans /var/lib/libvirt. PAS_D_ORPHELIN = None +# Dernier choix du sélecteur de pont : il ne désigne pas un pont, il en crée +# un. Rapporté — l'écran refusait de déployer « aucun pont sur l'hôte » sans +# offrir le moindre moyen d'en avoir un. +CREER_PONT = "__creer_pont__" + def assign_vmids(rows, used, start, ipconfig): """Pose un VMID libre et son adresse sur chaque VM À CRÉER. @@ -177,6 +182,8 @@ def run_proxmox_form(ctx, run_app: bool = True): self._gen = 0 self._shown_ids = () self._syncing = False + # La liste des ponts GRANDIT : l'écran sait en créer un. + self._ponts = list(ponts) # ---------------------------------------------------------------- # # L'écran @@ -248,7 +255,7 @@ def run_proxmox_form(ctx, run_app: bool = True): id="f_storage", ) yield Select( - [(b, b) for b in ponts], + self._choix_ponts(), value=( ctx.get("bridge") or (ponts[0] if ponts else SELECT_NULL) @@ -334,6 +341,57 @@ def run_proxmox_form(ctx, run_app: bool = True): yield Button(t("Cancel"), id="no") yield Footer() + def _choix_ponts(self): + """Les ponts de l'hôte, plus « en créer un ». + + L'entrée de création reste offerte même quand des ponts existent : + sur un hôte qui n'en a qu'un, sur le LAN, on peut vouloir un + réseau interne pour un parc d'essai.""" + choix = [(b, b) for b in self._ponts] + if ctx.get("make_bridge"): + nom, cidr = ctx.get("internal_bridge") or ("vmbr0", "") + choix.append( + ( + f"➕ {t('create an internal')} {nom} ({cidr}) + NAT", + CREER_PONT, + ) + ) + return choix + + def _creer_pont(self) -> None: + """Crée le pont sur l'hôte, dans un FIL : l'appel dure des + secondes, et l'écran doit rester vivant pendant ce temps.""" + self.notify(t("Creating the bridge on the host…")) + + def travail(): + nom, raison = ctx["make_bridge"]() + self.call_from_thread(self._pont_cree, nom, raison) + + self.run_worker(travail, thread=True) + + def _pont_cree(self, nom, raison) -> None: + """Retour du fil. Le sélecteur est remis d'aplomb dans les DEUX + cas : laissé sur « créer », il ne désignerait aucun pont.""" + selecteur = self.query_one("#f_bridge", Select) + if not nom: + self.notify( + f"{t('The bridge did not come up.')} {raison}", + severity="error", + timeout=12, + ) + selecteur.value = ( + self._ponts[0] if self._ponts else SELECT_NULL + ) + return + if nom not in self._ponts: + self._ponts.append(nom) + self._syncing = True + selecteur.set_options(self._choix_ponts()) + selecteur.value = nom + self._syncing = False + self.notify(f"✓ {nom}") + self._refresh_after() + def on_mount(self) -> None: self._reload_catalog() self._sync_install_deps() @@ -414,7 +472,11 @@ def run_proxmox_form(ctx, run_app: bool = True): def _bridge(self): valeur = self.query_one("#f_bridge", Select).value - return "" if valeur is SELECT_NULL else valeur + if valeur is SELECT_NULL or valeur == CREER_PONT: + # La sentinelle n'est pas un pont : la rendre ferait déployer + # une VM sur « __creer_pont__ ». + return "" + return valeur def _storage(self): valeur = self.query_one("#f_storage", Select).value @@ -612,6 +674,9 @@ def run_proxmox_form(ctx, run_app: bool = True): event.select ): return + if ident == "f_bridge" and event.value == CREER_PONT: + self._creer_pont() + return if ident in ("f_storage", "f_bridge"): self._refresh_after() return diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index d531a79..03d6ade 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -571,6 +571,40 @@ class ProxmoxMenuMixin: ) return distant if code == 0 else "" + def _pve_uplink(self): + """Interface qui porte la route par défaut, ou '' — la sortie du NAT. + + Sans elle, le pont interne existe mais ses VM ne voient pas Internet. + """ + _c, sortie = self._pve_show("ip -o -4 route show default", quiet=True) + parts = (sortie or "").split() + return parts[parts.index("dev") + 1] if "dev" in parts else "" + + def _pve_make_internal_bridge(self): + """Crée le pont INTERNE et le rend, ou ('', raison). SANS rien demander. + + Appelable depuis l'écran de déploiement, où Textual tient le terminal : + aucune invite, aucune sortie imprimée, tout est capturé. C'est possible + parce que ce pont ne touche AUCUNE interface physique — il n'y a donc + rien à faire arbitrer. Un pont sur le LAN, lui, déplace l'adresse de + l'hôte et coupe la session : il reste manuel, et l'écran le dit. + """ + from script.proxmox import proxmox_deploy as pve + + host = self._pve_host(ask=False) + if not host: + return "", t("No Proxmox host.") + uplink = self._pve_uplink() + for cmd in pve.bridge_setup_cmds(uplink=uplink): + code, sortie = pve.run(host, cmd, 180) + if code: + lignes = pve.strip_ssh_noise(sortie).strip().splitlines() + return "", (lignes[-1] if lignes else t("Step failed")) + _c, out = pve.run(host, "ip -o link show type bridge", 30) + if pve.INTERNAL_BRIDGE not in pve.parse_bridges(out): + return "", t("The bridge did not come up.") + return pve.INTERNAL_BRIDGE, "" + def _pve_offer_bridge(self): """Aucun pont sur l'hôte : en proposer un, sans risquer l'accès. @@ -602,11 +636,7 @@ class ProxmoxMenuMixin: f" ⚠ {t('This moves the host address: do it from a console.')}" ) return "" - _c, sortie = self._pve_show("ip -o -4 route show default", quiet=True) - uplink = "" - parts = (sortie or "").split() - if "dev" in parts: - uplink = parts[parts.index("dev") + 1] + uplink = self._pve_uplink() print(f" {t('uplink for NAT')} : {uplink or t('none')}") for cmd in pve.bridge_setup_cmds(uplink=uplink): code, _o = self._pve_show(cmd, timeout=120) @@ -680,6 +710,15 @@ class ProxmoxMenuMixin: stockages = pve.parse_storages(out) _c, out = self._pve_show("ip -o link show type bridge", quiet=True) ponts = pve.parse_bridges(out) + if not ponts: + # Le terminal est encore à nous : c'est ICI qu'on peut poser la + # question. L'écran sait aussi le faire, mais sans pouvoir + # expliquer les deux voies ni montrer ce qu'il exécute. + if self._pve_offer_bridge(): + _c, out = self._pve_show( + "ip -o link show type bridge", quiet=True + ) + ponts = pve.parse_bridges(out) _c, cfg = self._pve_show("cat /etc/network/interfaces", quiet=True) infos = pve.parse_bridge_config(cfg) cpu, ram_libre = self._pve_capacity() @@ -717,6 +756,10 @@ class ProxmoxMenuMixin: "bridge": pve.pick_bridge(ponts), "ipconfig": ipconfig, "nameservers": serveurs_dns, + # De quoi créer le pont DEPUIS l'écran, sans invite : le pont + # interne ne touche à aucune interface physique. + "make_bridge": self._pve_make_internal_bridge, + "internal_bridge": (pve.INTERNAL_BRIDGE, pve.INTERNAL_CIDR), "build_command": build_command, "branches": self._qemu_branch_list() or ["master"], "install_profiles": self._qemu_install_profiles(), diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 7c68609..6c4cffb 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3982,6 +3982,10 @@ TRANSLATIONS = { "fr": "Fichiers orphelins :", "en": "Orphan files:", }, + "Creating the bridge on the host…": { + "fr": "Création du pont sur l'hôte…", + "en": "Creating the bridge on the host…", + }, "no disk file found for this VM": { "fr": "aucun fichier de disque trouvé pour cette VM", "en": "no disk file found for this VM", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 5a914a1..a4decb3 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -400,6 +400,73 @@ class TestEcran(unittest.TestCase): self.assertIsNone(self._rendu(gestes).result) +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestCreerUnPont(unittest.TestCase): + """Sans pont, « qm create » est impossible — et l'écran refusait de + déployer sans offrir le moindre moyen d'en avoir un. Rapporté. + + Le pont INTERNE se crée depuis l'écran parce qu'il ne touche à aucune + interface physique : il n'y a rien à faire arbitrer. Un pont sur le LAN + déplace l'adresse de l'hôte et coupe la session : il reste manuel. + """ + + def _ecran(self, fabrique, ponts=()): + from script.todo.proxmox_deploy_form import ( + CREER_PONT, + run_proxmox_form, + ) + + ctx = contexte() + ctx["bridges"] = list(ponts) + ctx["bridge"] = ponts[0] if ponts else "" + ctx["make_bridge"] = fabrique + ctx["internal_bridge"] = ("vmbr0", "10.10.10.1/24") + vu = {} + + async def scenario(): + from textual.widgets import Select + + app = run_proxmox_form(ctx, run_app=False) + async with app.run_test(size=(200, 55)) as pilote: + await pilote.pause() + selecteur = app.query_one("#f_bridge", Select) + vu["choix_avant"] = [str(o[1]) for o in selecteur._options] + selecteur.value = CREER_PONT + for _ in range(30): + await pilote.pause() + if vu.get("fait"): + break + vu["fait"] = bool(app._ponts) and app._bridge() + await pilote.pause() + vu["pont"] = app._bridge() + vu["choix_apres"] = [str(o[1]) for o in selecteur._options] + + asyncio.run(scenario()) + return vu + + def test_the_entry_is_offered_when_no_bridge_exists(self): + vu = self._ecran(lambda: ("vmbr0", "")) + self.assertIn("__creer_pont__", vu["choix_avant"]) + + def test_choosing_it_creates_the_bridge_and_selects_it(self): + vu = self._ecran(lambda: ("vmbr0", "")) + self.assertEqual(vu["pont"], "vmbr0") + self.assertIn("vmbr0", vu["choix_apres"]) + + def test_a_failure_leaves_no_bridge_selected(self): + # Laissé sur « créer », le sélecteur ferait déployer une VM sur + # « __creer_pont__ » — un nom que « qm create » refuserait. + vu = self._ecran(lambda: ("", "Operation not supported")) + self.assertEqual(vu["pont"], "") + + def test_the_entry_stays_offered_when_a_bridge_exists(self): + # Un hôte avec un seul pont sur le LAN : on peut vouloir un réseau + # interne pour un parc d'essai. + vu = self._ecran(lambda: ("vmbr0", ""), ponts=("vmbr9",)) + self.assertIn("__creer_pont__", vu["choix_avant"]) + self.assertIn("vmbr9", vu["choix_avant"]) + + class TestLeSuivi(unittest.TestCase): """La case « Suivre l'installation » doit commander quelque chose.