diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index d16222c..12d1be2 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -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}]. diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 150955f..439e837 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -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. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5e7bcb6..dce07a6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index f93ec5d..1b37447 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -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) diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 9413265..781c411 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -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.