[FIX] qemu setup-host : ne plus priver l'hôte de réseau au redémarrage
Le réseau « default » de libvirt sert 192.168.122.0/24, et toute VM déployée par ce dépôt y vit : son pont prendrait la première adresse du /24, celle de sa passerelle. virsh refuse ce démarrage tant que la route est là ; au démarrage, libvirtd monte ses réseaux avant le bail DHCP, et plus rien ne la signale. L'autostart était armé même après un net-start refusé, d'où un hôte sans réseau au redémarrage suivant. Il ne s'arme plus qu'en l'absence de collision, le réseau est déplacé par redéfinition — ni pont ni module du noyau — et un actif en collision est abattu. L'état, cherché en anglais quand virsh traduit, se lisait toujours éteint : LC_ALL=C. Vérifié sur un hôte en collision, puis 21 tests. --- EN --- libvirt's `default` network serves 192.168.122.0/24, and every VM this repository deploys lives there: its bridge would take the /24's first address, which is that machine's gateway. virsh refuses such a start while the route is there; at boot, libvirtd raises its networks before the DHCP lease, and nothing signals the collision. Autostart was armed even after a refused net-start, hence a host with no network at the next boot. It is armed only where no collision remains, the network is moved by redefinition — no bridge, no kernel module — and an active collision is torn down. State, read in English where virsh translates, always read as off: LC_ALL=C. Checked on a colliding host, then 21 tests. Assisted-by: Claude Opus 5
This commit is contained in:
parent
44dc08c06c
commit
4b320dc1de
5 changed files with 610 additions and 19 deletions
|
|
@ -273,6 +273,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
## Corrigé
|
||||
<!-- [en] -->
|
||||
|
||||
- `--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
|
||||
- The QEMU menu goes through the `libvirt` group rather than sudo, which added no right and asked for a password at every entry; membership is settled by TRYING, never by reading /etc/group. The libvirt URI is named explicitly: without `--connect`, a non-root virsh targets `qemu:///session`, a SEPARATE hypervisor where no system VM exists, and `list --all` returns an empty list with no error — root's default URI had masked the omission
|
||||
- System tools launched from the menu no longer inherit the venv at the head of their PATH. A Python tool bootstrapped by `env python3` started in an interpreter without the distribution's modules and died on `No module named 'gi'`
|
||||
|
|
@ -299,6 +301,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
- The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation
|
||||
<!-- [fr] -->
|
||||
|
||||
- `--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
|
||||
- Le menu QEMU passe par le groupe « libvirt » plutôt que par sudo, qui n'ajoutait aucun droit et réclamait un mot de passe à chaque entrée ; l'appartenance se tranche en ESSAYANT, jamais en lisant /etc/group. L'URI libvirt est nommée explicitement : sans « --connect », un virsh non root vise « qemu:///session », un hyperviseur SÉPARÉ où aucune VM du système n'existe, et « list --all » y rend une liste vide sans erreur — l'URI par défaut de root masquait l'omission
|
||||
- Les outils système lancés depuis le menu n'héritent plus du venv en tête de leur PATH. Un outil écrit en Python et amorcé par « env python3 » démarrait dans un interpréteur privé des modules de la distribution et sortait sur « No module named 'gi' »
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
|
|||
|
||||
## Corrigé
|
||||
|
||||
- `--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
|
||||
- Le menu QEMU passe par le groupe « libvirt » plutôt que par sudo, qui n'ajoutait aucun droit et réclamait un mot de passe à chaque entrée ; l'appartenance se tranche en ESSAYANT, jamais en lisant /etc/group. L'URI libvirt est nommée explicitement : sans « --connect », un virsh non root vise « qemu:///session », un hyperviseur SÉPARÉ où aucune VM du système n'existe, et « list --all » y rend une liste vide sans erreur — l'URI par défaut de root masquait l'omission
|
||||
- Les outils système lancés depuis le menu n'héritent plus du venv en tête de leur PATH. Un outil écrit en Python et amorcé par « env python3 » démarrait dans un interpréteur privé des modules de la distribution et sortait sur « No module named 'gi' »
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ Recreating the virtual environment, use installation guide from tool `make`.
|
|||
|
||||
## Fixed
|
||||
|
||||
- `--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
|
||||
- The QEMU menu goes through the `libvirt` group rather than sudo, which added no right and asked for a password at every entry; membership is settled by TRYING, never by reading /etc/group. The libvirt URI is named explicitly: without `--connect`, a non-root virsh targets `qemu:///session`, a SEPARATE hypervisor where no system VM exists, and `list --all` returns an empty list with no error — root's default URI had masked the omission
|
||||
- System tools launched from the menu no longer inherit the venv at the head of their PATH. A Python tool bootstrapped by `env python3` started in an interpreter without the distribution's modules and died on `No module named 'gi'`
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import getpass
|
|||
import grp
|
||||
import gzip
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import zlib
|
||||
import os
|
||||
import re
|
||||
|
|
@ -1149,10 +1150,15 @@ def setup_host(
|
|||
ok = libvirt_ready(runner.use_sudo)
|
||||
print(f" hyperviseur qemu:///system : {'OK' if ok else 'INJOIGNABLE'}")
|
||||
active, autostart = network_state("default", runner.use_sudo)
|
||||
# Le sous-réseau est dit ICI parce que c'est de lui que les VM tireront
|
||||
# leur adresse : déplacé, il n'est plus celui que la documentation de
|
||||
# libvirt fait attendre.
|
||||
cidr = network_cidr("default", runner.use_sudo)
|
||||
print(
|
||||
f" réseau libvirt « default » : "
|
||||
f"{'actif' if active else 'INACTIF'}"
|
||||
f" / {'autostart' if autostart else 'PAS autostart'}"
|
||||
+ (f" / {cidr}" if cidr else "")
|
||||
)
|
||||
if not active and stale:
|
||||
# Le réseau est déjà « autostart » : après le redémarrage, libvirt le
|
||||
|
|
@ -2955,32 +2961,229 @@ def network_name(network_arg: str) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def c_locale_env() -> dict[str, str]:
|
||||
"""L'environnement des commandes dont on PARSE la sortie.
|
||||
|
||||
virsh TRADUIT ses étiquettes : sous une locale française, « net-info »
|
||||
répond « Actif : non » et « Démarrage automatique : oui », où un motif
|
||||
« Active: yes » lit toujours faux. Un réseau démarré passait alors pour
|
||||
éteint, l'hôte était déclaré « pas prêt » quel que soit son état, et le
|
||||
message invitait à redémarrer pour rien. Le même geste, pour la même
|
||||
raison, est dans script/todo/qemu_manage.py.
|
||||
"""
|
||||
return {**os.environ, "LC_ALL": "C", "LANG": "C"}
|
||||
|
||||
|
||||
def virsh_out(args: list[str], use_sudo: bool, timeout: int = 20) -> str:
|
||||
"""La sortie d'un virsh, en anglais, ou '' s'il n'a rien pu dire."""
|
||||
cmd = (["sudo"] if use_sudo else []) + ["virsh", "-c", LIBVIRT_URI, *args]
|
||||
try:
|
||||
res = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=c_locale_env(),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return res.stdout if res.returncode == 0 else ""
|
||||
|
||||
|
||||
def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]:
|
||||
"""(actif, autostart) d'un réseau libvirt, via « virsh net-info »."""
|
||||
cmd = (["sudo"] if use_sudo else []) + [
|
||||
"virsh",
|
||||
"-c",
|
||||
LIBVIRT_URI,
|
||||
"net-info",
|
||||
name,
|
||||
]
|
||||
try:
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return (False, False)
|
||||
active = bool(re.search(r"Active:\s*yes", res.stdout, re.IGNORECASE))
|
||||
autostart = bool(re.search(r"Autostart:\s*yes", res.stdout, re.IGNORECASE))
|
||||
out = virsh_out(["net-info", name], use_sudo, timeout=15)
|
||||
active = bool(re.search(r"Active:\s*yes", out, re.IGNORECASE))
|
||||
autostart = bool(re.search(r"Autostart:\s*yes", out, re.IGNORECASE))
|
||||
return (active, autostart)
|
||||
|
||||
|
||||
# Le troisième octet où commence la recherche d'un /24 libre. 122 est celui du
|
||||
# « default » de libvirt, 123 celui de beaucoup d'installations toutes faites :
|
||||
# partir au-dessus des deux coûte un octet. long_test/deep_qemu.py part du même
|
||||
# nombre, en le déduisant de la profondeur là où il ne peut pas sonder.
|
||||
LIBVIRT_NET_BASE = 131
|
||||
|
||||
|
||||
def host_networks() -> 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.
|
||||
"""
|
||||
vus: list[ipaddress.IPv4Network] = []
|
||||
for args in (["ip", "-4", "route", "show"], ["ip", "-4", "addr", "show"]):
|
||||
try:
|
||||
out = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
env=c_locale_env(),
|
||||
).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:
|
||||
continue
|
||||
return vus
|
||||
|
||||
|
||||
def network_cidr(name: str, use_sudo: bool) -> str:
|
||||
"""Le réseau servi par un réseau libvirt (« 192.168.122.0/24 »), ou ''."""
|
||||
return cidr_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 ''.
|
||||
|
||||
Le masque est écrit en quatre octets (« 255.255.255.0 ») et non en
|
||||
longueur de préfixe : ipaddress accepte les deux formes, le reste du code
|
||||
n'en manipule qu'une.
|
||||
"""
|
||||
m = re.search(r"<ip address='([\d.]+)' netmask='([\d.]+)'", xml)
|
||||
if not m:
|
||||
return ""
|
||||
try:
|
||||
return str(
|
||||
ipaddress.ip_network(f"{m.group(1)}/{m.group(2)}", strict=False)
|
||||
)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def network_collision(cidr: str, hote: list) -> str:
|
||||
"""Ce que l'hôte route déjà dans ce réseau, ou '' s'il est libre.
|
||||
|
||||
Le « default » de libvirt sert 192.168.122.0/24. Une machine qui VIT dans
|
||||
ce réseau — toute VM déployée par ce dépôt en sert une — ne peut pas le
|
||||
servir à son tour : l'adresse .1 du pont est celle de sa propre
|
||||
passerelle. virsh refuse d'ailleurs le démarrage (« Network is already in
|
||||
use by interface eth0 »), 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, et l'hôte perd sa
|
||||
passerelle au profit du pont.
|
||||
"""
|
||||
if not cidr:
|
||||
return ""
|
||||
try:
|
||||
mien = ipaddress.ip_network(cidr, strict=False)
|
||||
except ValueError:
|
||||
return ""
|
||||
for autre in hote:
|
||||
if mien.overlaps(autre):
|
||||
return str(autre)
|
||||
return ""
|
||||
|
||||
|
||||
def libvirt_networks_cidrs(name: str, use_sudo: bool) -> list:
|
||||
"""Les réseaux servis par les AUTRES réseaux libvirt de l'hôte.
|
||||
|
||||
Un réseau inactif ne route rien : il n'apparaît donc pas dans host_networks
|
||||
et rien n'empêcherait de lui reprendre son sous-réseau, pour buter dessus
|
||||
au premier démarrage des deux.
|
||||
"""
|
||||
out = []
|
||||
for autre in virsh_out(["net-list", "--all", "--name"], use_sudo).split():
|
||||
if autre == name:
|
||||
continue
|
||||
cidr = network_cidr(autre, use_sudo)
|
||||
if cidr:
|
||||
try:
|
||||
out.append(ipaddress.ip_network(cidr, strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def free_subnet(pris: list) -> str:
|
||||
"""Un préfixe « 192.168.X » qu'aucun réseau connu ne recouvre, ou ''."""
|
||||
for troisieme in range(LIBVIRT_NET_BASE, 255):
|
||||
candidat = ipaddress.ip_network(f"192.168.{troisieme}.0/24")
|
||||
if not any(candidat.overlaps(autre) for autre in pris):
|
||||
return f"192.168.{troisieme}"
|
||||
return ""
|
||||
|
||||
|
||||
def moved_network_xml(xml: str, ancien: str, nouveau: str) -> str:
|
||||
"""Le XML du réseau, son sous-réseau déplacé, le reste INTACT.
|
||||
|
||||
Réécrit plutôt que reconstruit : l'UUID, le nom du pont et son adresse
|
||||
MAC restent en place, si bien que le réseau garde son identité au lieu
|
||||
d'en prendre une nouvelle — et les domaines qui le nomment le retrouvent.
|
||||
"""
|
||||
return xml.replace(f"{ancien}.", f"{nouveau}.")
|
||||
|
||||
|
||||
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 ''.
|
||||
|
||||
Une REDÉFINITION et non un démarrage : elle ne demande ni pont ni module
|
||||
du noyau, donc elle réussit même sur une machine dont le noyau a été
|
||||
remplacé depuis le démarrage. C'est ce qui permet d'armer l'autostart
|
||||
avant le redémarrage sans armer une collision.
|
||||
"""
|
||||
xml = virsh_out(["net-dumpxml", name], runner.use_sudo)
|
||||
if not xml:
|
||||
print(f" ⚠ réseau « {name} » illisible : rien n'est déplacé.")
|
||||
return ""
|
||||
ancien = cidr.rsplit(".", 1)[0].rsplit("/", 1)[0]
|
||||
libre = free_subnet(
|
||||
host_networks() + libvirt_networks_cidrs(name, runner.use_sudo)
|
||||
)
|
||||
if not libre:
|
||||
print(" ⚠ aucun /24 libre en 192.168.x : rien n'est déplacé.")
|
||||
return ""
|
||||
print(
|
||||
f" Le réseau « {name} » sert {cidr}, que l'hôte route déjà"
|
||||
f" ({collision})."
|
||||
)
|
||||
print(
|
||||
f" Déplacé sur {libre}.0/24 : sinon le pont prendrait l'adresse de"
|
||||
" 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,
|
||||
)
|
||||
return libre
|
||||
|
||||
|
||||
def ensure_network(name: str | None, runner: Runner) -> None:
|
||||
"""Active le réseau libvirt si besoin. On vérifie d'abord son état pour
|
||||
éviter le faux « error: network is already active » de virsh quand il
|
||||
tourne déjà (message purement bruyant, sans conséquence)."""
|
||||
"""Rend le réseau libvirt utilisable, et SÛR pour le prochain démarrage.
|
||||
|
||||
Trois gestes, et leur ordre est tout :
|
||||
|
||||
0. ABATTRE un réseau actif qui recouvre une route de l'hôte : c'est la
|
||||
machine déjà cassée, et son pont porte l'adresse de la passerelle.
|
||||
1. DÉPLACER le réseau s'il recouvre ce que l'hôte route déjà. Le
|
||||
« 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. La redéfinition ne demande ni pont ni module
|
||||
du noyau, donc elle passe même quand le démarrage, lui, ne passe pas.
|
||||
2. DÉMARRER, puis relire l'état plutôt que de croire le code de retour.
|
||||
3. ARMER l'autostart SEULEMENT si le sous-réseau est libre de collision.
|
||||
C'est le geste qui cassait la machine : armé sur un réseau en
|
||||
collision, libvirtd le monte au démarrage AVANT que le bail DHCP de
|
||||
l'hôte ne soit là — plus aucune route ne signale la collision, virbr0
|
||||
prend l'adresse de la passerelle, et l'hôte n'a plus de réseau. Un
|
||||
réseau qui n'a pas pu démarrer faute de modules du noyau reste, lui,
|
||||
armé : c'est ce qui rend l'hôte utilisable en UN seul redémarrage.
|
||||
"""
|
||||
if not name:
|
||||
return
|
||||
if runner.dry_run:
|
||||
print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire")
|
||||
print(f" [dry-run] réseau libvirt '{name}' vérifié et activé")
|
||||
print(" [dry-run] sous-réseau déplacé s'il entre en collision")
|
||||
runner.run(
|
||||
["virsh", "-c", LIBVIRT_URI, "net-start", name],
|
||||
privileged=True,
|
||||
|
|
@ -2993,16 +3196,67 @@ def ensure_network(name: str | None, runner: Runner) -> None:
|
|||
)
|
||||
return
|
||||
active, autostart = network_state(name, runner.use_sudo)
|
||||
if active and autostart:
|
||||
cidr = network_cidr(name, runner.use_sudo)
|
||||
collision = network_collision(cidr, host_networks())
|
||||
|
||||
if collision:
|
||||
# Un réseau ACTIF en collision est la machine DÉJÀ privée de réseau :
|
||||
# libvirt refuse ce démarrage quand la route est là, donc le pont a
|
||||
# pris l'adresse de la passerelle AVANT elle, au démarrage. L'abattre
|
||||
# est ce qui rend l'accès au réseau à l'hôte, et tout de suite.
|
||||
if active:
|
||||
print(
|
||||
f" ⚠ le réseau « {name} » est actif SUR {collision}, que"
|
||||
" cette machine route : son pont porte l'adresse de la"
|
||||
" passerelle. Arrêté pour rendre l'accès au réseau."
|
||||
)
|
||||
runner.run(
|
||||
["virsh", "-c", LIBVIRT_URI, "net-destroy", name],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
active = False
|
||||
libre = move_network(name, cidr, collision, runner)
|
||||
if libre:
|
||||
cidr = network_cidr(name, runner.use_sudo)
|
||||
collision = network_collision(cidr, host_networks())
|
||||
|
||||
if active and autostart and not collision:
|
||||
print(f" Réseau libvirt '{name}' déjà actif.")
|
||||
return
|
||||
print(f" Configuration du réseau libvirt '{name}'…")
|
||||
|
||||
if not active:
|
||||
print(f" Configuration du réseau libvirt '{name}'…")
|
||||
runner.run(
|
||||
["virsh", "-c", LIBVIRT_URI, "net-start", name],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
active, autostart = network_state(name, runner.use_sudo)
|
||||
|
||||
if collision:
|
||||
# Rien n'a pu le rendre sûr : le désarmer est la seule chose qui
|
||||
# protège le prochain démarrage.
|
||||
if autostart:
|
||||
print(
|
||||
f" ⚠ autostart RETIRÉ au réseau « {name} » : il recouvre"
|
||||
f" {collision}, et le monter au démarrage priverait cette"
|
||||
" machine de sa passerelle."
|
||||
)
|
||||
runner.run(
|
||||
[
|
||||
"virsh",
|
||||
"-c",
|
||||
LIBVIRT_URI,
|
||||
"net-autostart",
|
||||
"--disable",
|
||||
name,
|
||||
],
|
||||
privileged=True,
|
||||
check=False,
|
||||
)
|
||||
return
|
||||
|
||||
if not autostart:
|
||||
runner.run(
|
||||
["virsh", "-c", LIBVIRT_URI, "net-autostart", name],
|
||||
|
|
|
|||
329
test/test_qemu_host_network.py
Normal file
329
test/test_qemu_host_network.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
#!/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.
|
||||
"""
|
||||
|
||||
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 = """<network>
|
||||
<name>default</name>
|
||||
<uuid>2cf1310d-8812-456e-b761-9867efe4af8d</uuid>
|
||||
<forward mode='nat'/>
|
||||
<bridge name='virbr0' stp='on' delay='0'/>
|
||||
<mac address='52:54:00:6b:6d:2f'/>
|
||||
<ip address='192.168.122.1' netmask='255.255.255.0'>
|
||||
<dhcp>
|
||||
<range start='192.168.122.2' end='192.168.122.254'/>
|
||||
</dhcp>
|
||||
</ip>
|
||||
</network>
|
||||
"""
|
||||
|
||||
# « 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("<network/>"))
|
||||
|
||||
|
||||
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 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("<bridge name='virbr0'", bouge)
|
||||
self.assertIn("52:54:00:6b:6d:2f", bouge)
|
||||
|
||||
def test_the_whole_subnet_moves(self):
|
||||
bouge = DQ.moved_network_xml(XML_DEFAUT, "192.168.122", "192.168.131")
|
||||
self.assertNotIn("192.168.122.", bouge)
|
||||
self.assertIn("<ip address='192.168.131.1'", bouge)
|
||||
self.assertIn("start='192.168.131.2'", bouge)
|
||||
self.assertIn("end='192.168.131.254'", bouge)
|
||||
self.assertEqual("192.168.131.0/24", DQ.cidr_from_network_xml(bouge))
|
||||
|
||||
|
||||
class LOrdreDesGestes(unittest.TestCase):
|
||||
"""ensure_network : déplacer, démarrer, puis armer — et pas autrement."""
|
||||
|
||||
def _lancer(self, cidrs, hote, actif_apres, etat_initial):
|
||||
"""Rend les commandes virsh lancées, dans l'ordre.
|
||||
|
||||
`cidrs` : ce que « default » sert, avant puis après un déplacement.
|
||||
`hote` : ce que la machine route. `actif_apres` : le réseau démarre-t-il ?
|
||||
"""
|
||||
runner = mock.MagicMock()
|
||||
runner.dry_run = False
|
||||
runner.use_sudo = False
|
||||
lancees = []
|
||||
runner.run.side_effect = lambda cmd, **kw: lancees.append(
|
||||
[x for x in cmd if x not in ("virsh", "-c", DQ.LIBVIRT_URI)]
|
||||
)
|
||||
suite_cidr = list(cidrs)
|
||||
etats = [etat_initial, (actif_apres, etat_initial[1])]
|
||||
|
||||
with mock.patch.object(
|
||||
DQ,
|
||||
"network_cidr",
|
||||
side_effect=lambda *a: (
|
||||
suite_cidr.pop(0) if len(suite_cidr) > 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]
|
||||
),
|
||||
), 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)
|
||||
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()
|
||||
Loading…
Reference in a new issue