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
126 lines
4.6 KiB
Python
126 lines
4.6 KiB
Python
#!/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()
|