From 7a91b010bd9b8e7fdfd29e9afa539ad61f5d73a9 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 02:49:02 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20suivi=20:=20le=20red=C3=A9marrage=20fai?= =?UTF-8?q?t=20partie=20de=20l'installation=20de=20Proxmox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox VE n'existe qu'après un redémarrage : tant que la VM tourne le noyau de son image cloud, elle n'a aucun module netfilter — ni pont NAT, ni invité. install_proxmox.sh pose le noyau puis s'arrête, à raison, car lancé par ssh un reboot couperait sa session et ferait passer l'installation pour un échec. On le découvrait donc des jours plus tard, en créant un pont. Le redémarrage revient à l'enveloppe de lancement, qui tourne sur NOTRE machine et survit à celui de la VM : installation, reboot, attente, puis vérification du noyau. Le ✅ ne s'écrit qu'après, et il veut donc dire « hyperviseur utilisable ». Trois choix méritent d'être dits. On ne redémarre qu'après un SUCCÈS — redémarrer après un échec effacerait la seule machine sur laquelle on pouvait chercher. On n'attend pas que ssh « revienne » mais que « uname -r » porte le motif attendu : sshd répond encore une seconde ou deux après l'ordre, et on lirait l'ancien noyau en croyant avoir la réponse. Et l'absence du noyau attendu est un vrai ÉCHEC, pas un avertissement. Le shell est exécuté par les tests, ssh bouchonné, dans les quatre cas — dont celui où les deux premières lectures rendent l'ancien noyau. Un garde qu'on ne sait pas éprouver s'ouvre le jour où il casse. La note du sommaire ne paraît plus que sans suivi, où rien ne redémarre : réclamer un redémarrage déjà fait est une consigne fausse. --- EN --- Proxmox VE only exists after a reboot: while the VM runs its cloud image's kernel it has no netfilter module — no NAT bridge, no guest. install_proxmox.sh installs the kernel then stops, rightly, since run over ssh a reboot would cut its own session and make the install look failed. So you found out days later, when creating a bridge. The reboot moves to the launch wrapper, which runs on OUR machine and survives the VM's: install, reboot, wait, then verify the kernel. The ✅ is written only after, and therefore means "usable hypervisor". Three choices worth stating. We reboot only after SUCCESS — rebooting after a failure would wipe the one machine you could investigate. We do not wait for ssh to "come back" but for "uname -r" to carry the expected pattern: sshd answers for another second or two after the order, and we would read the old kernel believing we had the answer. And a missing expected kernel is a real FAILURE, not a warning. The shell is executed by the tests, ssh stubbed, in all four cases — including the one where the first two reads return the old kernel. A guard you cannot exercise opens the day it breaks. The summary note now appears only without monitoring, where nothing reboots: asking for a reboot already done is a false instruction. Assisted-by: Claude Opus 5 --- script/todo/proxmox_menu.py | 17 +++-- script/todo/qemu_install_monitor.py | 98 ++++++++++++++++++++++++- script/todo/todo_i18n.py | 16 +++++ test/test_proxmox_form.py | 34 +++++++++ test/test_qemu_monitor_pve.py | 106 ++++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 10 deletions(-) diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 439e837..41f62a3 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1356,13 +1356,16 @@ class ProxmoxMenuMixin: 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): + # pont NAT ni VM à l'intérieur. + # + # Le suivi s'en charge : son enveloppe tourne sur NOTRE machine, + # donc elle survit au redémarrage de la VM, l'attend et vérifie le + # noyau avant de conclure. La note ne sert donc QUE sans suivi — + # la voie en série, elle, s'arrête à la fin du script. L'afficher + # dans les deux cas demanderait un redémarrage déjà fait. + if not spec.get("monitor", True) and 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" diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 8e7e9ae..57b52e4 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -33,6 +33,36 @@ except Exception: # pragma: no cover - repli si i18n indisponible EXIT_MARKER = "__ERPLIBRE_EXIT__" + +# Installations qui posent un NOYAU : elles ne valent rien avant un +# redémarrage, et le script ne peut pas survivre au sien. La table dit quoi +# attendre APRÈS — un motif à trouver dans « uname -r », donc une preuve et +# non une supposition. +# +# Proxmox VE en est le seul cas aujourd'hui, et il est systématique : notre +# install_proxmox.sh pose proxmox-default-kernel sans redémarrer — lancé par +# ssh, un reboot couperait la session et ferait passer l'installation pour un +# échec. La VM restait donc sur le noyau cloud de Debian, dépouillé de tout +# netfilter : ni pont NAT, ni invité. On le découvrait des jours plus tard. +REBOOT_AFTER = (("install_proxmox.sh", "-pve"),) + +# Attente maximale du retour de la machine, en tours de cinq secondes. +# Généreuse : un hyperviseur imbriqué redémarre lentement, et échouer trop +# tôt marquerait rouge une installation qui a réussi. +REBOOT_TOURS = 180 + + +def reboot_expected(remote_cmd) -> str: + """Motif à trouver dans « uname -r » après redémarrage, ou "". + + Jugé sur la COMMANDE effective de la VM, pas sur sa distribution : un parc + mixte est le cas normal, et c'est ce qu'on installe qui décide.""" + for marque, motif in REBOOT_AFTER: + if marque in (remote_cmd or ""): + return motif + return "" + + SSH_OPTS = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " "-o ConnectTimeout=8" @@ -79,6 +109,56 @@ def list_install_runs() -> list: return runs +def _reboot_steps(log_q: str, motif: str, tours: int = REBOOT_TOURS) -> str: + """Shell qui redémarre la VM, attend son retour, et vérifie son noyau. + + Trois choses valent d'être dites. + + Le redémarrage n'a lieu QUE si l'installation a réussi : redémarrer après + un échec effacerait la seule machine sur laquelle on pouvait chercher. + + On n'attend pas que ssh « revienne » — sshd répond encore une seconde ou + deux après l'ordre de redémarrage, et on lirait alors l'ANCIEN noyau en + croyant avoir la réponse. On attend que « uname -r » porte le motif ; tant + qu'il porte l'ancien, la machine n'est pas revenue. + + `tours` est un paramètre pour que ce shell soit ÉPROUVABLE : un garde + qu'on ne sait pas exécuter s'ouvre le jour où il casse. + + Et l'échec est un vrai échec : sans le noyau attendu, l'hyperviseur n'a ni + table NAT ni module bridge. Le dire ✅ serait le mensonge qui a coûté deux + jours à le comprendre.""" + msg_reboot = t("Rebooting to boot the new kernel") + msg_wait = t("waiting for the machine to come back") + msg_ok = t("kernel booted:") + msg_ko = t("the machine did not come back on the expected kernel:") + return ( + 'if [ "$rc" = 0 ]; then ' + f"echo {shlex.quote('== ' + msg_reboot + ' ==')} >> {log_q}; " + # « || true » : la session MEURT avec le redémarrage, et son code 255 + # ne dit rien de l'ordre lui-même. + f'ssh {SSH_OPTS_BATCH} "erplibre@$ip" ' + # « sudo -n » : cette enveloppe tourne DÉTACHÉE, sans terminal. Un + # sudo qui demande son mot de passe échoue alors tout de suite au lieu + # d'attendre une frappe que personne ne fera. + "'sudo -n systemctl reboot' " + f">> {log_q} 2>&1 || true; " + f"echo {shlex.quote(' ' + msg_wait)} >> {log_q}; " + "krn=''; " + f"for i in $(seq 1 {tours}); do sleep ${{ERPLIBRE_REBOOT_SLEEP:-5}}; " + f'k=$(ssh {SSH_OPTS_BATCH} -o BatchMode=yes "erplibre@$ip" ' + "'uname -r' 2>/dev/null); " + f'case "$k" in *{motif}*) krn="$k"; break;; esac; ' + "if [ $((i % 6)) -eq 0 ]; then " + f'echo " ... $((i*5))s" >> {log_q}; fi; ' + "done; " + 'if [ -n "$krn" ]; then ' + f'echo " {msg_ok} $krn" >> {log_q}; ' + f'else echo " ⚠ {msg_ko} {motif}" >> {log_q}; rc=1; fi; ' + "fi; " + ) + + def _launch_one( ip: str, remote_cmd: str, @@ -86,10 +166,17 @@ def _launch_one( name: str = "", installs: bool = True, pve: bool = False, + reboot: str = "", ) -> None: """Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise la sortie puis écrit le marqueur de fin avec le code de sortie. + `reboot` : motif attendu dans « uname -r » APRÈS un redémarrage. Non + vide, l'installation réussie est suivie d'un reboot, de l'attente du + retour, et d'une vérification du noyau — le succès n'est écrit qu'ensuite. + C'est ici et non dans la VM parce qu'un script ne survit pas à son propre + redémarrage : cette enveloppe, elle, tourne sur NOTRE machine. + `pve` : la VM vit sur un hôte Proxmox. On ne RÉ-RÉSOUT alors PAS son adresse par virsh — et c'est vital. Vécu le 24 août 2026 : une VM « erplibre-ubuntu-2604 » déployée sur Proxmox portait le nom d'un domaine @@ -225,8 +312,9 @@ def _launch_one( f"else echo {shlex.quote('== ' + msg_giveup + ' ==')} >> {log_q}; fi; " f'echo " → $ip" >> {log_q}; ' f'ssh {SSH_OPTS_BATCH} "erplibre@$ip" {shlex.quote(remote_cmd)} ' - f">> {log_q} 2>&1; " - f'echo "{EXIT_MARKER} $?" >> {log_q}' + f">> {log_q} 2>&1; rc=$?; " + + (_reboot_steps(log_q, reboot) if reboot else "") + + f'echo "{EXIT_MARKER} $rc" >> {log_q}' ) # setsid -f : le process survit à la fermeture du menu / du dashboard. # stdin sur /dev/null : SANS lui, le descripteur 0 du processus détaché @@ -293,13 +381,17 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: # ou bureau) se choisit machine par machine, le script distant n'est # plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse # intacts les appelants qui n'en fournissent qu'une. + cmd_vm = vm.get("remote_cmd") or remote_cmd _launch_one( vm["ip"], - vm.get("remote_cmd") or remote_cmd, + cmd_vm, log_path, vm["name"], installs=bool(branch), pve=bool(vm.get("pve")), + # Une installation qui pose un NOYAU ne vaut rien avant le + # redémarrage : l'enveloppe s'en charge et ne conclut qu'après. + reboot=reboot_expected(cmd_vm), ) entree = { "name": vm["name"], diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index dce07a6..2eeee6a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3370,6 +3370,22 @@ TRANSLATIONS = { "fr": "à redémarrer pour amorcer le noyau Proxmox :", "en": "reboot it to boot the Proxmox kernel:", }, + "Rebooting to boot the new kernel": { + "fr": "Redémarrage pour amorcer le nouveau noyau", + "en": "Rebooting to boot the new kernel", + }, + "waiting for the machine to come back": { + "fr": "attente du retour de la machine", + "en": "waiting for the machine to come back", + }, + "kernel booted:": { + "fr": "noyau amorcé :", + "en": "kernel booted:", + }, + "the machine did not come back on the expected kernel:": { + "fr": "la machine n'est pas revenue sur le noyau attendu :", + "en": "the machine did not come back on the expected kernel:", + }, "Running kernel:": { "fr": "Noyau en cours :", "en": "Running kernel:", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 781c411..bccd158 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -789,6 +789,40 @@ class TestUneProxmoxImbriqueeDoitRedemarrer(unittest.TestCase): self._juge({}, "make install_os && make install_odoo_18") ) + def test_the_note_only_shows_when_nothing_reboots_it(self): + """Avec suivi, l'enveloppe redémarre elle-même : réclamer un + redémarrage déjà fait est une consigne fausse. Sans suivi, la voie en + série s'arrête à la fin du script, et la note est la seule chose qui + dit que l'hyperviseur n'est pas encore utilisable.""" + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + def sommaire(monitor): + todo = TODO.__new__(TODO) + spec = { + "vms": [{"name": "pve-imbrique"}], + "install": { + "cmd": "./script/proxmox/install_proxmox.sh", + "label": "Proxmox VE", + "branch": "develop", + }, + "monitor": monitor, + "storage": "local", + "bridge": "vmbr0", + } + vm = dict(spec["vms"][0], alias="pve9+pve-imbrique", vmid=102) + tampon = io.StringIO() + with contextlib.redirect_stdout(tampon): + todo._pve_print_summary(spec, [vm], "") + return tampon.getvalue() + + self.assertIn("reboot", sommaire(monitor=False)) + self.assertNotIn("reboot", sommaire(monitor=True)) + 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( diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index 1010820..3bffe23 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -230,6 +230,112 @@ class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase): self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2) +class TestLeRedemarrageQuiFaitPartieDeLInstallation(unittest.TestCase): + """Proxmox VE n'existe qu'après un redémarrage, et le script ne peut pas + survivre au sien. + + install_proxmox.sh pose le noyau puis s'arrête, à raison — lancé par ssh, + un reboot couperait la session et ferait passer l'installation pour un + échec. La VM restait donc sur le noyau cloud de Debian, dépouillé de tout + netfilter, et on le découvrait des jours plus tard en créant un pont. + + L'enveloppe, elle, tourne sur NOTRE machine : elle survit au redémarrage + de la VM. Elle redémarre, attend, vérifie le noyau, et ne conclut + qu'ensuite — le ✅ veut donc dire « hyperviseur utilisable ».""" + + def _joue(self, rc, faux_ssh, tours=4): + """Exécute le shell RÉEL, avec ssh bouchonné par une fonction.""" + import os + import subprocess + import tempfile + + log = tempfile.NamedTemporaryFile(suffix=".log", delete=False) + log.close() + cpt = tempfile.NamedTemporaryFile("w+", delete=False) + cpt.write("0") + cpt.close() + shell = ( + f"CPT={cpt.name}\n{faux_ssh}\n" + f"ip=10.0.0.1; rc={rc}; " + + mon._reboot_steps(log.name, "-pve", tours=tours) + + f'echo "{mon.EXIT_MARKER} $rc" >> {log.name}' + ) + subprocess.run( + ["bash", "-c", shell], + env=dict(os.environ, ERPLIBRE_REBOOT_SLEEP="0"), + timeout=60, + ) + with open(log.name, encoding="utf-8") as fh: + texte = fh.read() + etat = mon.read_status(log.name) + os.unlink(log.name) + os.unlink(cpt.name) + return etat, texte + + # Le compteur vit dans un FICHIER : « k=$(ssh …) » tourne en sous-shell. + REVIENT = """ssh() { + case "$*" in + *"uname -r"*) + n=$(cat "$CPT"); n=$((n+1)); echo "$n" > "$CPT" + if [ "$n" -ge 3 ]; then echo "7.0.14-14-pve"; + else echo "6.12.101+deb13-cloud-amd64"; fi;; + *reboot*) return 255;; + esac +}""" + RESTE = """ssh() { + case "$*" in + *"uname -r"*) echo "6.12.101+deb13-cloud-amd64";; + *reboot*) return 255;; + esac +}""" + + def test_only_the_pve_kernel_ends_the_wait(self): + # Le piège : sshd répond encore une seconde ou deux après l'ordre de + # redémarrage. Lire « uname -r » et s'arrêter là donnerait l'ANCIEN + # noyau en croyant avoir la réponse. Ici les deux premières lectures + # rendent le noyau cloud et doivent être REJETÉES. + (etat, code), texte = self._joue(0, self.REVIENT) + self.assertEqual((etat, code), ("done", 0)) + self.assertIn("7.0.14-14-pve", texte) + + def test_a_machine_that_stays_on_the_old_kernel_fails(self): + # Sans le noyau attendu, l'hyperviseur n'a ni table NAT ni module + # bridge. Le dire ✅ serait le mensonge qui a coûté deux jours. + (etat, code), texte = self._joue(0, self.RESTE) + self.assertEqual(etat, "failed") + self.assertEqual(code, 1) + self.assertIn("-pve", texte) + + def test_a_machine_that_never_answers_fails(self): + (etat, _c), _texte = self._joue(0, "ssh() { return 255; }") + self.assertEqual(etat, "failed") + + def test_a_failed_install_is_never_rebooted(self): + # Redémarrer après un échec effacerait la seule machine sur laquelle + # on pouvait chercher. + (etat, code), texte = self._joue( + 2, 'ssh() { echo "NE DEVRAIT PAS ETRE APPELE"; }' + ) + self.assertEqual((etat, code), ("failed", 2)) + self.assertNotIn("NE DEVRAIT PAS", texte) + self.assertNotIn("Redémarrage", texte) + + def test_which_installs_ask_for_it(self): + self.assertEqual( + mon.reboot_expected("./script/proxmox/install_proxmox.sh"), "-pve" + ) + self.assertEqual( + mon.reboot_expected("make install_os && make install_odoo_18"), "" + ) + self.assertEqual(mon.reboot_expected(""), "") + + def test_the_wrapper_only_reboots_when_asked(self): + import inspect + + src = inspect.getsource(mon._launch_one) + self.assertIn("_reboot_steps(log_q, reboot) if reboot else", src) + + class TestTroisVmSurUnProxmox(unittest.TestCase): """Rapporté à l'usage : sur trois VM d'un même Proxmox, une seule avait ses colonnes vides — et les deux autres montraient les chiffres d'une