[FIX] proxmox : le pont NAT s'écrivait avant de savoir si le NAT existe

« Table does not exist » : six lignes d'iptables et « code de retour 1 »,
après avoir déjà posé la strophe dans /etc/network/interfaces. Rien dans ce
bruit ne dit qu'il faut redémarrer.

L'hôte tournait le noyau cloud de Debian, qui est dépouillé de tout netfilter
— aucun module NAT, ni legacy ni nft. Et le cas n'a rien d'exotique : c'est
notre propre install_proxmox.sh qui le produit. Il pose le noyau Proxmox sans
redémarrer, à raison — lancé par ssh, un reboot couperait la session et ferait
passer l'installation pour un échec. Une Proxmox imbriquée fraîchement
installée est donc TOUJOURS dans cet état.

L'avertissement sur le noyau existait déjà, mais à la CONFIRMATION de l'hôte,
et l'hôte est ensuite mémorisé : on revient des jours plus tard créer un pont,
et plus personne ne rappelle rien. Le garde va donc là où la conséquence
tombe, et AVANT toute écriture. Il interroge la table NAT elle-même et non le
NOM du noyau — « -pve » est un indice, pas une preuve — puis nomme le noyau
en cours, celui qui est posé, et la commande qui règle l'affaire.

Le sommaire de déploiement le dit désormais aussi, tant qu'on lit encore
l'écran plutôt qu'au bout d'un journal d'une heure.

--- EN ---

"Table does not exist": six lines of iptables and "exit code 1", after the
stanza had already been written into /etc/network/interfaces. Nothing in that
noise says a reboot is needed.

The host was running Debian's cloud kernel, stripped of all netfilter — no NAT
module, legacy or nft. And the case is not exotic: our own install_proxmox.sh
produces it. It installs the Proxmox kernel without rebooting, rightly — run
over ssh, a reboot would cut the session and make the install look failed. A
freshly installed nested Proxmox is therefore ALWAYS in this state.

The kernel warning already existed, but at host CONFIRMATION, and the host is
then remembered: you come back days later to create a bridge and nothing
reminds you. So the guard moves to where the consequence lands, and BEFORE any
write. It asks the NAT table itself rather than the kernel's NAME — "-pve" is
a hint, not a proof — then names the running kernel, the installed one, and
the command that settles it.

The deployment summary now says it too, while the screen is still being read
rather than at the end of an hour-long log.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-25 02:16:54 -04:00
parent 30ffb262b6
commit eb5e607e1e
5 changed files with 317 additions and 0 deletions

View file

@ -218,6 +218,38 @@ def parse_kernel(text: str) -> str:
return trouve.group(1) if trouve else ""
# Ce qu'il faut savoir AVANT d'écrire un pont NAT, en un aller-retour.
#
# Le noyau seul ne suffit pas à juger : « -pve » dans son nom est un indice,
# pas une preuve, et l'inverse non plus — c'est la table NAT elle-même qu'on
# interroge. « iptables -t nat -S » échoue avec « Table does not exist » quand
# aucun module netfilter n'est chargeable, et réussit sinon.
NAT_CHECK_CMD = (
"uname -r; echo '---ERPLIBRE-NAT---'; "
"iptables -t nat -S >/dev/null 2>&1 && echo NAT-OK || echo NAT-KO; "
"echo '---ERPLIBRE-PVE-KERNEL---'; "
"ls -1 /lib/modules 2>/dev/null | grep -- -pve | sort -V | tail -1"
)
def parse_nat_check(text: str) -> dict:
"""{"kernel": …, "nat": bool, "pve_kernel": …} depuis NAT_CHECK_CMD.
`nat` à False sans `pve_kernel` veut dire que l'installation Proxmox n'est
pas allée au bout ; avec, qu'elle attend un redémarrage."""
brut = strip_ssh_noise(text or "")
parts = brut.split("---ERPLIBRE-NAT---")
kernel = parts[0].strip().splitlines()
reste = parts[1] if len(parts) > 1 else ""
suite = reste.split("---ERPLIBRE-PVE-KERNEL---")
pve_kernel = suite[1].strip().splitlines() if len(suite) > 1 else []
return {
"kernel": kernel[-1].strip() if kernel else "",
"nat": "NAT-OK" in (suite[0] if suite else ""),
"pve_kernel": pve_kernel[-1].strip() if pve_kernel else "",
}
def parse_qm_list(text: str) -> list:
"""Sortie de « qm list » -> [{vmid, name, status, mem, disk}].

View file

@ -648,6 +648,56 @@ class ProxmoxMenuMixin:
parts = (sortie or "").split()
return parts[parts.index("dev") + 1] if "dev" in parts else ""
def _pve_nat_ready(self, host):
"""(prêt ?, lignes à dire). La table NAT existe-t-elle sur cet hôte ?
Posée ICI, au moment d'écrire un pont NAT, et non à la connexion : le
noyau est vérifié quand on confirme l'hôte, mais l'hôte est ensuite
MÉMORISÉ — on revient des jours plus tard créer un pont, et plus
personne ne rappelle rien. Le garde doit être là où la conséquence
tombe.
Sans cette question, ifupdown2 rendait six lignes d'iptables et « code
de retour 1 », après avoir déjà écrit la strophe dans
/etc/network/interfaces. Rien dans ce bruit ne dit qu'il faut
redémarrer.
Le cas n'a rien d'exotique : notre propre install_proxmox.sh pose le
noyau Proxmox sans redémarrer — lancé par ssh, un reboot couperait la
session. Une Proxmox imbriquée fraîchement installée est donc TOUJOURS
dans cet état, sur le noyau cloud de Debian, qui est dépouillé de tout
netfilter."""
from script.proxmox import proxmox_deploy as pve
_c, out = pve.run(host, pve.NAT_CHECK_CMD, 40)
etat = pve.parse_nat_check(out)
if etat["nat"]:
return True, []
lignes = [
f"✗ {t('No NAT table on this host: the bridge would lead nowhere.')}",
f" {t('Running kernel:')} {etat['kernel'] or '?'}",
]
if etat["pve_kernel"]:
lignes += [
f" {t('The distribution kernel carries no netfilter module.')}",
f" {t('Proxmox kernel installed:')} {etat['pve_kernel']}"
f" — {t('taken at next boot')}",
f"→ ssh {host.get('target', '')} sudo reboot,"
f" {t('then come back here.')}",
]
else:
lignes.append(
f" {t('No Proxmox kernel installed: finish the install first.')}"
)
return False, lignes
def _pve_nat_reason(self, host):
"""La même chose en UNE ligne, pour l'écran Textual."""
ok, lignes = self._pve_nat_ready(host)
if ok:
return ""
return " ".join(ligne.strip("✗→ ") for ligne in lignes[:2])
def _pve_make_internal_bridge(self):
"""Crée le pont INTERNE et le rend, ou ('', raison). SANS rien demander.
@ -662,6 +712,11 @@ class ProxmoxMenuMixin:
host = self._pve_host(ask=False)
if not host:
return "", t("No Proxmox host.")
# AVANT d'écrire quoi que ce soit : une strophe posée puis un
# « ifup » qui échoue laisse le fichier modifié et le pont absent.
raison = self._pve_nat_reason(host)
if raison:
return "", raison
uplink = self._pve_uplink()
for cmd in pve.bridge_setup_cmds(uplink=uplink):
code, sortie = pve.run(host, cmd, 180)
@ -704,6 +759,13 @@ class ProxmoxMenuMixin:
f" ⚠ {t('This moves the host address: do it from a console.')}"
)
return ""
host = self._pve_host(ask=False)
ok, lignes = self._pve_nat_ready(host) if host else (True, [])
if not ok:
print()
for ligne in lignes:
print(f" {ligne}")
return ""
uplink = self._pve_uplink()
print(f" {t('uplink for NAT')} : {uplink or t('none')}")
for cmd in pve.bridge_setup_cmds(uplink=uplink):
@ -1292,6 +1354,19 @@ class ProxmoxMenuMixin:
)
if vm.get("alias"):
print(f" ssh {vm['alias']}")
# Une VM qui vient de recevoir Proxmox tourne encore le noyau de
# son image cloud : celui-ci n'a AUCUN module netfilter, donc ni
# pont NAT ni VM à l'intérieur. install_proxmox.sh ne redémarre
# pas de lui-même — lancé par ssh, un reboot couperait la session
# et ferait passer l'installation pour un échec. Le dire ICI, où
# on lit encore l'écran, plutôt qu'au bout d'un journal d'une
# heure : sans cela on le redécouvre en créant un pont, devant six
# lignes d'iptables qui ne parlent pas de redémarrage.
if self._pve_installs_proxmox(vm, spec):
print(
f" ⚠ {t('reboot it to boot the Proxmox kernel:')}"
f" ssh {vm.get('alias') or vm['name']} sudo reboot"
)
if spec.get("install"):
print(
f" {t('Install:')} {spec['install'].get('label') or ''}"
@ -1299,6 +1374,16 @@ class ProxmoxMenuMixin:
)
print(f" {t('Log:')} {session}")
@staticmethod
def _pve_installs_proxmox(vm, spec) -> bool:
"""Cette VM reçoit-elle l'hyperviseur Proxmox VE ?
Jugé sur la commande EFFECTIVE de la VM — celle que son système lui
impose, sinon le choix commun — et non sur son nom ni sur sa
distribution : un parc mixte est le cas normal ici."""
cmd = vm.get("install_cmd") or (spec.get("install") or {}).get("cmd")
return "install_proxmox.sh" in (cmd or "")
def _pve_confirm_spec(self, host, spec):
"""Récapitulatif puis confirmation, dans le TERMINAL.

View file

@ -3362,6 +3362,38 @@ TRANSLATIONS = {
"fr": "Aucun stockage capable d'héberger un disque de VM.",
"en": "No storage able to hold a VM disk.",
},
"No NAT table on this host: the bridge would lead nowhere.": {
"fr": "Pas de table NAT sur cet hôte : le pont ne mènerait nulle part.",
"en": "No NAT table on this host: the bridge would lead nowhere.",
},
"reboot it to boot the Proxmox kernel:": {
"fr": "à redémarrer pour amorcer le noyau Proxmox :",
"en": "reboot it to boot the Proxmox kernel:",
},
"Running kernel:": {
"fr": "Noyau en cours :",
"en": "Running kernel:",
},
"The distribution kernel carries no netfilter module.": {
"fr": "Le noyau de la distribution ne porte aucun module netfilter.",
"en": "The distribution kernel carries no netfilter module.",
},
"Proxmox kernel installed:": {
"fr": "Noyau Proxmox installé :",
"en": "Proxmox kernel installed:",
},
"taken at next boot": {
"fr": "pris au prochain démarrage",
"en": "taken at next boot",
},
"then come back here.": {
"fr": "puis revenir ici.",
"en": "then come back here.",
},
"No Proxmox kernel installed: finish the install first.": {
"fr": "Aucun noyau Proxmox installé : terminer l'installation d'abord.",
"en": "No Proxmox kernel installed: finish the install first.",
},
"No network bridge on this host.": {
"fr": "Aucun pont réseau sur cet hôte.",
"en": "No network bridge on this host.",

View file

@ -619,5 +619,59 @@ class TestLeMenu(unittest.TestCase):
self.assertEqual(0, res.returncode, res.stderr)
class TestLaTableNat(unittest.TestCase):
"""« Table does not exist » : six lignes d'iptables et « code de retour 1 »,
après avoir déjà écrit la strophe dans /etc/network/interfaces.
Rien dans ce bruit ne dit qu'il faut redémarrer. Et le cas n'a rien
d'exotique : notre propre install_proxmox.sh pose le noyau Proxmox sans
redémarrer — lancé par ssh, un reboot couperait la session. Une Proxmox
imbriquée fraîchement installée est donc TOUJOURS sur le noyau cloud de
Debian, qui est dépouillé de tout netfilter.
On demande donc à la table NAT elle-même, et non au NOM du noyau : « -pve »
est un indice, pas une preuve."""
def _sortie(self, kernel, nat, pve_kernel=""):
return (
f"{kernel}\n---ERPLIBRE-NAT---\n"
f"{'NAT-OK' if nat else 'NAT-KO'}\n"
f"---ERPLIBRE-PVE-KERNEL---\n{pve_kernel}\n"
)
def test_a_working_host(self):
lu = pve.parse_nat_check(
self._sortie("7.0.14-14-pve", True, "7.0.14-14-pve")
)
self.assertTrue(lu["nat"])
self.assertEqual(lu["kernel"], "7.0.14-14-pve")
def test_the_cloud_kernel_waiting_for_a_reboot(self):
# L'état exact rapporté : le noyau Proxmox est POSÉ, pas amorcé.
lu = pve.parse_nat_check(
self._sortie("6.12.101+deb13-cloud-amd64", False, "7.0.14-14-pve")
)
self.assertFalse(lu["nat"])
self.assertEqual(lu["pve_kernel"], "7.0.14-14-pve")
def test_an_unfinished_install_has_no_pve_kernel(self):
lu = pve.parse_nat_check(
self._sortie("6.12.101+deb13-cloud-amd64", False)
)
self.assertFalse(lu["nat"])
self.assertEqual(lu["pve_kernel"], "")
def test_ssh_noise_does_not_become_a_kernel(self):
brut = (
"Warning: Permanently added 'x' (ED25519) to the list of known"
" hosts.\n" + self._sortie("7.0.14-14-pve", True, "7.0.14-14-pve")
)
self.assertEqual(pve.parse_nat_check(brut)["kernel"], "7.0.14-14-pve")
def test_the_probe_asks_the_table_not_the_name(self):
self.assertIn("iptables -t nat", pve.NAT_CHECK_CMD)
self.assertIn("uname -r", pve.NAT_CHECK_CMD)
if __name__ == "__main__":
unittest.main(verbosity=1)

View file

@ -16,6 +16,7 @@ aucun hôte Proxmox n'est joint.
import asyncio
import sys
import unittest
from unittest import mock
sys.argv = ["todo.py"]
from script.todo.proxmox_deploy_form import ( # noqa: E402
@ -685,6 +686,119 @@ class TestUnParcMixte(unittest.TestCase):
self.assertEqual(vu["kw"]["desktop"], "")
class TestLePontQuiNeMeneraitNullePart(unittest.TestCase):
"""Le pont NAT était écrit AVANT qu'on sache si le NAT existe.
Résultat rapporté : la strophe posée dans /etc/network/interfaces, le
pont absent, et six lignes d'iptables qui ne parlent pas de redémarrage.
L'avertissement sur le noyau existait — mais à la CONFIRMATION de l'hôte,
et l'hôte est ensuite mémorisé : on revient des jours plus tard créer un
pont, et plus personne ne rappelle rien."""
def _todo(self, sortie):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
vu = []
todo._pve_host = lambda ask=True: {"target": "pve9", "sudo": ""}
todo._pve_uplink = lambda: "eth0"
def faux_run(host, cmd, timeout=120):
vu.append(cmd)
from script.proxmox import proxmox_deploy as pve
if cmd == pve.NAT_CHECK_CMD:
return 0, sortie
return 0, ""
return todo, vu, faux_run
def _sortie(self, nat, pve_kernel="7.0.14-14-pve"):
return (
f"{'7.0.14-14-pve' if nat else '6.12.101+deb13-cloud-amd64'}\n"
f"---ERPLIBRE-NAT---\n{'NAT-OK' if nat else 'NAT-KO'}\n"
f"---ERPLIBRE-PVE-KERNEL---\n{pve_kernel}\n"
)
def test_nothing_is_written_when_there_is_no_nat(self):
todo, vu, faux = self._todo(self._sortie(nat=False))
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
nom, raison = todo._pve_make_internal_bridge()
self.assertEqual(nom, "")
self.assertTrue(raison)
# Une seule commande : la sonde. Rien n'a touché au fichier.
self.assertEqual(len(vu), 1, vu)
self.assertNotIn(
"interfaces", " ".join(vu), "la strophe ne doit pas être écrite"
)
def test_the_reason_names_the_kernel_to_boot(self):
todo, _vu, faux = self._todo(self._sortie(nat=False))
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
ok, lignes = todo._pve_nat_ready({"target": "pve9", "sudo": ""})
self.assertFalse(ok)
texte = " ".join(lignes)
self.assertIn("6.12.101+deb13-cloud-amd64", texte)
self.assertIn("7.0.14-14-pve", texte)
self.assertIn("reboot", texte)
def test_an_unfinished_install_says_so_instead(self):
todo, _vu, faux = self._todo(self._sortie(nat=False, pve_kernel=""))
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
_ok, lignes = todo._pve_nat_ready({"target": "pve9", "sudo": ""})
texte = " ".join(lignes)
self.assertNotIn("reboot", texte, "rien à redémarrer, rien de posé")
def test_a_working_host_goes_through(self):
todo, vu, faux = self._todo(self._sortie(nat=True))
with mock.patch("script.proxmox.proxmox_deploy.run", faux):
todo._pve_make_internal_bridge()
self.assertGreater(len(vu), 1, "la création doit suivre la sonde")
class TestUneProxmoxImbriqueeDoitRedemarrer(unittest.TestCase):
"""Le sommaire ne disait pas qu'une VM qui vient de recevoir Proxmox
tourne encore le noyau de son image cloud.
On le redécouvrait des jours plus tard, en créant un pont, devant six
lignes d'iptables."""
def _juge(self, vm, commun=""):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO._pve_installs_proxmox(
vm, {"install": {"cmd": commun}} if commun else {}
)
def test_a_vm_that_gets_the_hypervisor(self):
self.assertTrue(
self._juge({"install_cmd": "./script/proxmox/install_proxmox.sh"})
)
def test_through_the_common_choice_too(self):
self.assertTrue(self._juge({}, "./script/proxmox/install_proxmox.sh"))
def test_an_erplibre_vm_is_left_alone(self):
self.assertFalse(
self._juge({}, "make install_os && make install_odoo_18")
)
def test_a_vm_of_its_own_overrides_the_common_choice(self):
# Parc mixte : la commande de la VM l'emporte sur celle du parc.
self.assertFalse(
self._juge(
{"install_cmd": "make install_odoo_18"},
"./script/proxmox/install_proxmox.sh",
)
)
class TestLEcranDUneVmProxmox(unittest.TestCase):
"""« Console de l'hyperviseur » conseillait des commandes virsh sur une
machine qui n'a pas libvirt.