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