#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Le réseau libvirt de l'hôte, et la collision qui lui coûtait sa passerelle. Le réseau « default » de libvirt sert 192.168.122.0/24. Toute VM déployée par ce dépôt vit DANS ce réseau : son pont y prendrait l'adresse .1, celle de sa propre passerelle. virsh refuse ce démarrage — mais seulement quand la route est là. Au démarrage de la machine, libvirtd monte ses réseaux AVANT que le bail DHCP ne soit arrivé : plus rien ne signale la collision, virbr0 prend l'adresse de la passerelle, et l'hôte n'a plus de réseau. Une installation suivie d'un redémarrage rendait donc la machine inutilisable. Ce que ces tests gardent : - un réseau en collision est DÉPLACÉ sur un /24 libre, par redéfinition — laquelle ne demande ni pont ni module du noyau, et passe donc là où le démarrage ne passe pas ; - l'autostart ne s'arme JAMAIS sur un réseau en collision, et se RETIRE si on le trouve armé : c'est le seul geste qui protège le démarrage suivant ; - l'autostart s'arme quand le seul obstacle est le noyau remplacé depuis le démarrage — c'est ce qui rend l'hôte utilisable en UN redémarrage ; - l'état d'un réseau est lu en ANGLAIS : virsh traduit ses étiquettes, et un hôte en français lisait tout réseau comme éteint et jamais prêt ; - le XML déplacé garde l'identité du réseau — UUID, pont, MAC ; - un réseau démarré ne se compte PAS comme sa propre collision : il porte et route son /24 sur son pont, et ce pont est écarté de ce que « l'hôte occupe déjà ». Sans cette exclusion, le verdict était « collision » sur toute machine où le réseau tournait, quel que soit son sous-réseau, et le déplacement qui suivait retirait leur passerelle aux VM attachées. """ 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() # Le XML que virsh rend pour le réseau « default » d'une installation neuve. XML_DEFAUT = """ default 2cf1310d-8812-456e-b761-9867efe4af8d """ # « virsh net-info » sous locale française : les étiquettes sont traduites, # les valeurs aussi. INFO_FR = """Nom : default Actif : oui Persistant : oui Démarrage automatique : oui """ INFO_EN = """Name: default Active: yes Persistent: yes Autostart: yes """ def reseaux(*cidrs): import ipaddress return [ipaddress.ip_network(c, strict=False) for c in cidrs] class LaLecture(unittest.TestCase): """Ce que le script lit de virsh, et dans quelle langue.""" def test_the_state_is_read_in_english(self): """virsh TRADUIT ses étiquettes. Le script forçait la locale nulle part : sur un hôte français, « Active: yes » ne se trouvait jamais, tout réseau passait pour éteint, et --setup-host déclarait l'hôte pas prêt quel que soit son état.""" with mock.patch.object(DQ, "virsh_out", return_value=INFO_EN): self.assertEqual((True, True), DQ.network_state("default", False)) def test_the_french_output_is_what_the_old_pattern_missed(self): """La preuve du défaut, figée : la sortie traduite ne contient AUCUN des deux motifs anglais. Si un jour on retire LC_ALL=C, ce test rappelle ce qu'on relit.""" with mock.patch.object(DQ, "virsh_out", return_value=INFO_FR): self.assertEqual( (False, False), DQ.network_state("default", False) ) def test_the_c_locale_is_forced(self): env = DQ.c_locale_env() self.assertEqual("C", env["LC_ALL"]) self.assertEqual("C", env["LANG"]) def test_the_subnet_comes_from_the_xml(self): self.assertEqual( "192.168.122.0/24", DQ.cidr_from_network_xml(XML_DEFAUT) ) def test_an_unreadable_xml_says_nothing(self): """Rien plutôt qu'un sous-réseau deviné : c'est sur lui qu'on décide de déplacer un réseau.""" self.assertEqual("", DQ.cidr_from_network_xml("")) self.assertEqual("", DQ.cidr_from_network_xml("")) class LaCollision(unittest.TestCase): def test_the_default_network_collides_inside_a_deployed_vm(self): """Une VM de ce dépôt reçoit 192.168.122.x de son hôte, et son propre « default » sert le même /24 : la collision est la règle, pas le cas limite.""" self.assertEqual( "192.168.122.0/24", DQ.network_collision( "192.168.122.0/24", reseaux("192.168.122.0/24") ), ) def test_a_wider_host_route_still_collides(self): """Le recouvrement se CALCULE : un /16 de l'hôte contient le /24 du réseau, ce qu'une comparaison de préfixes texte ne verrait pas.""" self.assertTrue( DQ.network_collision("192.168.131.0/24", reseaux("192.168.0.0/16")) ) def test_a_free_subnet_does_not_collide(self): self.assertEqual( "", DQ.network_collision( "192.168.131.0/24", reseaux("192.168.122.0/24", "127.0.0.0/8") ), ) def test_no_subnet_means_no_verdict(self): self.assertEqual("", DQ.network_collision("", reseaux("10.0.0.0/8"))) class CeQueLHoteOccupe(unittest.TestCase): """host_networks : ce qui compte comme « déjà pris », et ce qui non.""" # Ce que rendent « ip -4 route show » et « ip -o -4 addr show » sur un hôte # dont le réseau local est ailleurs et dont le « default » de libvirt # tourne. Les deux dernières lignes de chaque sortie sont celles du pont. ROUTES = ( "default via 192.168.2.1 dev enp6s0 proto dhcp src 192.168.2.11" " metric 100\n" "192.168.2.0/24 dev enp6s0 proto kernel scope link src 192.168.2.11\n" "192.168.122.0/24 dev virbr0 proto kernel scope link" " src 192.168.122.1\n" ) ADRESSES = ( "1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever\n" "2: enp6s0 inet 192.168.2.11/24 brd 192.168.2.255 scope global" " enp6s0\\ valid_lft forever\n" "3: virbr0 inet 192.168.122.1/24 brd 192.168.122.255 scope global" " virbr0\\ valid_lft forever\n" ) def _host_networks(self, exclure=()): sorties = {"route": self.ROUTES, "addr": self.ADRESSES} def faux_run(args, **kwargs): quoi = "route" if "route" in args else "addr" return mock.Mock(stdout=sorties[quoi]) with mock.patch.object(DQ.subprocess, "run", side_effect=faux_run): return {str(r) for r in DQ.host_networks(exclure_ponts=exclure)} def test_the_network_no_longer_collides_with_itself(self): """Le défaut qui déplaçait un réseau sain. Un « default » démarré route son propre /24 : compté, il est en collision avec lui-même sur TOUTE machine, et le déplacement qui suit retire aux VM attachées la passerelle qu'elles ont dans leur bail.""" vus = self._host_networks(exclure=["virbr0"]) self.assertNotIn("192.168.122.0/24", vus) self.assertEqual( "", DQ.network_collision("192.168.122.0/24", reseaux(*vus)), ) def test_without_the_exclusion_the_verdict_was_always_collision(self): """La preuve du défaut, figée : sans exclusion, le même hôte — dont le réseau local est pourtant ailleurs — voit une collision.""" vus = self._host_networks() self.assertIn("192.168.122.0/24", vus) def test_the_other_interfaces_still_count(self): """Écarter le pont n'aveugle pas sur le reste : c'est le réseau local de l'hôte qui rend une collision RÉELLE, et il doit rester vu.""" vus = self._host_networks(exclure=["virbr0"]) self.assertIn("192.168.2.0/24", vus) self.assertIn("127.0.0.0/8", vus) def test_a_line_without_a_readable_device_is_kept(self): """Sur « ce /24 est-il libre », le silence pèse du côté prudent : une ligne dont l'interface ne se lit pas compte comme occupée. Un pont inconnu ('' rendu par network_bridge) n'écarte donc rien.""" self.assertEqual("", DQ.interface_de_ligne("192.168.9.0/24 proto ra")) vus = self._host_networks(exclure=[""]) self.assertIn("192.168.122.0/24", vus) self.assertIn("192.168.2.0/24", vus) def test_the_interface_is_read_from_both_grammars(self): self.assertEqual( "enp6s0", DQ.interface_de_ligne("192.168.2.0/24 dev enp6s0 proto") ) self.assertEqual( "virbr0", DQ.interface_de_ligne("3: virbr0 inet 192.168.122.1/24 scope"), ) # Une interface appairée porte le nom de son pair : « eth0@if12 ». self.assertEqual( "eth0", DQ.interface_de_ligne("5: eth0@if12 inet 10.0.0.2/24") ) class LePontDuReseau(unittest.TestCase): def test_the_bridge_comes_from_the_xml(self): self.assertEqual("virbr0", DQ.bridge_from_network_xml(XML_DEFAUT)) def test_no_bridge_says_nothing(self): """'' plutôt qu'un nom deviné : c'est sur lui qu'on écarte une interface de la recherche de collision.""" self.assertEqual("", DQ.bridge_from_network_xml("")) self.assertEqual("", DQ.bridge_from_network_xml("")) class LeFichierTemporaire(unittest.TestCase): """Le XML posé pour « net-define » : imprévisible, et retiré.""" def test_it_is_removed_even_when_virsh_fails(self): with self.assertRaises(RuntimeError): with DQ.fichier_xml_temporaire("") as chemin: garde = chemin raise RuntimeError("virsh a échoué") self.assertFalse(Path(garde).exists()) def test_the_name_is_not_predictable(self): """Un nom composé est un chemin devinable dans un répertoire où tout le monde écrit : qui l'occupe d'avance choisit ce que root définit.""" with DQ.fichier_xml_temporaire("") as un: with DQ.fichier_xml_temporaire("") as deux: self.assertNotEqual(un, deux) def test_root_can_read_it(self): """virsh lit le fichier sous root, et le fichier temporaire d'un utilisateur non privilégié ne l'est pas toujours.""" with DQ.fichier_xml_temporaire("") as chemin: self.assertTrue(Path(chemin).stat().st_mode & 0o044) class LeSousReseauLibre(unittest.TestCase): def test_it_starts_above_the_two_usual_ones(self): """122 est celui de libvirt, 123 celui de beaucoup d'installations toutes faites : partir au-dessus des deux coûte un octet.""" self.assertEqual(131, DQ.LIBVIRT_NET_BASE) self.assertEqual("192.168.131", DQ.free_subnet(reseaux())) def test_it_skips_what_is_taken(self): pris = reseaux("192.168.131.0/24", "192.168.132.0/24") self.assertEqual("192.168.133", DQ.free_subnet(pris)) def test_a_saturated_range_says_so(self): """'' et non un sous-réseau au hasard : l'appelant doit pouvoir dire qu'il ne déplace rien.""" pris = reseaux("192.168.0.0/16") self.assertEqual("", DQ.free_subnet(pris)) class LeDeplacement(unittest.TestCase): def test_the_network_keeps_its_identity(self): """Réécrit plutôt que reconstruit : l'UUID, le pont et le MAC restent, donc les domaines qui nomment ce réseau le retrouvent.""" bouge = DQ.moved_network_xml(XML_DEFAUT, "192.168.122", "192.168.131") self.assertIn("2cf1310d-8812-456e-b761-9867efe4af8d", bouge) self.assertIn(" 1 else suite_cidr[0] ), ), mock.patch.object( DQ, "host_networks", return_value=reseaux(*hote) ), mock.patch.object( DQ, "libvirt_networks_cidrs", return_value=[] ), mock.patch.object( DQ, "virsh_out", return_value=XML_DEFAUT ), mock.patch.object( DQ, "network_state", side_effect=lambda *a: ( etats.pop(0) if len(etats) > 1 else etats[0] ), ): with redirect_stdout(io.StringIO()) as sortie: DQ.ensure_network("default", runner) return lancees, sortie.getvalue() def test_a_colliding_network_is_moved_before_being_started(self): """La redéfinition ne demande ni pont ni module du noyau : elle passe là où le démarrage ne passe pas.""" lancees, texte = self._lancer( cidrs=["192.168.122.0/24", "192.168.131.0/24"], hote=["192.168.122.0/24"], actif_apres=True, etat_initial=(False, False), ) verbes = [c[0] for c in lancees] self.assertEqual(["net-define", "net-start", "net-autostart"], verbes) self.assertIn("192.168.131.0/24", texte) def test_autostart_is_never_armed_on_a_collision(self): """Le geste qui cassait la machine. Sans /24 libre, rien n'est déplacé — et alors rien n'est armé.""" lancees, texte = self._lancer( cidrs=["192.168.122.0/24"], hote=["192.168.0.0/16"], actif_apres=False, etat_initial=(False, False), ) self.assertNotIn("net-autostart", [c[0] for c in lancees]) def test_an_armed_collision_is_disarmed(self): """L'état dans lequel --setup-host laissait la machine : un réseau inactif, en collision, et armé pour le prochain démarrage.""" lancees, texte = self._lancer( cidrs=["192.168.122.0/24"], hote=["192.168.0.0/16"], actif_apres=False, etat_initial=(False, True), ) self.assertIn(["net-autostart", "--disable", "default"], lancees) self.assertIn("autostart RETIRÉ", texte) def test_a_stale_kernel_keeps_the_one_reboot_shortcut(self): """Sans collision, un réseau qui ne démarre pas faute de modules du noyau reste armé : au retour du redémarrage, libvirt le monte seul et l'hôte est utilisable sans repasser par --setup-host.""" lancees, _ = self._lancer( cidrs=["192.168.131.0/24"], hote=["192.168.122.0/24"], actif_apres=False, etat_initial=(False, False), ) verbes = [c[0] for c in lancees] self.assertEqual(["net-start", "net-autostart"], verbes) def test_a_healthy_network_is_left_alone(self): """Idempotence : relancer --setup-host sur un hôte prêt ne doit rien lancer, et surtout pas redéfinir le réseau sous les VM qui tournent.""" lancees, _ = self._lancer( cidrs=["192.168.131.0/24"], hote=["192.168.122.0/24"], actif_apres=True, etat_initial=(True, True), ) self.assertEqual([], lancees) def test_an_active_network_without_collision_is_never_moved(self): """Le déplacer casserait les VM qui y sont attachées pour un danger qui n'existe pas.""" lancees, _ = self._lancer( cidrs=["192.168.131.0/24"], hote=["192.168.122.0/24"], actif_apres=True, etat_initial=(True, True), ) self.assertNotIn("net-define", [c[0] for c in lancees]) def test_an_active_collision_is_torn_down_first(self): """La machine DÉJÀ cassée, celle qu'on retrouve après le démarrage fautif : le pont porte l'adresse de la passerelle. L'abattre rend l'accès au réseau avant toute autre chose — sans quoi il n'y a même pas de quoi télécharger un correctif.""" lancees, texte = self._lancer( cidrs=["192.168.122.0/24", "192.168.131.0/24"], hote=["192.168.122.0/24"], actif_apres=True, etat_initial=(True, True), ) verbes = [c[0] for c in lancees] self.assertEqual("net-destroy", verbes[0]) self.assertLess( verbes.index("net-destroy"), verbes.index("net-define") ) self.assertIn("rendre l'accès au réseau", texte) if __name__ == "__main__": unittest.main()