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