From d3e8d509532da68bfcd68dec605aa60ffc28426f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 08:32:41 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20qemu=20deploy=20:=20montrer=20le=20mess?= =?UTF-8?q?age=20de=20l'outil=20quand=20une=20VM=20=C3=A9choue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une VM ratée n'affichait que ses quatre dernières lignes, et l'épilogue « Échec de la commande » avec sa ligne de commande les occupe entièrement : le message de l'outil tombait juste au-dessus de la fenêtre. La sortie étant jetée après la boucle, passé l'écran elle n'existait plus nulle part. Trente lignes en cas d'échec, quatre en cas de réussite, et la sortie complète va dans un fichier dont le chemin s'affiche. Quand la commande portait la 3D, le rapport nomme « --gpu off » : le menu ne l'expose pas, c'est la seule issue depuis là. Vérifié : 7 tests, dont un nom de VM hostile qui ne doit pas composer un chemin hors du répertoire de session. --- EN --- A failed VM showed only its last four lines, and the « Échec de la commande » epilogue with its command line fills them entirely: the tool's own message fell just above the window. The output being discarded after the loop, past the screen it existed nowhere at all. Thirty lines on failure, four on success, and the whole output goes to a file whose path is printed. When the command carried 3D, the report names « --gpu off »: the menu does not expose it, and it is the only way out from there. Checked: 7 tests, among them a hostile VM name that must not compose a path outside the session directory. Assisted-by: Claude Opus 5 --- script/todo/qemu_deploy.py | 40 +++++++- script/todo/todo_i18n.py | 8 ++ test/test_qemu_deploy_failure_output.py | 126 ++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 test/test_qemu_deploy_failure_output.py diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 00ef384..6a86938 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -1905,11 +1905,49 @@ class QemuDeployMixin: f"\n[{done}/{len(jobs)}] {mark} [{jid}] {jname} " f"(rc={rc}, {self._fmt_dur(secs)})" ) - for line in [ln for ln in out.strip().splitlines() if ln][-4:]: + lignes = [ln for ln in out.strip().splitlines() if ln] + # Une VM qui réussit n'a rien à raconter ; une qui échoue a + # UNE ligne qui compte, et elle est écrite par l'outil, pas + # par nous. Quatre lignes ne suffisent pas à l'atteindre : + # l'épilogue « Échec de la commande » et sa ligne de commande + # les occupent, et le message de virt-install tombe juste + # au-dessus de la fenêtre. + for line in lignes[-4:] if rc == 0 else lignes[-30:]: print(f" {line}") + if rc != 0: + chemin = self._qemu_save_failure_log(jname, out) + if chemin: + print(f" {t('Full output:')} {chemin}") + # La 3D est décidée DANS deploy_qemu.py, pas dans l'argv : + # sa présence se lit sur la commande que le journal a + # rapportée. Le menu n'expose pas « --gpu off », donc la + # seule issue depuis ici est de la nommer. + if "accel3d=on" in out or "egl-headless" in out: + print(f" {t('3D was on; retry without it:')}") + print(" ./script/qemu/deploy_qemu.py --gpu off …") outcome.append((jname, rc, out, secs)) return outcome + @staticmethod + def _qemu_save_failure_log(name, out): + """Écrit la sortie complète d'une création ratée. Rend le chemin. + + L'appelant jette `out` après la boucle : sans ce fichier, l'unique + trace d'un échec est ce qui a défilé à l'écran. Rend None si l'écriture + échoue — perdre le journal ne doit pas faire perdre le déploiement. + """ + try: + from script.todo.qemu_install_monitor import session_dir + + sur = "".join( + c if c.isalnum() or c in "-_." else "_" for c in name + ) + chemin = session_dir() / f"{sur}-create.log" + chemin.write_text(out, encoding="utf-8", errors="replace") + return chemin + except Exception: + return None + def _qemu_deploy_jobs_tui(self, jobs, workers): """Même chose, en blocs repliables Textual. Renvoie None si textual manque, pour que l'appelant retombe sur la sortie texte.""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8325d41..c5cb581 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4961,6 +4961,14 @@ TRANSLATIONS = { "fr": "Logs :", "en": "Logs:", }, + "3D was on; retry without it:": { + "fr": "La 3D était active ; réessayer sans elle :", + "en": "3D was on; retry without it:", + }, + "Full output:": { + "fr": "Sortie complète :", + "en": "Full output:", + }, "Read the logs:": { "fr": "Lire les logs :", "en": "Read the logs:", diff --git a/test/test_qemu_deploy_failure_output.py b/test/test_qemu_deploy_failure_output.py new file mode 100644 index 0000000..07df6fc --- /dev/null +++ b/test/test_qemu_deploy_failure_output.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Une création de VM ratée doit montrer le message de l'outil. + +La sortie de virt-install est capturée en mémoire puis jetée après la boucle : +si elle ne s'affiche pas à ce moment-là, elle n'existe plus nulle part. Quatre +lignes ne suffisent pas — l'épilogue « Échec de la commande » et sa ligne de +commande les occupent entièrement, et le message de l'outil tombe juste +au-dessus de la fenêtre. + +Ce que ces tests gardent : + +- une VM qui réussit reste discrète, une qui échoue montre assez pour être + diagnostiquée ; +- la sortie complète d'un échec atterrit dans un fichier, seule trace qui + survit à l'écran ; +- un journal qu'on ne peut pas écrire ne fait pas échouer le déploiement. +""" + +import io +import sys +import unittest +from contextlib import redirect_stdout +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +# Forme réelle d'un échec : le message de l'outil, puis l'épilogue qui occupe +# à lui seul les quatre dernières lignes. +SORTIE_ECHEC = "\n".join( + [f" bruit {i}" for i in range(20)] + + [ + "ERROR Le message de virt-install qui explique tout", + " virsh --connect qemu:///system start la-vm", + " sinon, recommencer l'installation.", + "Échec de la commande (code 1) :", + " env XDG_CACHE_HOME=/var/tmp/x virt-install --connect …", + ] +) + + +class FailureOutput(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def _lancer(self, rc, sortie): + """Rend ce que le déploiement affiche pour un job au code `rc`.""" + jobs = [(1, "la-vm", ["/bin/true"])] + + class Res: + returncode = rc + stdout = sortie + stderr = "" + + with mock.patch( + "script.todo.qemu_deploy.subprocess.run", return_value=Res() + ), mock.patch.object( + self.todo, "_fmt_dur", return_value="1s" + ), mock.patch.object( + self.todo, "_qemu_save_failure_log", return_value="/tmp/x.log" + ): + buf = io.StringIO() + with redirect_stdout(buf): + self.todo._qemu_deploy_jobs_cli(jobs, 1) + return buf.getvalue() + + def test_a_failure_shows_the_tool_message(self): + """La régression même : avec quatre lignes, cette ligne manquait.""" + rendu = self._lancer(1, SORTIE_ECHEC) + self.assertIn("Le message de virt-install qui explique tout", rendu) + + def test_a_failure_names_the_full_log(self): + rendu = self._lancer(1, SORTIE_ECHEC) + self.assertIn("/tmp/x.log", rendu) + + def test_a_success_stays_terse(self): + """Sans quoi trente lignes par VM réussie noieraient un lot de dix.""" + rendu = self._lancer(0, SORTIE_ECHEC) + self.assertNotIn("bruit 0", rendu) + corps = [ + ln + for ln in rendu.splitlines() + if ln.startswith(" ") and ln.strip() + ] + self.assertLessEqual(len(corps), 4, corps) + + def test_a_3d_failure_names_the_way_out(self): + """« --gpu off » n'est pas dans le menu : si le rapport ne le nomme + pas, un hôte incompatible avec la 3D n'a aucune issue depuis TODO.""" + rendu = self._lancer(1, SORTIE_ECHEC + "\n --video accel3d=on") + self.assertIn("--gpu off", rendu) + + def test_a_failure_without_3d_stays_silent_about_it(self): + rendu = self._lancer(1, SORTIE_ECHEC) + self.assertNotIn("--gpu off", rendu) + + def test_an_unwritable_log_does_not_break_the_deploy(self): + """Perdre le journal ne doit pas faire perdre le déploiement.""" + with mock.patch( + "script.todo.qemu_install_monitor.session_dir", + side_effect=OSError("disque plein"), + ): + chemin = self.todo._qemu_save_failure_log("la-vm", "peu importe") + self.assertIsNone(chemin) + + def test_the_log_name_survives_a_hostile_vm_name(self): + """Le nom de VM vient de l'utilisateur : il ne doit pas composer un + chemin hors du répertoire de session.""" + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as tmp: + with mock.patch( + "script.todo.qemu_install_monitor.session_dir", + return_value=Path(tmp), + ): + chemin = self.todo._qemu_save_failure_log("../../evil vm", "x") + self.assertIsNotNone(chemin) + self.assertEqual(chemin.parent, Path(tmp)) + self.assertNotIn("/", chemin.name.replace("-create.log", "")) + + +if __name__ == "__main__": + unittest.main()