[FIX] qemu setup-host : demander avant de redémarrer l'hôte
Accepter d'installer les paquets QEMU redémarrait la machine sans autre
question : --assume-yes couvrait le gestionnaire de paquets, et la
commande y ajoutait --reboot-if-needed, qui ne demandait rien. Une seule
constante servait la VM qu'on vient de créer et le poste qui la crée.
--reboot-if-needed PROPOSE désormais, sur /dev/tty pour rester visible
quand la sortie est un tuyau, et vaut non par défaut ; un refus laisse
les paquets posés et dit quoi faire. --assume-yes-reboot est le seul
consentement muet, que seul le profil invité porte.
Vérifié : 7 tests, rougis par deux mutations — assume_yes rouvrant la
porte, le menu hôte reprenant le drapeau.
--- EN ---
Accepting the QEMU package install rebooted the machine with no further
question: --assume-yes covered the package manager, and the command
added --reboot-if-needed, which asked nothing. One constant served both
the VM just created and the workstation creating it. --reboot-if-needed
now OFFERS, on /dev/tty so it stays visible when output is a pipe, and
defaults to no; a refusal leaves the packages in place and says what to
do. --assume-yes-reboot is the only silent consent, carried by the guest
profile alone.
Checked: 7 tests, turned red by two mutations — assume_yes reopening the
door, the host menu taking the flag back.
Assisted-by: Claude Opus 5
2026-09-02 07:35:28 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
"""Le redémarrage de l'hôte se demande, il ne se déduit pas.
|
|
|
|
|
|
|
|
|
|
« --setup-host » installe des paquets, et sur une distribution à noyau
|
|
|
|
|
roulant il peut avoir besoin d'un redémarrage pour que libvirt monte virbr0.
|
|
|
|
|
Ces deux actes n'ont pas le même prix : le premier s'annule, le second emporte
|
|
|
|
|
tout ce qui tourne sur la machine.
|
|
|
|
|
|
|
|
|
|
Ce que ces tests gardent :
|
|
|
|
|
|
|
|
|
|
- « --assume-yes » couvre le gestionnaire de paquets, JAMAIS le redémarrage.
|
|
|
|
|
- « --reboot-if-needed » PROPOSE ; un refus laisse la machine debout et sort
|
|
|
|
|
en erreur, sans jamais programmer le redémarrage.
|
|
|
|
|
- « --assume-yes-reboot » est le seul consentement qui se passe de question,
|
|
|
|
|
et il est réservé à la provision d'une VM que personne ne regarde.
|
|
|
|
|
- La commande du menu hôte ne le porte pas ; celle du profil invité le porte.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import importlib.util
|
|
|
|
|
import io
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
from contextlib import redirect_stdout
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
sys.argv = ["todo.py"]
|
|
|
|
|
|
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _deploy_qemu():
|
|
|
|
|
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
|
|
|
|
|
path = RACINE / "script/qemu/deploy_qemu.py"
|
|
|
|
|
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
|
|
|
|
|
mod = importlib.util.module_from_spec(spec)
|
|
|
|
|
spec.loader.exec_module(mod)
|
|
|
|
|
return mod
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DQ = _deploy_qemu()
|
|
|
|
|
|
|
|
|
|
# L'état exact qui déclenche la question : le noyau a été remplacé depuis le
|
|
|
|
|
# démarrage, donc le réseau « default » ne peut pas monter.
|
|
|
|
|
NOYAU_PERIME = "Le noyau en cours n'a plus ses modules."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SetupHostReboot(unittest.TestCase):
|
|
|
|
|
def _lancer(self, reponse=False, **kwargs):
|
|
|
|
|
"""setup_host sur un hôte au noyau périmé et au réseau inactif.
|
|
|
|
|
|
|
|
|
|
Rend le triplet (redémarrages, questions, SystemExit ou None). Tout
|
|
|
|
|
ce qui touche au système est neutralisé : seul l'enchaînement des
|
|
|
|
|
décisions est sous test. « reponse » est ce que l'utilisateur répond
|
|
|
|
|
si la question lui est posée.
|
|
|
|
|
"""
|
|
|
|
|
runner = mock.MagicMock()
|
|
|
|
|
runner.dry_run = False
|
|
|
|
|
runner.use_sudo = False
|
|
|
|
|
reboots = []
|
|
|
|
|
questions = []
|
|
|
|
|
|
|
|
|
|
def question(texte, default=True):
|
|
|
|
|
questions.append(texte)
|
|
|
|
|
return reponse
|
|
|
|
|
|
[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.object(DQ, "ensure_tools"),
|
|
|
|
|
mock.patch.object(DQ, "ensure_libvirt_service"),
|
|
|
|
|
mock.patch.object(DQ, "ensure_libvirt_group", return_value=True),
|
|
|
|
|
mock.patch.object(DQ, "ensure_ssh_key"),
|
|
|
|
|
mock.patch.object(DQ, "ensure_network"),
|
|
|
|
|
mock.patch.object(
|
|
|
|
|
DQ, "kernel_modules_stale", return_value=NOYAU_PERIME
|
|
|
|
|
),
|
|
|
|
|
mock.patch.object(DQ, "libvirt_ready", return_value=True),
|
|
|
|
|
mock.patch.object(DQ, "network_state", return_value=(False, True)),
|
|
|
|
|
mock.patch.object(
|
|
|
|
|
DQ, "schedule_reboot", side_effect=lambda r: reboots.append(r)
|
|
|
|
|
),
|
|
|
|
|
mock.patch.object(DQ, "prompt_yes_no", side_effect=question),
|
[FIX] qemu setup-host : demander avant de redémarrer l'hôte
Accepter d'installer les paquets QEMU redémarrait la machine sans autre
question : --assume-yes couvrait le gestionnaire de paquets, et la
commande y ajoutait --reboot-if-needed, qui ne demandait rien. Une seule
constante servait la VM qu'on vient de créer et le poste qui la crée.
--reboot-if-needed PROPOSE désormais, sur /dev/tty pour rester visible
quand la sortie est un tuyau, et vaut non par défaut ; un refus laisse
les paquets posés et dit quoi faire. --assume-yes-reboot est le seul
consentement muet, que seul le profil invité porte.
Vérifié : 7 tests, rougis par deux mutations — assume_yes rouvrant la
porte, le menu hôte reprenant le drapeau.
--- EN ---
Accepting the QEMU package install rebooted the machine with no further
question: --assume-yes covered the package manager, and the command
added --reboot-if-needed, which asked nothing. One constant served both
the VM just created and the workstation creating it. --reboot-if-needed
now OFFERS, on /dev/tty so it stays visible when output is a pipe, and
defaults to no; a refusal leaves the packages in place and says what to
do. --assume-yes-reboot is the only silent consent, carried by the guest
profile alone.
Checked: 7 tests, turned red by two mutations — assume_yes reopening the
door, the host menu taking the flag back.
Assisted-by: Claude Opus 5
2026-09-02 07:35:28 -04:00
|
|
|
):
|
|
|
|
|
with redirect_stdout(io.StringIO()):
|
|
|
|
|
try:
|
|
|
|
|
DQ.setup_host(runner, **kwargs)
|
|
|
|
|
sortie = None
|
|
|
|
|
except SystemExit as exc:
|
|
|
|
|
sortie = exc
|
|
|
|
|
return reboots, questions, sortie
|
|
|
|
|
|
|
|
|
|
def test_assume_yes_alone_never_reboots(self):
|
|
|
|
|
"""La régression même : accepter l'installation des paquets ne
|
|
|
|
|
redémarrait pas la machine, mais l'appelant, lui, ajoutait le drapeau
|
|
|
|
|
qui le faisait. Sans le drapeau, rien ne redémarre et rien n'est
|
|
|
|
|
demandé."""
|
|
|
|
|
reboots, questions, sortie = self._lancer(
|
|
|
|
|
assume_yes=True, no_install=False, reboot_if_needed=False
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(reboots, [])
|
|
|
|
|
self.assertEqual(questions, [])
|
|
|
|
|
self.assertIsInstance(sortie, SystemExit)
|
|
|
|
|
|
|
|
|
|
def test_reboot_if_needed_asks_and_a_refusal_stops(self):
|
|
|
|
|
reboots, questions, sortie = self._lancer(
|
|
|
|
|
assume_yes=True,
|
|
|
|
|
no_install=False,
|
|
|
|
|
reboot_if_needed=True,
|
|
|
|
|
reponse=False,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(len(questions), 1, questions)
|
|
|
|
|
self.assertEqual(reboots, [])
|
|
|
|
|
self.assertIsInstance(sortie, SystemExit)
|
|
|
|
|
# Le refus doit se lire dans le message : un « pas prêt » sec laisse
|
|
|
|
|
# croire à une panne alors que la machine a obéi.
|
|
|
|
|
self.assertIn("refusé", str(sortie))
|
|
|
|
|
|
|
|
|
|
def test_reboot_if_needed_reboots_when_accepted(self):
|
|
|
|
|
reboots, questions, sortie = self._lancer(
|
|
|
|
|
assume_yes=True,
|
|
|
|
|
no_install=False,
|
|
|
|
|
reboot_if_needed=True,
|
|
|
|
|
reponse=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(len(questions), 1)
|
|
|
|
|
self.assertEqual(len(reboots), 1)
|
|
|
|
|
self.assertIsNone(sortie)
|
|
|
|
|
|
|
|
|
|
def test_assume_yes_reboot_skips_the_question(self):
|
|
|
|
|
"""La provision d'une VM neuve n'a personne pour répondre : sans ce
|
|
|
|
|
drapeau, la question tomberait sur un EOF et la VM resterait sur un
|
|
|
|
|
noyau sans modules."""
|
|
|
|
|
reboots, questions, sortie = self._lancer(
|
|
|
|
|
assume_yes=True,
|
|
|
|
|
no_install=False,
|
|
|
|
|
reboot_if_needed=True,
|
|
|
|
|
assume_yes_reboot=True,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(questions, [])
|
|
|
|
|
self.assertEqual(len(reboots), 1)
|
|
|
|
|
self.assertIsNone(sortie)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ConsentInTheCallers(unittest.TestCase):
|
|
|
|
|
"""Le drapeau se lit dans les commandes que TODO fabrique."""
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _commande_hote():
|
|
|
|
|
"""La chaîne assignée à « cmd » dans _qemu_ensure_tools.
|
|
|
|
|
|
|
|
|
|
Lue par l'arbre syntaxique et non par le texte : un commentaire qui
|
|
|
|
|
NOMME le drapeau pour expliquer son absence est légitime, et une
|
|
|
|
|
recherche textuelle le prendrait pour la commande.
|
|
|
|
|
"""
|
|
|
|
|
import ast
|
|
|
|
|
|
|
|
|
|
source = (RACINE / "script/todo/qemu_menu.py").read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
for node in ast.walk(ast.parse(source)):
|
|
|
|
|
if (
|
|
|
|
|
isinstance(node, ast.FunctionDef)
|
|
|
|
|
and node.name == "_qemu_ensure_tools"
|
|
|
|
|
):
|
|
|
|
|
for stmt in ast.walk(node):
|
|
|
|
|
if (
|
|
|
|
|
isinstance(stmt, ast.Assign)
|
|
|
|
|
and getattr(stmt.targets[0], "id", "") == "cmd"
|
|
|
|
|
):
|
|
|
|
|
return ast.literal_eval(stmt.value)
|
|
|
|
|
raise AssertionError("cmd introuvable dans _qemu_ensure_tools")
|
|
|
|
|
|
|
|
|
|
def test_the_host_menu_never_assumes_the_reboot(self):
|
|
|
|
|
cmd = self._commande_hote()
|
|
|
|
|
self.assertIn("--setup-host", cmd)
|
|
|
|
|
self.assertIn("--reboot-if-needed", cmd)
|
|
|
|
|
self.assertNotIn("--assume-yes-reboot", cmd)
|
|
|
|
|
|
|
|
|
|
def test_the_guest_profile_carries_the_explicit_consent(self):
|
|
|
|
|
source = (RACINE / "script/todo/qemu_install.py").read_text(
|
|
|
|
|
encoding="utf-8"
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("--assume-yes-reboot", source)
|
|
|
|
|
|
|
|
|
|
def test_the_flag_exists_in_the_parser(self):
|
|
|
|
|
parser = DQ.build_parser()
|
|
|
|
|
rendu = parser.format_help()
|
|
|
|
|
self.assertIn("--assume-yes-reboot", rendu)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|