diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 6e0d3f4..586956c 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -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" diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 17255f9..c51e395 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -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.", diff --git a/script/todo/todo.py b/script/todo/todo.py index 8b958e0..5a823f9 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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})…" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 84b46b4..e71c5d9 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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)", diff --git a/test/test_qemu_deploy_monitor.py b/test/test_qemu_deploy_monitor.py new file mode 100644 index 0000000..fb17d2c --- /dev/null +++ b/test/test_qemu_deploy_monitor.py @@ -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)