[FIX] déploiement : suivre une VM même sans installation ERPLibre

Décocher l'installation d'ERPLibre faisait disparaître le tableau de bord. La
case « suivi » vivait DANS le groupe de l'installation, build_spec ne la
recopiait même pas dans la spec, et l'épilogue était gardé par « if install or
desktop » : sans rien à installer, il ne se passait rien.

Le suivi devient un choix du DÉPLOIEMENT. Et sans rien à installer, la
commande distante ne vaut plus « true » — journal vide, ✅ instantané : elle
regarde la VM ARRIVER, attend cloud-init, puis relève système, noyau, adresse,
disque et mémoire. Le journal cesse aussi d'annoncer une installation ERPLibre
qui n'a pas lieu.

--- EN ---

Unchecking the ERPLibre install made the dashboard vanish. The "monitoring"
checkbox lived INSIDE the install group, build_spec did not even copy it into
the spec, and the deploy epilogue was gated by "if install or desktop": with
nothing to install, nothing happened.

Monitoring is now a DEPLOYMENT-level choice. And with nothing to install, the
remote command is no longer "true" — empty log, instant ✅: it watches the VM
ARRIVE, waits for cloud-init, then reports system, kernel, address, disk and
memory. The log also stops announcing an ERPLibre install that never happens.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-23 03:50:57 -04:00
parent 7923e37e4f
commit 3c10ca2eb2
5 changed files with 377 additions and 10 deletions

View file

@ -298,6 +298,10 @@ def build_spec(vms, domains, form):
"python_provider": form.get("python_provider", ""), "python_provider": form.get("python_provider", ""),
"app_store": form.get("app_store", "deb"), "app_store": form.get("app_store", "deb"),
"install": form["install"], "install": form["install"],
# Au NIVEAU DU DÉPLOIEMENT, pas de l'installation : une VM sans
# ERPLibre se suit aussi (cloud-init, puis relevé système). Absent de
# cette assemblée, le choix du formulaire n'atteignait jamais la spec.
"monitor": form.get("monitor", True),
"add_ssh_config": form["add_ssh_config"], "add_ssh_config": form["add_ssh_config"],
"parallelism": form["parallelism"], "parallelism": form["parallelism"],
} }
@ -1828,6 +1832,10 @@ def run_deploy_form(ctx, run_app: bool = True):
} }
key = self.query_one("#f_key", Input).value.strip() key = self.query_one("#f_key", Input).value.strip()
return { return {
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT, pas de
# l'installation : décocher ERPLibre emportait la case avec
# elle, et le tableau de bord ne s'ouvrait plus du tout.
"monitor": self.query_one("#f_monitor", Checkbox).value,
"res_label": ( "res_label": (
t("custom") t("custom")
if self.profile == "custom" if self.profile == "custom"

View file

@ -80,7 +80,11 @@ def list_install_runs() -> list:
def _launch_one( def _launch_one(
ip: str, remote_cmd: str, log_path: str, name: str = "" ip: str,
remote_cmd: str,
log_path: str,
name: str = "",
installs: bool = True,
) -> None: ) -> None:
"""Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise """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.""" la sortie puis écrit le marqueur de fin avec le code de sortie."""
@ -102,7 +106,13 @@ def _launch_one(
# log reste VIDE pendant tout le boot émulé et paraît « bloqué ». # log reste VIDE pendant tout le boot émulé et paraît « bloqué ».
msg_wait = t("Waiting for the VM to start (boot + cloud-init)") msg_wait = t("Waiting for the VM to start (boot + cloud-init)")
msg_slow = t("(an emulated architecture can be slow; this is normal)") msg_slow = t("(an emulated architecture can be slow; this is normal)")
msg_ready = t("VM ready - starting the ERPLibre install") # « installation ERPLibre en cours » sur un déploiement qui n'installe
# RIEN était un mensonge du journal : la ligne dit maintenant ce qui suit.
msg_ready = (
t("VM ready - starting the ERPLibre install")
if installs
else t("VM ready - taking its measurements")
)
msg_giveup = t( msg_giveup = t(
"cloud-init still running after 20 min - install starts anyway" "cloud-init still running after 20 min - install starts anyway"
" (it waits for cloud-init first)" " (it waits for cloud-init first)"
@ -230,19 +240,26 @@ def _launch_one(
def _log_header(vm: dict, branch: str, when: str) -> str: def _log_header(vm: dict, branch: str, when: str) -> str:
"""En-tête du log : date, VM, distribution, version, architecture, branche. """En-tête du log : date, VM, distribution, version, architecture, branche.
Permet d'identifier l'installation d'un coup d'œil (et de ne jamais laisser Permet d'identifier l'installation d'un coup d'œil (et de ne jamais laisser
le log vide pendant l'attente du boot).""" le log vide pendant l'attente du boot).
Sans branche, il n'y a rien à installer : le titre le dit et la ligne
« Branche » disparaît, au lieu d'annoncer une installation ERPLibre qui
n'aura pas lieu.
"""
distro = vm.get("distro") or "?" distro = vm.get("distro") or "?"
version = vm.get("version") or "" version = vm.get("version") or ""
arch = vm.get("arch") or "?" arch = vm.get("arch") or "?"
bar = "=" * 64 bar = "=" * 64
titre = t("installation") if branch else t("VM start-up")
ligne_branche = f" Branche : {branch}\n" if branch else ""
return ( return (
f"{bar}\n" f"{bar}\n"
f" ERPLibre — {t('installation')}\n" f" ERPLibre — {titre}\n"
f" Date : {when}\n" f" Date : {when}\n"
f" VM : {vm['name']}\n" f" VM : {vm['name']}\n"
f" Distribution : {distro} {version}\n" f" Distribution : {distro} {version}\n"
f" Architecture : {arch}\n" f" Architecture : {arch}\n"
f" Branche : {branch}\n" f"{ligne_branche}"
f" IP : {vm['ip']}\n" f" IP : {vm['ip']}\n"
f"{bar}\n\n" f"{bar}\n\n"
) )
@ -268,7 +285,11 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str:
# plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse # plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse
# intacts les appelants qui n'en fournissent qu'une. # intacts les appelants qui n'en fournissent qu'une.
_launch_one( _launch_one(
vm["ip"], vm.get("remote_cmd") or remote_cmd, log_path, vm["name"] vm["ip"],
vm.get("remote_cmd") or remote_cmd,
log_path,
vm["name"],
installs=bool(branch),
) )
entries.append( entries.append(
{ {
@ -411,6 +432,10 @@ def active_run():
# avertissements connus et bénins) : on réutilise la MÊME logique de détection # avertissements connus et bénins) : on réutilise la MÊME logique de détection
# que la suite de tests ERPLibre pour analyser les logs d'installation. # que la suite de tests ERPLibre pour analyser les logs d'installation.
_LST_IGNORE_WARNING = ( _LST_IGNORE_WARNING = (
# ssh annonce l'ajout d'une clé d'hôte à chaque PREMIÈRE connexion à une
# VM neuve. Ce n'est pas un avertissement d'installation : compté, il
# allumait la colonne ⚠ sur TOUTE installation, dès sa première ligne.
"Warning: Permanently added",
"have the same label:", "have the same label:",
"odoo.addons.code_generator.extractor_module_file: Ignore next error about" "odoo.addons.code_generator.extractor_module_file: Ignore next error about"
" ALTER TABLE DROP CONSTRAINT.", " ALTER TABLE DROP CONSTRAINT.",

View file

@ -5654,6 +5654,31 @@ class TODO:
"fi; " "fi; "
) )
@staticmethod
def _qemu_vm_ready_report():
"""Relevé de mise en route, pour une VM où l'on n'installe RIEN.
Sans lui, la commande distante valait « true » : le suivi affichait un
✅ instantané sur un journal vide, ce qui n'apprend rien de la machine
qu'on vient de créer. Ici, il y a une fin claire (le marqueur de sortie
que pose le lanceur) et de quoi juger qu'elle est prête : système,
noyau, adresse, disque, mémoire, et le verdict de cloud-init.
"""
return (
f'echo "===> {t("VM start-up")}"; '
". /etc/os-release 2>/dev/null || true; "
f'echo " {t("system:")} ${{PRETTY_NAME:-?}}"; '
f'echo " {t("kernel:")} $(uname -r) ($(uname -m))"; '
f'echo " {t("address:")} '
"$(hostname -I 2>/dev/null | awk '{print $1}')\"; "
f'echo " {t("disk:")} '
"$(df -h / | awk 'NR==2 {print $3\"/\"$2\" (\"$5\")\"}')\"; "
f'echo " {t("memory:")} '
"$(free -h 2>/dev/null | awk 'NR==2 {print $3\"/\"$2}')\"; "
f'echo " {t("uptime:")} $(uptime -p 2>/dev/null || true)"; '
f'echo "<=== {t("VM start-up")}"; '
)
@staticmethod @staticmethod
def _qemu_no_auto_upgrade(prod, app_store="deb"): def _qemu_no_auto_upgrade(prod, app_store="deb"):
"""Coupe les mises à jour automatiques sur une VM de DÉVELOPPEMENT. """Coupe les mises à jour automatiques sur une VM de DÉVELOPPEMENT.
@ -7236,7 +7261,15 @@ class TODO:
# attente de cloud-init et coupure des mises à jour automatiques, # attente de cloud-init et coupure des mises à jour automatiques,
# sans quoi le verrou apt ferait échouer l'installation du bureau. # sans quoi le verrou apt ferait échouer l'installation du bureau.
if not desktop: if not desktop:
return "true" # Rien à installer : le suivi n'a alors qu'à regarder la VM
# ARRIVER. Un « true » rendait un journal vide et un ✅
# instantané — et c'est pourquoi le suivi « ne marchait plus »
# dès qu'on décochait ERPLibre.
return (
"set -e; "
+ self._qemu_cloud_init_wait()
+ self._qemu_vm_ready_report()
)
# Les outils de la phase « after » vivent DANS le dépôt — la # Les outils de la phase « after » vivent DANS le dépôt — la
# compilation mobile, l'AVD, le script Forgejo. Sans clone, ils # compilation mobile, l'AVD, le script Forgejo. Sans clone, ils
# n'existent pas ici. Les écarter en silence laissait croire qu'une # n'existent pas ici. Les écarter en silence laissait croire qu'une
@ -8970,6 +9003,15 @@ class TODO:
"cmd": cmd, "cmd": cmd,
"monitor": monitor, "monitor": monitor,
} }
else:
# Rien à installer : le suivi garde tout son sens — il regarde les
# VM arriver (cloud-init, puis relevé système) et porte le tableau
# d'état, de débit d'écriture, de RAM et de disque. La question
# était posée DANS la branche ERPLibre : refuser l'une emportait
# l'autre sans qu'on l'ait demandé.
monitor = self._is_yes_default_yes(
input(f"{t('Watch the VMs start (no install)')} ? (O/n) : ")
)
add_ssh_config = self._is_yes_default_yes( add_ssh_config = self._is_yes_default_yes(
input(t("Add each VM to ~/.ssh/config? (Y/n): ")) input(t("Add each VM to ~/.ssh/config? (Y/n): "))
@ -9030,6 +9072,9 @@ class TODO:
"python_provider": python_provider, "python_provider": python_provider,
"app_store": app_store, "app_store": app_store,
"install": install, "install": install,
# Au niveau du déploiement : le suivi survit à une installation
# décochée (voir _qemu_run_spec).
"monitor": monitor,
"add_ssh_config": add_ssh_config, "add_ssh_config": add_ssh_config,
"parallelism": parallelism, "parallelism": parallelism,
} }
@ -9191,8 +9236,16 @@ class TODO:
# 7) Installation ERPLibre (clone + make) et/ou bureau GNOME. Le bureau # 7) Installation ERPLibre (clone + make) et/ou bureau GNOME. Le bureau
# ne dépend PAS d'ERPLibre : une VM peut être voulue graphique et nue. # ne dépend PAS d'ERPLibre : une VM peut être voulue graphique et nue.
# Il passe par la même commande distante, donc par le même suivi. # Il passe par la même commande distante, donc par le même suivi.
if install or desktop: #
monitor = install["monitor"] if install else True # Et quand il n'y a RIEN à installer, le suivi s'ouvre quand même : la
# commande distante regarde alors la VM arriver (cloud-init puis relevé
# système). Sans cela, décocher ERPLibre faisait disparaître le tableau
# de bord — rapporté, et c'est ce qui donnait « le suivi ne fonctionne
# plus ». Le choix vient du déploiement, pas de l'installation.
monitor = (
install["monitor"] if install else spec.get("monitor", True)
)
if install or desktop or monitor:
if monitor: if monitor:
# Installs détachées en parallèle + dashboard Textual. # Installs détachées en parallèle + dashboard Textual.
self._qemu_install_erplibre_monitored( self._qemu_install_erplibre_monitored(
@ -9206,7 +9259,7 @@ class TODO:
app_store=app_store, app_store=app_store,
vm_tools=vm_tools, vm_tools=vm_tools,
) )
else: elif install:
print( print(
f"\n{t('Installing ERPLibre on each VM')} " f"\n{t('Installing ERPLibre on each VM')} "
f"({install_branch})…" f"({install_branch})…"

View file

@ -3115,6 +3115,38 @@ TRANSLATIONS = {
"fr": "Nœud de rendu", "fr": "Nœud de rendu",
"en": "Render node", "en": "Render node",
}, },
"VM ready - taking its measurements": {
"fr": "VM prête — relevé en cours",
"en": "VM ready - taking its measurements",
},
"VM start-up": {
"fr": "Mise en route de la VM",
"en": "VM start-up",
},
"system:": {
"fr": "système :",
"en": "system:",
},
"kernel:": {
"fr": "noyau :",
"en": "kernel: ",
},
"address:": {
"fr": "adresse :",
"en": "address:",
},
"disk:": {
"fr": "disque :",
"en": "disk: ",
},
"uptime:": {
"fr": "actif :",
"en": "uptime: ",
},
"Watch the VMs start (no install)": {
"fr": "Suivre le démarrage des VM (sans installation)",
"en": "Watch the VMs start (no install)",
},
"Proxmox VE hypervisor (no Odoo)": { "Proxmox VE hypervisor (no Odoo)": {
"fr": "Hyperviseur Proxmox VE (sans Odoo)", "fr": "Hyperviseur Proxmox VE (sans Odoo)",
"en": "Proxmox VE hypervisor (no Odoo)", "en": "Proxmox VE hypervisor (no Odoo)",

View file

@ -0,0 +1,249 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Suivre un déploiement qui n'installe PAS ERPLibre.
Symptôme rapporté : en décochant l'installation d'ERPLibre, le tableau de bord
ne s'ouvrait plus du tout. Deux causes, l'une derrière l'autre :
- la case « suivi » vivait DANS le groupe de l'installation ERPLibre, et
`build_spec` ne la recopiait même pas dans la spec finale ;
- l'épilogue du déploiement était gardé par « if install or desktop » : sans
rien à installer, il ne se passait rien.
Et si le suivi s'ouvrait quand même, il n'aurait rien montré : la commande
distante valait « true », donc un journal vide et un ✅ instantané. Elle
regarde maintenant la VM ARRIVER — cloud-init, puis un relevé système — ce qui
donne un début, une fin, et de quoi juger qu'elle est prête.
"""
import contextlib
import io
import os
import subprocess
import sys
import unittest
sys.argv = ["todo.py"]
from script.todo import qemu_install_monitor as mon # noqa: E402
from script.todo.qemu_deploy_form import build_spec # noqa: E402
from script.todo.todo import TODO # noqa: E402
class TestLaCommandeDistante(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
def _sans_rien(self):
return self.todo._qemu_erplibre_remote_cmd(None)
def test_it_is_no_longer_a_bare_true(self):
"""« true » rendait un journal vide et un ✅ instantané : le suivi
s'ouvrait sur rien."""
self.assertNotEqual("true", self._sans_rien().strip())
def test_it_is_valid_shell(self):
res = subprocess.run(
["bash", "-n"],
input=self._sans_rien(),
capture_output=True,
text=True,
)
self.assertEqual(0, res.returncode, res.stderr)
def test_it_waits_for_cloud_init(self):
"""C'est le vrai « suivi » d'une VM neuve : tant que cloud-init tourne,
la machine n'est pas prête."""
self.assertIn("cloud-init status --wait", self._sans_rien())
def test_it_frames_the_step_so_the_monitor_can_bound_it(self):
"""Le tableau lit ces marqueurs pour dire où en est la VM."""
cmd = self._sans_rien()
self.assertIn("===>", cmd)
self.assertIn("<===", cmd)
def test_it_reports_what_says_the_vm_is_up(self):
cmd = self._sans_rien()
for morceau in ("/etc/os-release", "uname -r", "hostname -I", "df -h"):
self.assertIn(morceau, cmd, morceau)
def test_it_runs_and_says_something(self):
"""Exécutée ici, hors VM : l'attente de cloud-init est neutralisée, le
relevé doit sortir et rendre 0."""
cmd = self._sans_rien().replace(
"sudo timeout 900 cloud-init status --wait", "true"
)
res = subprocess.run(
["bash", "-c", cmd], capture_output=True, text=True, timeout=60
)
self.assertEqual(0, res.returncode, res.stderr[-300:])
self.assertIn("===>", res.stdout)
self.assertIn("<===", res.stdout)
# Trois lignes de relevé au moins : sans elles, le journal est creux.
self.assertGreaterEqual(len(res.stdout.strip().splitlines()), 5)
def test_a_desktop_only_vm_still_installs_its_desktop(self):
"""Le chemin qui marchait déjà ne doit pas changer de sens."""
self.todo._qemu_desktop_remote_cmd = lambda d, s: "INSTALLE_BUREAU; "
self.todo._qemu_tools_remote_cmd = lambda *a, **k: ""
self.todo._qemu_no_auto_upgrade = lambda *a, **k: ""
cmd = self.todo._qemu_erplibre_remote_cmd(None, desktop="gnome")
self.assertIn("INSTALLE_BUREAU", cmd)
class TestLaSpecDuFormulaire(unittest.TestCase):
def _form(self, **extra):
base = {
"res_label": "x1",
"ssh_key": "/k.pub",
"install": None,
"add_ssh_config": False,
"parallelism": 1,
}
base.update(extra)
return base
def test_the_choice_reaches_the_spec(self):
"""Il ne la recopiait pas : le choix du formulaire n'atteignait jamais
le déploiement."""
spec = build_spec([], [], self._form(monitor=True))
self.assertTrue(spec["monitor"])
spec = build_spec([], [], self._form(monitor=False))
self.assertFalse(spec["monitor"])
def test_an_old_form_without_the_key_still_monitors(self):
"""Compatibilité : une spec enregistrée avant ce changement ne doit pas
perdre son tableau de bord."""
self.assertTrue(build_spec([], [], self._form())["monitor"])
class TestLaDecisionDuDeploiement(unittest.TestCase):
"""L'épilogue : qui est appelé, et avec quoi."""
def _joue(self, spec):
todo = TODO.__new__(TODO)
appels = []
todo._qemu_install_erplibre_monitored = lambda *a, **k: appels.append(
"suivi"
)
todo._qemu_install_erplibre_vm = lambda *a, **k: appels.append("muet")
todo._qemu_resolve_ips = lambda names, labels=None: {}
base = {
"vms": [],
"existing": ["vm-a"],
"install": None,
"add_ssh_config": False,
"parallelism": 1,
}
base.update(spec)
with contextlib.redirect_stdout(io.StringIO()):
todo._qemu_run_spec(base)
return appels
def test_without_erplibre_the_monitor_still_opens(self):
"""Le cœur du problème rapporté."""
self.assertEqual(["suivi"], self._joue({"monitor": True}))
def test_a_spec_without_the_key_monitors_too(self):
self.assertEqual(["suivi"], self._joue({}))
def test_refusing_the_monitor_does_nothing_at_all(self):
"""Et surtout : ne pas partir installer un profil qui n'existe pas.
L'ancien repli faisait « install['cmd'] » sur un None."""
self.assertEqual([], self._joue({"monitor": False}))
def test_an_install_without_the_monitor_takes_the_quiet_path(self):
appels = self._joue(
{
"monitor": False,
"install": {
"branch": "develop",
"prod": False,
"cmd": "make x",
"monitor": False,
},
}
)
self.assertEqual(["muet"], appels)
def test_the_install_keeps_the_last_word_on_its_own_monitoring(self):
appels = self._joue(
{
"monitor": False,
"install": {
"branch": "develop",
"prod": False,
"cmd": "make x",
"monitor": True,
},
}
)
self.assertEqual(["suivi"], appels)
class TestLeJournal(unittest.TestCase):
"""L'en-tête et le prologue ne doivent pas annoncer ce qui n'a pas lieu."""
def _vm(self):
return {
"name": "vm-a",
"ip": "10.0.0.9",
"distro": "debian",
"version": "13",
"arch": "amd64",
}
def test_an_install_is_titled_an_install(self):
head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00")
self.assertIn(mon.t("installation"), head)
self.assertIn("develop", head)
def test_without_a_branch_it_is_not_called_an_install(self):
"""« ERPLibre — installation » puis « Branche : » vide, sur un
déploiement qui n'installe rien : le journal se contredisait."""
head = mon._log_header(self._vm(), "", "2026-01-01 00:00:00")
self.assertIn(mon.t("VM start-up"), head)
self.assertNotIn("Branche", head)
def test_the_prologue_says_what_actually_follows(self):
"""« installation ERPLibre en cours » alors que rien ne s'installe."""
import tempfile
from pathlib import Path
vus = []
vrai_launch, vrai_dir = mon._launch_one, mon.session_dir
mon._launch_one = (
lambda ip, cmd, log, name="", installs=True: vus.append(installs)
)
# session_dir détournée : sans cela le test écrivait de VRAIES sessions
# dans ~/.erplibre/qemu-install, qui polluaient l'historique que
# « Rouvrir le suivi » propose à l'utilisateur.
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
mon.session_dir = lambda: Path(tmp.name)
try:
mon.launch_installs([self._vm()], "", "true")
mon.launch_installs([self._vm()], "develop", "true")
finally:
mon._launch_one, mon.session_dir = vrai_launch, vrai_dir
self.assertEqual([False, True], vus)
def test_the_host_key_notice_is_not_a_warning(self):
"""ssh l'écrit à CHAQUE première connexion : comptée, la colonne ⚠
s'allumait sur toute installation, dès sa première ligne."""
import tempfile
with tempfile.NamedTemporaryFile(
"w", suffix=".log", delete=False, encoding="utf-8"
) as fh:
fh.write(
"Warning: Permanently added '10.0.0.9' (ED25519) to the"
" list of known hosts.\n__ERPLIBRE_EXIT__ 0\n"
)
chemin = fh.name
self.addCleanup(os.unlink, chemin)
self.assertEqual((0, 0), mon.scan_log_errors(chemin))
if __name__ == "__main__":
unittest.main(verbosity=1)