[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
2026-09-02 08:32:41 -04:00
|
|
|
#!/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 = ""
|
|
|
|
|
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
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"
|
|
|
|
|
),
|
[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
2026-09-02 08:32:41 -04:00
|
|
|
):
|
|
|
|
|
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()
|