diff --git a/CHANGELOG.md b/CHANGELOG.md index e1e4866..2cce2b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index fd36583..fb0041e 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -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 —",