[FIX] qemu réseau : ne plus compter le pont d'un réseau contre lui-même

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
This commit is contained in:
Mathieu Benoit 2026-09-04 03:31:08 -04:00
parent 416a0c8631
commit 82c651f1df
5 changed files with 237 additions and 24 deletions

View file

@ -274,6 +274,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
<!-- [en] -->
- 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
<!-- [fr] -->
- 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

View file

@ -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

View file

@ -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

View file

@ -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"<bridge[^>]*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.")

View file

@ -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("<network/>"))
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("<network/>") 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("<network/>") as un:
with DQ.fichier_xml_temporaire("<network/>") 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("<network/>") 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)