[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", ""),
"app_store": form.get("app_store", "deb"),
"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"],
"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()
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": (
t("custom")
if self.profile == "custom"

View file

@ -80,7 +80,11 @@ def list_install_runs() -> list:
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:
"""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."""
@ -102,7 +106,13 @@ def _launch_one(
# 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_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(
"cloud-init still running after 20 min - install starts anyway"
" (it waits for cloud-init first)"
@ -230,19 +240,26 @@ def _launch_one(
def _log_header(vm: dict, branch: str, when: str) -> str:
"""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
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 "?"
version = vm.get("version") or ""
arch = vm.get("arch") or "?"
bar = "=" * 64
titre = t("installation") if branch else t("VM start-up")
ligne_branche = f" Branche : {branch}\n" if branch else ""
return (
f"{bar}\n"
f" ERPLibre — {t('installation')}\n"
f" ERPLibre — {titre}\n"
f" Date : {when}\n"
f" VM : {vm['name']}\n"
f" Distribution : {distro} {version}\n"
f" Architecture : {arch}\n"
f" Branche : {branch}\n"
f"{ligne_branche}"
f" IP : {vm['ip']}\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
# intacts les appelants qui n'en fournissent qu'une.
_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(
{
@ -411,6 +432,10 @@ def active_run():
# 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.
_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:",
"odoo.addons.code_generator.extractor_module_file: Ignore next error about"
" ALTER TABLE DROP CONSTRAINT.",

View file

@ -5654,6 +5654,31 @@ class TODO:
"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
def _qemu_no_auto_upgrade(prod, app_store="deb"):
"""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,
# sans quoi le verrou apt ferait échouer l'installation du bureau.
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
# compilation mobile, l'AVD, le script Forgejo. Sans clone, ils
# n'existent pas ici. Les écarter en silence laissait croire qu'une
@ -8970,6 +9003,15 @@ class TODO:
"cmd": cmd,
"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(
input(t("Add each VM to ~/.ssh/config? (Y/n): "))
@ -9030,6 +9072,9 @@ class TODO:
"python_provider": python_provider,
"app_store": app_store,
"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,
"parallelism": parallelism,
}
@ -9191,8 +9236,16 @@ class TODO:
# 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.
# 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:
# Installs détachées en parallèle + dashboard Textual.
self._qemu_install_erplibre_monitored(
@ -9206,7 +9259,7 @@ class TODO:
app_store=app_store,
vm_tools=vm_tools,
)
else:
elif install:
print(
f"\n{t('Installing ERPLibre on each VM')} "
f"({install_branch})…"

View file

@ -3115,6 +3115,38 @@ TRANSLATIONS = {
"fr": "Nœud de rendu",
"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)": {
"fr": "Hyperviseur Proxmox VE (sans 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)