devis switch : les rayons de l'étoile ne sont pas des ports de bord

L'ajout du spanning-tree venait de rendre dangereuse une imprécision de
titre. La section 4 s'appelait « Trunk vers Proxmox + inter-switch » et
n'émettait qu'un port, que la section 6 déclarait en bord de réseau.
Réutiliser ce placeholder pour les rayons revenait à mettre portfast sur les
liens qui portent les BPDU — désactiver la protection anti-boucle là où elle
sert.

Les rayons sont dérivés et émis à part (4c côté routeur, B3a côté accès), un
par switch d'accès, marqués comme non-bord. La section 4 ne désigne plus que
les hyperviseurs ; la partie B distingue sa montante de son trunk terminal.

`underlay.switches_acces()` devient la source unique du « qui est un switch
d'accès » — pour leur devis ET pour les rayons côté routeur.

Vérifié : 3 commandes portfast avec `stp` déclaré, 0 sans lui, 0 sur un
rayon.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-01 21:31:31 -04:00
parent 8f61721cb3
commit 92d31c0406
3 changed files with 80 additions and 4 deletions

View file

@ -1,5 +1,26 @@
# CHANGELOG — Set-OPS
## 2026-08-01 (suite 9) — rayons de l'étoile séparés des ports terminaux
L'ajout du spanning-tree venait de rendre dangereuse une imprécision qui, jusque-là, n'était
qu'un titre approximatif. La section 4 s'appelait « Trunk vers Proxmox **+ inter-switch** »
et n'émettait qu'un port, que la section 6 déclarait en bord de réseau. Réutiliser ce
placeholder pour les rayons vers les switches d'accès revenait à mettre `portfast` sur les
liens qui portent les BPDU — c'est-à-dire à désactiver la protection anti-boucle exactement
là où elle sert.
Les rayons sont désormais **dérivés** et émis à part (section 4c côté routeur, B3a côté
accès), un par switch d'accès, avec l'avertissement qu'ils ne sont pas des ports de bord.
La section 4 ne désigne plus que les hyperviseurs, et la partie B distingue sa montante
(`B3a`) de son trunk terminal (`B3b`).
`underlay.switches_acces()` devient la source unique du « qui est un switch d'accès » —
utilisée pour leur devis **et** pour les rayons côté routeur : les deux ne peuvent pas
diverger.
Vérifié : trois commandes `portfast` émises avec `stp` déclaré, **aucune** sans lui, et
**aucune** sur un rayon.
## 2026-08-01 (suite 8) — spanning-tree dérivé de la topologie déclarée
### Ajouté — `underlay.stp` et les sections 6 / B4

View file

@ -102,6 +102,33 @@ def section_underlay(underlay: dict | None) -> list[str]:
return out
def port_vers(nom_hote: str) -> str:
"""Marqueur de port physique vers un equipement nomme."""
return f"<PORT-VERS-{nom_hote.upper()}>"
def section_rayons(underlay: dict | None, vlans: str) -> list[str]:
"""Les rayons de l'etoile : un trunk du routeur vers chaque switch d'acces.
Ils sont emis A PART du trunk vers les hyperviseurs parce qu'ils n'ont pas le meme
statut vis-a-vis du spanning-tree : un rayon porte les BPDU de la fabric et ne doit
JAMAIS etre declare en bord de reseau, sinon la protection tombe la ou elle sert.
"""
acces = underlay_mod.switches_acces(underlay)
if not acces:
return []
out = ["! ----- 4c. Rayons de l'etoile (vers les switches d'acces) -----",
"! /!\\ CES PORTS NE SONT PAS DES PORTS DE BORD. Ils portent les BPDU de la",
"! fabric : y appliquer `portfast` desactiverait la protection anti-boucle",
"! exactement la ou elle sert. La section 6 ne les declare volontairement pas."]
for h in acces:
out += [f"interface {port_vers(h['nom'])}",
f" description rayon vers {h['nom']}",
" switchport mode trunk",
f" switchport trunk allowed vlan add {vlans}"]
return out
def _stp_lignes(dialecte: str, mode: str, priorite: int) -> list[str]:
"""Mode + priorite de pont, dans la forme du dialecte.
@ -210,11 +237,19 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
f"ip default-gateway {mgmt['passerelle']} ! le routeur {r_nom}"]
else:
out.append("! Aucun reseau underlay avec passerelle : gestion a definir a la main.")
out += ["!", "! ----- B3. Trunks -----",
"! Vers les hyperviseurs ET vers le routeur : memes VLANs etiquetes.",
"interface <PORT-TRUNK>",
out += ["!", "! ----- B3a. Montante vers le routeur (rayon de l'etoile) -----",
"! /!\\ PAS un port de bord : il porte les BPDU. Ne jamais y mettre portfast.",
f"interface {port_vers(r_nom)}",
f" description montante vers {r_nom}",
" switchport mode trunk",
f" switchport trunk allowed vlan add {vlans}",
"!",
"! ----- B3b. Trunk vers les hyperviseurs -----",
"! Port terminal : rien derriere lui ne participe au spanning-tree.",
"interface <PORT-VERS-PROXMOX>",
" switchport mode trunk",
f" switchport trunk allowed vlan add {vlans}",
" spanning-tree portfast trunk" if underlay_mod.stp(underlay) else "!",
"!"]
s = underlay_mod.stp(underlay)
if s:
@ -391,7 +426,9 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
out.append(f" deny ip {reseau} {m} {u_reseau} {u_m}")
out += remarque(dialecte, "Reste (Internet / inter-tenant controle) -> passerelle OPNsense")
out.append(f" permit ip {reseau} {m} any")
out += ["!", "! ----- 4. Trunk vers Proxmox + inter-switch (a adapter) -----"]
out += ["!", "! ----- 4. Trunk vers les hyperviseurs -----",
"! Ports TERMINAUX : rien derriere eux ne participe au spanning-tree.",
"! Les liens vers les autres switches sont en section 4c, pas ici."]
transit = underlay_mod.reseau_transit(underlay)
# Le VLAN de transit ne va PAS vers Proxmox : aucun hyperviseur n'a d'interface
# dessus. Il sort par le port de la frontiere (ci-dessous), et lui seul.
@ -409,6 +446,7 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
out.append(" switchport mode trunk")
out.append(f" switchport trunk allowed vlan add {vlans}")
out += ["!"] + section_frontiere(transit)
out += ["!"] + section_rayons(underlay, ",".join(vlans_underlay + vlans_tenants))
out += ["!"] + section_routes(underlay)
out += section_stp(underlay, dialecte, ["<PORT-VERS-PROXMOX>", "<PORT-VERS-FRONTIERE>"])
out += ["end", "write memory"]

View file

@ -107,6 +107,23 @@ def routeur(underlay: dict | None) -> str | None:
return (underlay or {}).get("routeur") or None
def switches_acces(underlay: dict | None) -> list[dict]:
"""Les switches d'acces : hotes du reseau de management, hors routeur.
Source unique du « qui est un switch d'acces » — utilisee pour leur devis (partie B)
ET pour les rayons de l'etoile emis cote routeur. Les deux ne peuvent pas diverger.
"""
r_nom = routeur(underlay)
if not r_nom:
return []
fabric = fabric_du_routeur(underlay)
mgmt = next((r for r in reseaux_de_fabric(underlay, fabric) if r.get("passerelle")), None)
if not mgmt:
return []
return [h for h in hotes(underlay)
if h.get("nom") != r_nom and h.get("reseau") == mgmt["nom"]]
def reseau_transit(underlay: dict | None) -> dict | None:
"""Le reseau de TRANSIT vers la frontiere nord/sud, s'il est declare.