From f2b118a110942e3130886d239b335d219734718e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 04:59:13 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20proxmox=20:=20cr=C3=A9er=20le=20pont=20?= =?UTF-8?q?manquant=20depuis=20l'=C3=A9cran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sans pont, « qm create » est impossible — et l'écran refusait de déployer « aucun pont sur l'hôte » sans offrir le moindre moyen d'en avoir un. Une Proxmox installée SUR Debian n'en a jamais : l'ISO en crée un, pas la procédure sur Debian. Deux moments, donc. Avant l'écran, la question se pose dans le terminal, où l'on peut expliquer les deux voies et montrer ce qui s'exécute. Dans l'écran, le sélecteur porte « ➕ créer un pont interne vmbr0 (10.10.10.1/24) + NAT » : la création part dans un fil, l'affichage reste vivant, et le pont créé se sélectionne tout seul. Elle ne demande rien parce qu'un pont interne ne touche à aucune interface physique ; un pont sur le LAN déplace l'adresse de l'hôte et coupe la session, donc il reste manuel. --- EN --- With no bridge, "qm create" is impossible — and the screen refused to deploy "no bridge on the host" without offering any way to get one. A Proxmox installed ON Debian never has one: the ISO creates it, the Debian procedure does not. Two moments, then. Before the screen, the question is asked in the terminal, where both ways can be explained and the commands shown. In the screen, the selector carries "➕ create an internal vmbr0 (10.10.10.1/24) + NAT": creation runs in a thread, the display stays alive, and the new bridge selects itself. It asks nothing because an internal bridge touches no physical NIC; a bridge on the LAN moves the host's address and cuts the session, so it stays manual. Assisted-by: Claude Opus 5 --- script/todo/proxmox_deploy_form.py | 69 +++++++++++++++++++++++++++++- script/todo/proxmox_menu.py | 53 ++++++++++++++++++++--- script/todo/todo_i18n.py | 4 ++ test/test_proxmox_form.py | 67 +++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 7 deletions(-) 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.