[FIX] qemu deploy : montrer le message de l'outil quand une VM échoue

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
This commit is contained in:
Mathieu Benoit 2026-09-02 08:32:41 -04:00
parent 97ccce1def
commit d3e8d50953
3 changed files with 173 additions and 1 deletions

View file

@ -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."""

View file

@ -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:",

View file

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