devis reseau : un segment non etiquete n'appartient a aucune fabric de switch

`segment_physique: true` retire la cle `vlan`. devis_reseau.py ecrivait `r['vlan']` en une
vingtaine d'endroits : KeyError, /api/devis-reseau en 500, et LA VUE RESEAU DU PANNEAU
RESTAIT VIDE — une panne a deux couches de sa cause.

Filtre a la source plutot que colmatage : devis_reseau definit son propre
`reseaux_de_fabric` qui ecarte les reseaux sans etiquette, et les dix appels y passent.
Colmater les vingt occurrences aurait laisse la vingt-et-unieme.

Le bloc de gestion d'un switch echappait au filtre (il lit son reseau directement). Sans
etiquette, aucune `interface VlanN` n'existe : l'adresse va sur l'interface de gestion
native du boitier. Le devis le DIT au lieu d'inventer une syntaxe.

Expose au passage que bifrost-3 et bifrost-4 declarent leur gestion sur un segment qui ne
les traverse pas, a des adresses qui n'ont jamais repondu.

Les quatre devis du panneau repassent. 42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-25 09:51:57 -04:00
parent a6713ffdcd
commit fcde343ef0
2 changed files with 69 additions and 11 deletions

View file

@ -1,5 +1,32 @@
# CHANGELOG — Set-OPS
## 2026-08-25 — La vue Réseau du panneau était vide : un `KeyError` deux couches plus bas
`segment_physique: true` — introduit le matin même pour dire qu'un réseau n'a pas
d'étiquette VLAN — retire la clé `vlan`. Or `devis_reseau.py` écrivait `r['vlan']` en une
vingtaine d'endroits.
Le premier segment non étiqueté a levé un `KeyError`, `/api/devis-reseau` a rendu une 500,
et **la vue Réseau de l'interface est restée vide**. Une panne qui ne ressemble à rien, à
deux couches de sa cause — c'est le genre qu'on cherche partout sauf là où elle est.
**Filtré à la source, une seule fois.** Colmater les vingt appels aurait laissé le
vingt-et-unième. `devis_reseau` définit désormais son propre `reseaux_de_fabric` qui écarte
les réseaux sans étiquette, et les dix appels y passent : ce fichier ne configure que des
commutateurs, et un commutateur n'a rien à dire d'un segment qui ne l'atteint pas.
Le bloc de gestion d'un switch échappait au filtre — il lit son réseau directement. Quand
ce réseau n'est pas étiqueté, il n'existe aucune `interface VlanN` : l'adresse appartient à
l'interface de gestion native du boîtier, dont le nom dépend du modèle. Le devis **le dit**
plutôt que d'inventer une syntaxe — un devis qui promet une commande fausse est pire qu'un
devis qui se tait.
*Au passage, ça expose une incohérence de la carte : `bifrost-3` et `bifrost-4` déclarent
leur adresse de gestion sur `management`, un segment qui ne les traverse pas. Ces deux
adresses sont d'ailleurs celles de l'ancien plan et n'ont jamais répondu.*
Les quatre devis du panneau — switches, frontière, SDN, pare-feu est-ouest — repassent.
## 2026-08-25 — Un renommage de rôle laissait son ancien fichier aux commandes
Mesuré sur `forge-01`, par l'agent invité — donc sans dépendre du réseau. Quatre

View file

@ -87,8 +87,20 @@ def _bloc_gestion(underlay: dict | None, nom_switch: str | None) -> list[str]:
if not r or not r.get("sous_reseau"):
return []
net = ipaddress.ip_network(r["sous_reseau"], strict=False)
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}",
f" ip address {h['ip']} {net.netmask}", " no shutdown"]
# LE SEGMENT DE GESTION PEUT NE PAS ETRE ETIQUETE (2026-08-25).
#
# Un switch declare son adresse sur un reseau ; si ce reseau est un SEGMENT PHYSIQUE
# (`segment_physique: true`, aucune encapsulation), il n'existe aucune `interface
# VlanN` a configurer — l'adresse appartient a l'interface de gestion native du
# boitier, dont le nom depend du modele. On le DIT plutot que d'inventer une syntaxe :
# un devis qui promet une commande fausse est pire qu'un devis qui se tait.
if r.get("vlan") is None:
out = [f"! gestion {nom_switch} : le segment '{r['nom']}' n'est pas etiquete —",
f"! {h['ip']} {net.netmask} va sur l'interface de gestion native du",
"! boitier (nom dependant du modele), pas sur une interface VLAN."]
else:
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}",
f" ip address {h['ip']} {net.netmask}", " no shutdown"]
gw = r.get("passerelle")
if gw:
porteur = next((x for x in underlay_mod.hotes(underlay)
@ -98,6 +110,25 @@ def _bloc_gestion(underlay: dict | None, nom_switch: str | None) -> list[str]:
return out
# UN SEGMENT SANS ETIQUETTE N'APPARTIENT A AUCUNE FABRIC DE COMMUTATEUR (2026-08-25).
#
# Un reseau peut se declarer `segment_physique: true` : le plan d'administration de
# Chezlepro arrive sur un PORT dedie de la frontiere, sans encapsulation, et ne traverse
# aucun switch — mesure du 2026-08-24, trois sondes muettes avec temoin positif. Il n'a
# donc pas de cle `vlan`.
#
# Ce devis emettait `r['vlan']` en une vingtaine d'endroits. Le premier segment non
# etiquete a leve un `KeyError`, l'API du panneau a rendu une 500, et la vue Reseau de
# l'interface est restee VIDE — une panne qui ne ressemble a rien, deux couches plus haut
# que sa cause. Colmater les vingt appels aurait laisse le vingt-et-unieme.
#
# On filtre donc A LA SOURCE, et une seule fois : ce fichier ne configure que des
# commutateurs, et un commutateur n'a rien a dire d'un segment qui ne l'atteint pas.
def reseaux_de_fabric(underlay: dict | None, fabric: str) -> list[dict]:
return [r for r in underlay_mod.reseaux_de_fabric(underlay, fabric)
if r.get("vlan") is not None]
def section_underlay(underlay: dict | None) -> list[str]:
"""VLANs + SVIs (si passerelle) de la fabric du routeur. Vide si aucun underlay.
@ -106,7 +137,7 @@ def section_underlay(underlay: dict | None) -> list[str]:
leurs VLAN sur ces switches serait faux, et les mettre dans leurs trunks aussi.
"""
fabric = underlay_mod.fabric_du_routeur(underlay)
reseaux = underlay_mod.reseaux_de_fabric(underlay, fabric)
reseaux = reseaux_de_fabric(underlay, fabric)
if not reseaux:
return []
out = [f"! ----- 0. Underlay — fabric '{fabric}' (cluster-global, hors tenant) -----"]
@ -152,7 +183,7 @@ def section_underlay(underlay: dict | None) -> list[str]:
for autre in underlay_mod.fabriques(underlay):
if autre == fabric:
continue
r_autres = underlay_mod.reseaux_de_fabric(underlay, autre)
r_autres = reseaux_de_fabric(underlay, autre)
out.append(f"! HORS PERIMETRE — fabric '{autre}' : "
+ ", ".join(f"{r['nom']} (VLAN {r['vlan']})" for r in r_autres))
out.append("! Portee par des switches distincts, sans cable commun avec celle-ci :")
@ -283,7 +314,7 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
if not r_nom:
return []
fabric = underlay_mod.fabric_du_routeur(underlay)
mgmt = next((r for r in underlay_mod.reseaux_de_fabric(underlay, fabric)
mgmt = next((r for r in reseaux_de_fabric(underlay, fabric)
if r.get("passerelle")), None)
# SOURCE UNIQUE : `switches_acces()` decide qui est un switch d'acces — reseau de
# management ET role `switch`. Une copie locale de ce filtre avait laisse les
@ -306,7 +337,7 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
# Le lien de frontiere N'EST PLUS reserve au routeur : depuis que les noeuds de
# sortie EVPN y ont une patte, un hyperviseur branche sur un switch d'acces doit
# pouvoir l'atteindre. Le sauter ici ferait tomber son trafic sortant en silence.
for r in underlay_mod.reseaux_de_fabric(underlay, fabric):
for r in reseaux_de_fabric(underlay, fabric):
out += [f"vlan {r['vlan']}", f" name {r['nom']}"]
if underlay_mod.routage_tenants(underlay) == "sdn":
out.append("! Aucun VLAN de tenant : ils vivent dans le SDN, pas sur le fil.")
@ -357,7 +388,7 @@ def reseaux_par_via(underlay: dict | None, role: str) -> dict[str, list[dict]]:
"""
par_via: dict[str, dict[str, dict]] = {}
fab = underlay_mod.fabric_du_routeur(underlay)
connus = {r.get("nom"): r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
connus = {r.get("nom"): r for r in reseaux_de_fabric(underlay, fab)
if r.get("vlan") is not None}
for h in underlay_mod.hotes(underlay):
if h.get("role") != role or not h.get("reseau"):
@ -382,7 +413,7 @@ def vlans_du_role(underlay: dict | None, role: str,
noms = {h.get("reseau") for h in underlay_mod.hotes(underlay)
if h.get("role") == role and h.get("reseau")}
fab = underlay_mod.fabric_du_routeur(underlay)
reseaux = [r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
reseaux = [r for r in reseaux_de_fabric(underlay, fab)
if r.get("nom") in noms and r.get("vlan") is not None]
if not reseaux and repli:
reseaux = [repli]
@ -486,7 +517,7 @@ def switch_route(underlay: dict | None) -> bool:
cas d'erreur — disparait d'elle-meme.
"""
r_nom = underlay_mod.routeur(underlay)
for r in underlay_mod.reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
for r in reseaux_de_fabric(underlay, underlay_mod.fabric_du_routeur(underlay)):
gw = r.get("passerelle")
if not gw:
continue
@ -736,7 +767,7 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
# perdraient leur propre adresse de gestion. Un port terminal porte ce que son hote
# declare ; un lien de fabric porte ce qui doit traverser.
vlans_fabric = [str(r["vlan"])
for r in underlay_mod.reseaux_de_fabric(
for r in reseaux_de_fabric(
underlay, underlay_mod.fabric_du_routeur(underlay))
if r.get("vlan") is not None]
# En SDN, les VLAN tenants n'existent pas sur le fil : le trunk ne porte que
@ -753,7 +784,7 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
# Aucun hyperviseur declare : on ne peut rien deriver de leurs rattachements.
# Emettre TOUT l'underlay est le repli sur, et il faut le dire — un devis muet
# ferait croire qu'il n'y a pas de port a configurer, ce qui est faux.
par_via_hyp = {"": [r for r in underlay_mod.reseaux_de_fabric(
par_via_hyp = {"": [r for r in reseaux_de_fabric(
underlay, underlay_mod.fabric_du_routeur(underlay)) if r.get("vlan") is not None]}
out += ["! Aucun hote `role: hyperviseur` declare dans underlay.yml : ce port porte",
"! TOUT l'underlay, faute de mieux. Les declarer rendrait ce bloc precis —",