[ADD] suivi : le redémarrage fait partie de l'installation de Proxmox

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
This commit is contained in:
Mathieu Benoit 2026-08-25 02:49:02 -04:00
parent eb5e607e1e
commit 7a91b010bd
5 changed files with 261 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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

View file

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