From 82c651f1df67e739aea7035662e1ce7222ff2016 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 03:31:08 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20qemu=20r=C3=A9seau=20:=20ne=20plus=20co?= =?UTF-8?q?mpter=20le=20pont=20d'un=20r=C3=A9seau=20contre=20lui-m=C3=AAme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un réseau libvirt démarré porte et route son /24 sur son pont, et ce pont comptait dans « ce que l'hôte occupe déjà » : le verdict était donc « collision » sur toute machine où le réseau tournait, quel que soit son sous-réseau. Tout déploiement passe par cette vérification, qui abattait alors le réseau et le déplaçait sur un /24 libre là où rien n'entrait en conflit — les VM attachées y perdaient passerelle et pont. Le pont du réseau examiné en est écarté, chaque adresse étant rattachée à son interface ; un nom de pont illisible n'écarte rien. Le XML de net-define passe par un fichier temporaire imprévisible, retiré même quand virsh échoue. Vérifié : 31 tests, dont 10 neufs, que le retrait de l'exclusion casse. --- EN --- A started libvirt network carries and routes its /24 on its bridge, and that bridge counted as « what the host already occupies »: the verdict was therefore « collision » on every machine where the network ran, whatever subnet it served. Every deployment goes through that check, which then tore the network down and moved it onto a free /24 where nothing conflicted — the attached VMs lost gateway and bridge. The bridge of the network being examined is now excluded, each address being tied to its own interface; an unreadable bridge name excludes nothing. The XML for net-define goes through an unpredictable temporary file, removed even when virsh fails. Checked: 31 tests, 10 of them new, which removing the exclusion breaks. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 2 + CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + script/qemu/deploy_qemu.py | 132 ++++++++++++++++++++++++++++----- test/test_qemu_host_network.py | 125 +++++++++++++++++++++++++++++-- 5 files changed, 237 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 631f1c3..97081ee 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -274,6 +274,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - A deployment says WHY it needs a password before sudo asks for one, and on what it checked. sudo never states what it is about to do: the prompt lands between two log lines, and one types a password without knowing whether it covers libvirt, a package or a file — the more so as being in the `libvirt` group looks like it should be enough. It is not, and libvirt is not the reason: the disk and the cloud-init seed are written into libvirt's default pool, a root-owned directory where the group grants no write right at all. The reason is therefore CHECKED and not asserted — writing is tested, since an ACL can grant it where `drwxr-xr-x root root` seems to refuse it — then named with the directory, its owner and its mode, next to what the group really does cover: the qemu:///system socket, probed by trying. Said once per run, before the first privileged command, and last on the final review page, which is the screen right before the prompt. Root is told nothing, no prompt being due; a missing virsh reads as missing rather than as a group at fault +- A libvirt network no longer counts as its own collision. A started network carries and routes its /24 on its bridge, and that bridge was read as « the host already occupies this », so the verdict was « collision » on every machine where the network ran, whatever subnet it served. `--setup-host` — and every deployment, which calls the same check — then tore the network down and moved it onto a free /24 where nothing conflicted, leaving the VMs attached to it without a gateway and with a detached tap. The bridge of the network being examined is now excluded from what the host occupies, addresses being read one interface per line so each address can be tied to its own; an unreadable bridge name excludes nothing, silence weighing on the cautious side. The XML handed to `net-define` goes through a real temporary file, unpredictable and removed even when virsh fails, where a name composed of the network's own was a guessable path in a directory everyone writes to - `--setup-host` no longer leaves a machine that loses its network at the next boot. libvirt's `default` network serves 192.168.122.0/24, and every VM this repository deploys LIVES in that network: its bridge would take the .1 address, which is that machine's own gateway. virsh refuses such a start — but only while the route is there, and at boot libvirtd raises its networks BEFORE the host's DHCP lease arrives: nothing signals the collision any more, virbr0 takes the gateway address, and the host has no network left. Autostart was armed even when the start had just failed, and the message then advised a reboot. The network is now MOVED onto a free /24 by redefinition, which needs neither bridge nor kernel module and therefore works where the start does not, keeping its UUID, bridge name and MAC so the domains naming it still find it; autostart is armed only where no collision remains, and REMOVED where one does. A network found active on a route of the host is torn down first — that is the already-broken machine, and it is what gives the host its network back, before anything else needs downloading. One reboot still suffices where the only obstacle is a kernel replaced since boot - The state of a libvirt network is read in English. virsh TRANSLATES its labels: under a French locale `net-info` answers « Actif : non », where a pattern on `Active: yes` never matches — so every network read as off, `--setup-host` declared the host not ready whatever its state, and its advice was to reboot for nothing. Every parsed virsh output now goes through one call that forces `LC_ALL=C`, the same gesture, for the same reason, as the QEMU management screen - The KeePassXC vault opens on a machine without tkinter, which is every server. Both imports shared a single `try`, so a missing tkinter set PyKeePass to None as well: the vault stayed unopenable even with path and password configured, while the log said `pykeepass is not installed` and pykeepass 4.2 was there. tkinter serves only the file picker, when no path is configured. The prompt also names the vault before asking for its password, rather than after @@ -303,6 +304,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - Un déploiement dit POURQUOI il faut un mot de passe avant que sudo ne le demande, et sur quel constat. sudo ne dit jamais ce qu'il sert à faire : l'invite tombe entre deux lignes de journal, et l'on tape un mot de passe sans savoir s'il porte sur libvirt, sur un paquet ou sur un fichier — d'autant qu'appartenir au groupe `libvirt` a tout l'air de suffire. Il ne suffit pas, et libvirt n'y est pour rien : le disque et le seed cloud-init s'écrivent dans le pool par défaut de libvirt, un répertoire de root où le groupe ne donne aucun droit d'écriture. La raison est donc CONSTATÉE et non affirmée — l'écriture se teste, une ACL pouvant l'accorder là où « drwxr-xr-x root root » semble la refuser —, puis nommée avec le répertoire, son propriétaire et son mode, à côté de ce que le groupe couvre vraiment : la socket qemu:///system, sondée en essayant. Dit une fois par exécution, avant la première commande privilégiée, et en dernière ligne du récapitulatif final, qui est l'écran juste avant l'invite. Root ne s'entend rien annoncer, aucune invite ne lui étant due ; un virsh absent se lit comme absent, et non comme un groupe en défaut +- Un réseau libvirt ne compte plus comme sa propre collision. Un réseau démarré porte et route son /24 sur son pont, et ce pont était lu comme « l'hôte occupe déjà ceci » : le verdict était donc « collision » sur toute machine où le réseau tournait, quel que soit le sous-réseau qu'il servait. `--setup-host` — et tout déploiement, qui passe par la même vérification — abattait alors le réseau et le déplaçait sur un /24 libre là où rien n'entrait en conflit, laissant les VM qui y étaient attachées sans passerelle et le tap détaché. Le pont du réseau examiné est désormais écarté de ce que l'hôte occupe, les adresses étant lues une interface par ligne pour que chacune se rattache à la sienne ; un nom de pont illisible n'écarte rien, le silence pesant du côté prudent. Le XML remis à « net-define » passe par un vrai fichier temporaire, imprévisible et retiré même quand virsh échoue, là où un nom composé de celui du réseau était un chemin devinable dans un répertoire où tout le monde écrit - `--setup-host` ne laisse plus derrière lui une machine qui perd son réseau au démarrage suivant. Le réseau « default » de libvirt sert 192.168.122.0/24, et 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 tant que la route est là, et au démarrage libvirtd monte ses réseaux AVANT que le bail DHCP de l'hôte n'arrive : plus rien ne signale la collision, virbr0 prend l'adresse de la passerelle, et l'hôte n'a plus de réseau. L'autostart était armé même quand le démarrage venait d'échouer, et le message invitait alors à redémarrer. Le réseau est désormais DÉPLACÉ sur un /24 libre par redéfinition — laquelle ne demande ni pont ni module du noyau, donc elle passe là où le démarrage ne passe pas — en gardant son UUID, le nom de son pont et son adresse MAC, si bien que les domaines qui le nomment le retrouvent ; l'autostart ne s'arme que là où aucune collision ne reste, et se RETIRE là où il en reste une. Un réseau trouvé actif sur une route de l'hôte est abattu d'abord : c'est la machine déjà cassée, et c'est ce qui lui rend son accès au réseau, avant même qu'il y ait de quoi télécharger un correctif. Un seul redémarrage suffit toujours quand le seul obstacle est un noyau remplacé depuis le démarrage - L'état d'un réseau libvirt est lu en anglais. virsh TRADUIT ses étiquettes : sous une locale française, « net-info » répond « Actif : non », où un motif sur « Active: yes » ne trouve jamais rien — tout réseau se lisait donc éteint, « --setup-host » déclarait l'hôte pas prêt quel que soit son état, et son conseil était de redémarrer pour rien. Toute sortie de virsh que l'on analyse passe maintenant par un appel unique qui force « LC_ALL=C », le même geste, pour la même raison, que l'écran de gestion QEMU - Le coffre KeePassXC s'ouvre sur une machine sans tkinter, c'est-à-dire sur tout serveur. Les deux imports partageaient un seul `try`, si bien que l'absence de tkinter mettait aussi PyKeePass à None : le coffre restait inouvrable même avec chemin et mot de passe configurés, alors que le journal annonçait « pykeepass is not installed » et que pykeepass 4.2 était là. tkinter ne sert qu'au sélecteur de fichier, quand aucun chemin n'est configuré. L'invite nomme aussi le coffre avant d'en demander le mot de passe, et non après diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 6ad5e7c..bc43c18 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -129,6 +129,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi ## Corrigé - Un déploiement dit POURQUOI il faut un mot de passe avant que sudo ne le demande, et sur quel constat. sudo ne dit jamais ce qu'il sert à faire : l'invite tombe entre deux lignes de journal, et l'on tape un mot de passe sans savoir s'il porte sur libvirt, sur un paquet ou sur un fichier — d'autant qu'appartenir au groupe `libvirt` a tout l'air de suffire. Il ne suffit pas, et libvirt n'y est pour rien : le disque et le seed cloud-init s'écrivent dans le pool par défaut de libvirt, un répertoire de root où le groupe ne donne aucun droit d'écriture. La raison est donc CONSTATÉE et non affirmée — l'écriture se teste, une ACL pouvant l'accorder là où « drwxr-xr-x root root » semble la refuser —, puis nommée avec le répertoire, son propriétaire et son mode, à côté de ce que le groupe couvre vraiment : la socket qemu:///system, sondée en essayant. Dit une fois par exécution, avant la première commande privilégiée, et en dernière ligne du récapitulatif final, qui est l'écran juste avant l'invite. Root ne s'entend rien annoncer, aucune invite ne lui étant due ; un virsh absent se lit comme absent, et non comme un groupe en défaut +- Un réseau libvirt ne compte plus comme sa propre collision. Un réseau démarré porte et route son /24 sur son pont, et ce pont était lu comme « l'hôte occupe déjà ceci » : le verdict était donc « collision » sur toute machine où le réseau tournait, quel que soit le sous-réseau qu'il servait. `--setup-host` — et tout déploiement, qui passe par la même vérification — abattait alors le réseau et le déplaçait sur un /24 libre là où rien n'entrait en conflit, laissant les VM qui y étaient attachées sans passerelle et le tap détaché. Le pont du réseau examiné est désormais écarté de ce que l'hôte occupe, les adresses étant lues une interface par ligne pour que chacune se rattache à la sienne ; un nom de pont illisible n'écarte rien, le silence pesant du côté prudent. Le XML remis à « net-define » passe par un vrai fichier temporaire, imprévisible et retiré même quand virsh échoue, là où un nom composé de celui du réseau était un chemin devinable dans un répertoire où tout le monde écrit - `--setup-host` ne laisse plus derrière lui une machine qui perd son réseau au démarrage suivant. Le réseau « default » de libvirt sert 192.168.122.0/24, et 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 tant que la route est là, et au démarrage libvirtd monte ses réseaux AVANT que le bail DHCP de l'hôte n'arrive : plus rien ne signale la collision, virbr0 prend l'adresse de la passerelle, et l'hôte n'a plus de réseau. L'autostart était armé même quand le démarrage venait d'échouer, et le message invitait alors à redémarrer. Le réseau est désormais DÉPLACÉ sur un /24 libre par redéfinition — laquelle ne demande ni pont ni module du noyau, donc elle passe là où le démarrage ne passe pas — en gardant son UUID, le nom de son pont et son adresse MAC, si bien que les domaines qui le nomment le retrouvent ; l'autostart ne s'arme que là où aucune collision ne reste, et se RETIRE là où il en reste une. Un réseau trouvé actif sur une route de l'hôte est abattu d'abord : c'est la machine déjà cassée, et c'est ce qui lui rend son accès au réseau, avant même qu'il y ait de quoi télécharger un correctif. Un seul redémarrage suffit toujours quand le seul obstacle est un noyau remplacé depuis le démarrage - L'état d'un réseau libvirt est lu en anglais. virsh TRADUIT ses étiquettes : sous une locale française, « net-info » répond « Actif : non », où un motif sur « Active: yes » ne trouve jamais rien — tout réseau se lisait donc éteint, « --setup-host » déclarait l'hôte pas prêt quel que soit son état, et son conseil était de redémarrer pour rien. Toute sortie de virsh que l'on analyse passe maintenant par un appel unique qui force « LC_ALL=C », le même geste, pour la même raison, que l'écran de gestion QEMU - Le coffre KeePassXC s'ouvre sur une machine sans tkinter, c'est-à-dire sur tout serveur. Les deux imports partageaient un seul `try`, si bien que l'absence de tkinter mettait aussi PyKeePass à None : le coffre restait inouvrable même avec chemin et mot de passe configurés, alors que le journal annonçait « pykeepass is not installed » et que pykeepass 4.2 était là. tkinter ne sert qu'au sélecteur de fichier, quand aucun chemin n'est configuré. L'invite nomme aussi le coffre avant d'en demander le mot de passe, et non après diff --git a/CHANGELOG.md b/CHANGELOG.md index 38ac332..6ab3a9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,7 @@ Recreating the virtual environment, use installation guide from tool `make`. ## Fixed - A deployment says WHY it needs a password before sudo asks for one, and on what it checked. sudo never states what it is about to do: the prompt lands between two log lines, and one types a password without knowing whether it covers libvirt, a package or a file — the more so as being in the `libvirt` group looks like it should be enough. It is not, and libvirt is not the reason: the disk and the cloud-init seed are written into libvirt's default pool, a root-owned directory where the group grants no write right at all. The reason is therefore CHECKED and not asserted — writing is tested, since an ACL can grant it where `drwxr-xr-x root root` seems to refuse it — then named with the directory, its owner and its mode, next to what the group really does cover: the qemu:///system socket, probed by trying. Said once per run, before the first privileged command, and last on the final review page, which is the screen right before the prompt. Root is told nothing, no prompt being due; a missing virsh reads as missing rather than as a group at fault +- A libvirt network no longer counts as its own collision. A started network carries and routes its /24 on its bridge, and that bridge was read as « the host already occupies this », so the verdict was « collision » on every machine where the network ran, whatever subnet it served. `--setup-host` — and every deployment, which calls the same check — then tore the network down and moved it onto a free /24 where nothing conflicted, leaving the VMs attached to it without a gateway and with a detached tap. The bridge of the network being examined is now excluded from what the host occupies, addresses being read one interface per line so each address can be tied to its own; an unreadable bridge name excludes nothing, silence weighing on the cautious side. The XML handed to `net-define` goes through a real temporary file, unpredictable and removed even when virsh fails, where a name composed of the network's own was a guessable path in a directory everyone writes to - `--setup-host` no longer leaves a machine that loses its network at the next boot. libvirt's `default` network serves 192.168.122.0/24, and every VM this repository deploys LIVES in that network: its bridge would take the .1 address, which is that machine's own gateway. virsh refuses such a start — but only while the route is there, and at boot libvirtd raises its networks BEFORE the host's DHCP lease arrives: nothing signals the collision any more, virbr0 takes the gateway address, and the host has no network left. Autostart was armed even when the start had just failed, and the message then advised a reboot. The network is now MOVED onto a free /24 by redefinition, which needs neither bridge nor kernel module and therefore works where the start does not, keeping its UUID, bridge name and MAC so the domains naming it still find it; autostart is armed only where no collision remains, and REMOVED where one does. A network found active on a route of the host is torn down first — that is the already-broken machine, and it is what gives the host its network back, before anything else needs downloading. One reboot still suffices where the only obstacle is a kernel replaced since boot - The state of a libvirt network is read in English. virsh TRANSLATES its labels: under a French locale `net-info` answers « Actif : non », where a pattern on `Active: yes` never matches — so every network read as off, `--setup-host` declared the host not ready whatever its state, and its advice was to reboot for nothing. Every parsed virsh output now goes through one call that forces `LC_ALL=C`, the same gesture, for the same reason, as the QEMU management screen - The KeePassXC vault opens on a machine without tkinter, which is every server. Both imports shared a single `try`, so a missing tkinter set PyKeePass to None as well: the vault stayed unopenable even with path and password configured, while the log said `pykeepass is not installed` and pykeepass 4.2 was there. tkinter serves only the file picker, when no path is configured. The prompt also names the vault before asking for its password, rather than after diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index d025a62..83c50b5 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -47,6 +47,7 @@ from __future__ import annotations import argparse import base64 +import contextlib import getpass import grp import gzip @@ -3123,14 +3124,54 @@ def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]: LIBVIRT_NET_BASE = 131 -def host_networks() -> list[ipaddress.IPv4Network]: +def interface_de_ligne(ligne: str) -> str: + """L'interface que porte une ligne de « ip route » ou « ip -o addr ». + + Deux grammaires pour une seule question. Une route nomme son interface + après « dev » ; « ip -o -4 addr » la donne en deuxième champ, la ligne + commençant par son index (« 3: virbr0 inet … »). Le « -o » est ce qui + rend la seconde lisible : sans lui, l'interface est sur une ligne et ses + adresses sur les suivantes, plus rattachables l'une à l'autre. + """ + mots = ligne.split() + if "dev" in mots: + i = mots.index("dev") + if i + 1 < len(mots): + return mots[i + 1] + if len(mots) >= 2 and mots[0].rstrip(":").isdigit(): + # « eth0@if12 » sur une interface appairée : le nom est avant l'arobase. + return mots[1].rstrip(":").split("@")[0] + return "" + + +def host_networks(exclure_ponts=()) -> list[ipaddress.IPv4Network]: """Les réseaux IPv4 que l'hôte porte ou route déjà. Les deux, adresses ET routes : une interface peut router un réseau sans y porter d'adresse, et c'est la ROUTE qui décide où part un paquet. + + `exclure_ponts` écarte les interfaces dont le réseau EST celui qu'on + examine. Un réseau libvirt actif porte son propre /24 sur son pont, et le + route : compté comme « déjà pris par l'hôte », il se trouve en collision + avec lui-même. Le verdict était alors le même sur toute machine où le + réseau tournait, quel que soit son sous-réseau — d'où un déplacement là où + rien n'entrait en conflit, sous des VM qui perdaient leur passerelle. La + question posée est « quelqu'un D'AUTRE occupe-t-il ce /24 », et le pont du + réseau examiné n'est pas quelqu'un d'autre. """ + # Un nom vide n'exclut RIEN : network_bridge rend '' quand le XML est + # illisible, et le garder dans l'ensemble écarterait toute ligne dont + # l'interface ne se lit pas — soit, sur une grammaire inattendue, la + # totalité de ce que l'hôte occupe. + exclure = {pont for pont in (exclure_ponts or ()) if pont} vus: list[ipaddress.IPv4Network] = [] - for args in (["ip", "-4", "route", "show"], ["ip", "-4", "addr", "show"]): + commandes = ( + ["ip", "-4", "route", "show"], + # « -o » : une adresse par ligne, sinon l'interface est sur la ligne + # d'en-tête et l'adresse sur la suivante, donc plus rattachables. + ["ip", "-o", "-4", "addr", "show"], + ) + for args in commandes: try: out = subprocess.run( args, @@ -3141,11 +3182,14 @@ def host_networks() -> list[ipaddress.IPv4Network]: ).stdout except (OSError, subprocess.SubprocessError): continue - for cidr in re.findall(r"\b(\d+\.\d+\.\d+\.\d+/\d+)\b", out): - try: - vus.append(ipaddress.ip_network(cidr, strict=False)) - except ValueError: + for ligne in out.splitlines(): + if interface_de_ligne(ligne) in exclure: continue + for cidr in re.findall(r"\b(\d+\.\d+\.\d+\.\d+/\d+)\b", ligne): + try: + vus.append(ipaddress.ip_network(cidr, strict=False)) + except ValueError: + continue return vus @@ -3154,6 +3198,21 @@ def network_cidr(name: str, use_sudo: bool) -> str: return cidr_from_network_xml(virsh_out(["net-dumpxml", name], use_sudo)) +def bridge_from_network_xml(xml: str) -> str: + """Le pont déclaré par un XML de réseau libvirt (« virbr0 »), ou ''.""" + m = re.search(r"]*name='([^']+)'", xml) + return m.group(1) if m else "" + + +def network_bridge(name: str, use_sudo: bool) -> str: + """Le pont que ce réseau libvirt monte, ou ''. + + C'est l'interface qui portera son adresse .1 une fois le réseau démarré : + la seule que la recherche de collision doit s'interdire de compter. + """ + return bridge_from_network_xml(virsh_out(["net-dumpxml", name], use_sudo)) + + def cidr_from_network_xml(xml: str) -> str: """Le réseau déclaré par un XML de réseau libvirt, ou ''. @@ -3235,6 +3294,44 @@ def moved_network_xml(xml: str, ancien: str, nouveau: str) -> str: return xml.replace(f"{ancien}.", f"{nouveau}.") +@contextlib.contextmanager +def fichier_xml_temporaire(contenu: str, prefixe: str = "erplibre-net-"): + """Un XML posé sur le disque le temps d'un « virsh net-define », puis ôté. + + Créé par mkstemp, donc à un nom que personne ne peut prédire, et retiré à + la sortie même si virsh échoue. Un nom composé — d'un nom de réseau, par + exemple — est un chemin PRÉVISIBLE dans un répertoire où tout le monde + écrit : qui l'occupe d'avance par un lien symbolique choisit ce que root + va définir, et le laisser derrière donne à lire la configuration du parc. + + Le fichier est lisible par tous : virsh le lit sous root, et le fichier + d'un utilisateur non privilégié ne l'est pas toujours. Ce qu'il contient + ne sort pas de la définition du réseau, que « net-dumpxml » rend à qui + peut déjà joindre libvirt. + """ + fd, chemin = tempfile.mkstemp(prefix=prefixe, suffix=".xml") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(contenu) + os.chmod(chemin, 0o644) + yield chemin + finally: + try: + os.unlink(chemin) + except OSError: + pass + + +def define_network_xml(xml: str, runner: Runner) -> None: + """Redéfinit un réseau libvirt à partir de son XML.""" + with fichier_xml_temporaire(xml) as chemin: + runner.run( + ["virsh", "-c", LIBVIRT_URI, "net-define", chemin], + privileged=True, + check=False, + ) + + def move_network(name: str, cidr: str, collision: str, runner: Runner) -> str: """Redéfinit le réseau sur un /24 libre. Rend le nouveau préfixe, ou ''. @@ -3249,7 +3346,8 @@ def move_network(name: str, cidr: str, collision: str, runner: Runner) -> str: return "" ancien = cidr.rsplit(".", 1)[0].rsplit("/", 1)[0] libre = free_subnet( - host_networks() + libvirt_networks_cidrs(name, runner.use_sudo) + host_networks(exclure_ponts=[bridge_from_network_xml(xml)]) + + libvirt_networks_cidrs(name, runner.use_sudo) ) if not libre: print(" ⚠ aucun /24 libre en 192.168.x : rien n'est déplacé.") @@ -3263,16 +3361,7 @@ def move_network(name: str, cidr: str, collision: str, runner: Runner) -> str: " la passerelle de cette machine, qui perdrait son accès au réseau" " au prochain démarrage." ) - chemin = Path(tempfile.gettempdir()) / f"erplibre-net-{name}.xml" - chemin.write_text(moved_network_xml(xml, ancien, libre), encoding="utf-8") - # Lisible par root, qui exécute virsh : le fichier temporaire d'un - # utilisateur non privilégié ne l'est pas toujours. - chemin.chmod(0o644) - runner.run( - ["virsh", "-c", LIBVIRT_URI, "net-define", str(chemin)], - privileged=True, - check=False, - ) + define_network_xml(moved_network_xml(xml, ancien, libre), runner) return libre @@ -3315,7 +3404,10 @@ def ensure_network(name: str | None, runner: Runner) -> None: return active, autostart = network_state(name, runner.use_sudo) cidr = network_cidr(name, runner.use_sudo) - collision = network_collision(cidr, host_networks()) + # Son propre pont ne compte pas : un réseau démarré porte et route son /24 + # là, et se verrait sinon en collision avec lui-même sur toute machine. + pont = network_bridge(name, runner.use_sudo) + collision = network_collision(cidr, host_networks(exclure_ponts=[pont])) if collision: # Un réseau ACTIF en collision est la machine DÉJÀ privée de réseau : @@ -3337,7 +3429,9 @@ def ensure_network(name: str | None, runner: Runner) -> None: libre = move_network(name, cidr, collision, runner) if libre: cidr = network_cidr(name, runner.use_sudo) - collision = network_collision(cidr, host_networks()) + collision = network_collision( + cidr, host_networks(exclure_ponts=[pont]) + ) if active and autostart and not collision: print(f" Réseau libvirt '{name}' déjà actif.") diff --git a/test/test_qemu_host_network.py b/test/test_qemu_host_network.py index c4c0345..11e5498 100644 --- a/test/test_qemu_host_network.py +++ b/test/test_qemu_host_network.py @@ -22,7 +22,12 @@ Ce que ces tests gardent : 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. +- 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 @@ -153,6 +158,120 @@ class LaCollision(unittest.TestCase): 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 @@ -226,10 +345,6 @@ class LOrdreDesGestes(unittest.TestCase): side_effect=lambda *a: ( etats.pop(0) if len(etats) > 1 else etats[0] ), - ), mock.patch.object( - DQ.Path, "write_text" - ), mock.patch.object( - DQ.Path, "chmod" ): with redirect_stdout(io.StringIO()) as sortie: DQ.ensure_network("default", runner)