[ADD] proxmox : créer le pont manquant depuis l'écran

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
This commit is contained in:
Mathieu Benoit 2026-08-24 04:59:13 -04:00
parent eca61cb2a7
commit f2b118a110
4 changed files with 186 additions and 7 deletions

View file

@ -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

View file

@ -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(),

View file

@ -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",

View file

@ -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.