127 lines
4.6 KiB
Python
127 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()
|