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 # 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 ## 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 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"): if not r or not r.get("sous_reseau"):
return [] return []
net = ipaddress.ip_network(r["sous_reseau"], strict=False) net = ipaddress.ip_network(r["sous_reseau"], strict=False)
out = [f"interface Vlan{r['vlan']}", f" description gestion {nom_switch}", # LE SEGMENT DE GESTION PEUT NE PAS ETRE ETIQUETE (2026-08-25).
f" ip address {h['ip']} {net.netmask}", " no shutdown"] #
# 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") gw = r.get("passerelle")
if gw: if gw:
porteur = next((x for x in underlay_mod.hotes(underlay) 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 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]: def section_underlay(underlay: dict | None) -> list[str]:
"""VLANs + SVIs (si passerelle) de la fabric du routeur. Vide si aucun underlay. """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. leurs VLAN sur ces switches serait faux, et les mettre dans leurs trunks aussi.
""" """
fabric = underlay_mod.fabric_du_routeur(underlay) fabric = underlay_mod.fabric_du_routeur(underlay)
reseaux = underlay_mod.reseaux_de_fabric(underlay, fabric) reseaux = reseaux_de_fabric(underlay, fabric)
if not reseaux: if not reseaux:
return [] return []
out = [f"! ----- 0. Underlay — fabric '{fabric}' (cluster-global, hors tenant) -----"] 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): for autre in underlay_mod.fabriques(underlay):
if autre == fabric: if autre == fabric:
continue continue
r_autres = underlay_mod.reseaux_de_fabric(underlay, autre) r_autres = reseaux_de_fabric(underlay, autre)
out.append(f"! HORS PERIMETRE — fabric '{autre}' : " out.append(f"! HORS PERIMETRE — fabric '{autre}' : "
+ ", ".join(f"{r['nom']} (VLAN {r['vlan']})" for r in r_autres)) + ", ".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 :") 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: if not r_nom:
return [] return []
fabric = underlay_mod.fabric_du_routeur(underlay) 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) if r.get("passerelle")), None)
# SOURCE UNIQUE : `switches_acces()` decide qui est un switch d'acces — reseau de # 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 # 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 # 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 # 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. # 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']}"] out += [f"vlan {r['vlan']}", f" name {r['nom']}"]
if underlay_mod.routage_tenants(underlay) == "sdn": if underlay_mod.routage_tenants(underlay) == "sdn":
out.append("! Aucun VLAN de tenant : ils vivent dans le SDN, pas sur le fil.") 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]] = {} par_via: dict[str, dict[str, dict]] = {}
fab = underlay_mod.fabric_du_routeur(underlay) 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} if r.get("vlan") is not None}
for h in underlay_mod.hotes(underlay): for h in underlay_mod.hotes(underlay):
if h.get("role") != role or not h.get("reseau"): 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) noms = {h.get("reseau") for h in underlay_mod.hotes(underlay)
if h.get("role") == role and h.get("reseau")} if h.get("role") == role and h.get("reseau")}
fab = underlay_mod.fabric_du_routeur(underlay) 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 r.get("nom") in noms and r.get("vlan") is not None]
if not reseaux and repli: if not reseaux and repli:
reseaux = [repli] reseaux = [repli]
@ -486,7 +517,7 @@ def switch_route(underlay: dict | None) -> bool:
cas d'erreur — disparait d'elle-meme. cas d'erreur — disparait d'elle-meme.
""" """
r_nom = underlay_mod.routeur(underlay) 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") gw = r.get("passerelle")
if not gw: if not gw:
continue 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 # perdraient leur propre adresse de gestion. Un port terminal porte ce que son hote
# declare ; un lien de fabric porte ce qui doit traverser. # declare ; un lien de fabric porte ce qui doit traverser.
vlans_fabric = [str(r["vlan"]) 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)) underlay, underlay_mod.fabric_du_routeur(underlay))
if r.get("vlan") is not None] if r.get("vlan") is not None]
# En SDN, les VLAN tenants n'existent pas sur le fil : le trunk ne porte que # 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. # 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 # 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. # 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]} 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", out += ["! Aucun hote `role: hyperviseur` declare dans underlay.yml : ce port porte",
"! TOUT l'underlay, faute de mieux. Les declarer rendrait ce bloc precis —", "! TOUT l'underlay, faute de mieux. Les declarer rendrait ce bloc precis —",