underlay : les ports physiques entrent dans le modèle

Les noms de ports n'étaient modélisés nulle part — des marqueurs littéraux
dans le générateur, remplacés à la main dans la sortie et perdus à chaque
régénération. Seul endroit du devis où le travail était refait à répétition.

Ils se déclarent par équipement sous quatre clefs correspondant aux quatre
natures de lien : `hyperviseurs` et `frontiere` (terminaux, portfast),
`rayons` (côté routeur), `montante` (côté switch d'accès).

Le devis émet les vrais ports, y compris PLUSIEURS vers les hyperviseurs —
il n'en supposait qu'un, alors que le cluster en compte trois. Non déclarés,
les marqueurs reviennent, par équipement : un switch renseigné et un autre
non cohabitent.

La partie B devient par switch : gestion, montante et ports terminaux
diffèrent d'une machine à l'autre, un bloc commun n'avait plus de sens.

Gardes exercées : port déclaré deux fois, rayon vers un switch inconnu,
`rayons` sur autre chose que le routeur, `montante` sur le routeur.

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:47:15 -04:00
parent f80b53b592
commit 76e82bfb96
4 changed files with 155 additions and 43 deletions

View file

@ -1,5 +1,35 @@
# CHANGELOG — Set-OPS
## 2026-08-01 (suite 11) — les ports physiques entrent dans le modèle
Les noms de ports n'étaient modélisés **nulle part** : `<PORT-VERS-PROXMOX>` et consorts
étaient des marqueurs littéraux dans le générateur. L'opérateur les remplaçait à la main dans
la sortie, et recommençait **à chaque régénération**. C'était le seul endroit du devis où le
travail était perdu à répétition.
Ils se déclarent désormais par équipement dans `underlay.yml`, sous quatre clefs qui
correspondent aux quatre natures de lien :
```yaml
ports:
hyperviseurs: [Te1/0/1, Te1/0/2, Te1/0/3] # ports terminaux (portfast)
frontiere: [Gi1/0/23] # vers le pare-feu (portfast)
rayons: { sleipnir-02: Te1/0/47, … } # côté ROUTEUR
montante: Te1/0/48 # côté SWITCH D'ACCÈS
```
Le devis émet alors les vrais ports, y compris **plusieurs** vers les hyperviseurs — il n'en
supposait qu'un seul jusqu'ici, alors que le cluster en compte trois. Non déclarés, les
marqueurs reviennent : la dégradation est par équipement, un switch renseigné et un autre non
cohabitent sans problème.
La partie B devient **par switch** : adresse de gestion, montante et ports terminaux
différant d'une machine à l'autre, un bloc commun n'avait plus de sens.
`make underlay` refuse un port déclaré deux fois sur un même équipement, un rayon vers un
switch inconnu, des `rayons` sur autre chose que le routeur, une `montante` sur le routeur
lui-même. Les quatre cas exercés.
## 2026-08-01 (suite 10) — une interface, un bloc
La partie A déclarait ses ports de bord deux fois : l'interface en section 4, son `portfast`

View file

@ -107,6 +107,30 @@ def port_vers(nom_hote: str) -> str:
return f"<PORT-VERS-{nom_hote.upper()}>"
def hote_nomme(underlay: dict | None, nom: str | None) -> dict:
"""L'hote underlay portant ce nom, ou {}."""
return next((h for h in underlay_mod.hotes(underlay) if h.get("nom") == nom), {})
def ports_ou_marqueur(hote: dict, clef: str, marqueur: str) -> list[str]:
"""Ports declares pour cette nature de lien, sinon le marqueur a completer."""
valeurs = underlay_mod.ports_de(hote).get(clef) or []
if isinstance(valeurs, str):
valeurs = [valeurs]
return [str(v) for v in valeurs] or [marqueur]
def bloc_trunk(port: str, vlans: str, description: str = "", bord: bool = False) -> list[str]:
"""Un bloc d'interface trunk complet — l'interface se lit d'un seul tenant."""
out = [f"interface {port}"]
if description:
out.append(f" description {description}")
out += [" switchport mode trunk", f" switchport trunk allowed vlan add {vlans}"]
if bord:
out.append(" spanning-tree portfast trunk")
return out
def section_rayons(underlay: dict | None, vlans: str) -> list[str]:
"""Les rayons de l'etoile : un trunk du routeur vers chaque switch d'acces.
@ -121,11 +145,10 @@ def section_rayons(underlay: dict | None, vlans: str) -> list[str]:
"! /!\\ 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."]
rayons = underlay_mod.ports_de(hote_nomme(underlay, underlay_mod.routeur(underlay))).get("rayons") or {}
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}"]
port = str(rayons.get(h["nom"]) or port_vers(h["nom"]))
out += bloc_trunk(port, vlans, f"rayon vers {h['nom']}")
return out
@ -219,39 +242,32 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
for zone in sorted(n["categories"]):
out += [f"vlan {vlan_de(n['index'], zone)}",
f" name {pfx}{n['index']}-{n['categories'][zone]['libelle']}"]
out += ["!", "! ----- B2. Gestion (seule adresse IP du switch) -----",
"! /!\\ UN SEUL de ces blocs par switch : celui qui porte son nom.",
"! Les coller tous les deux sur la meme machine ecraserait son adresse."]
if mgmt:
net = ipaddress.ip_network(mgmt["sous_reseau"], strict=False)
for h in autres:
out += [f"! --- {h['nom']} ---",
f"interface Vlan{mgmt['vlan']}",
bord = bool(underlay_mod.stp(underlay))
out += ["!", "! ----- B2. Configuration par switch -----",
"! /!\\ UN SEUL de ces blocs par machine : celui qui porte son nom. Adresse de",
"! gestion ET ports different d'un switch a l'autre — tout coller ecraserait."]
net = ipaddress.ip_network(mgmt["sous_reseau"], strict=False) if mgmt else None
for h in autres:
out += ["!", f"! --- {h['nom']} " + "-" * max(0, 48 - len(h["nom"])), "!"]
if net:
out += [f"interface Vlan{mgmt['vlan']}",
f" description gestion {h['nom']}",
f" ip address {h['ip']} {net.netmask}",
" no shutdown",
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 += ["!", "! ----- 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 "!",
"!"]
else:
out.append("! Aucun reseau underlay avec passerelle : gestion a definir a la main.")
out += ["! Montante vers le routeur — PAS un port de bord : elle porte les BPDU."]
montante = str(underlay_mod.ports_de(h).get("montante") or port_vers(r_nom))
out += bloc_trunk(montante, vlans, f"montante vers {r_nom}")
out += ["! Ports terminaux vers les hyperviseurs."]
for port in ports_ou_marqueur(h, "hyperviseurs", "<PORT-VERS-PROXMOX>"):
out += bloc_trunk(port, vlans, bord=bord)
s = underlay_mod.stp(underlay)
if s:
mode = str(s.get("mode", "rstp"))
out += [f"! ----- B4. Spanning-tree ({mode.upper()}) -----",
f"! Priorite VOLONTAIREMENT haute : ces switches ne doivent jamais devenir",
out += ["!", f"! ----- B3. Spanning-tree ({mode.upper()}) — sur les deux -----",
"! Priorite VOLONTAIREMENT haute : ces switches ne doivent jamais devenir",
f"! racine. Le centre de l'etoile, {r_nom}, la garde en toutes circonstances."]
out += _stp_lignes(dialecte_b, mode, underlay_mod.STP_PRIORITE_ACCES)
out += ["!"]
@ -259,7 +275,8 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
return out
def section_frontiere(transit: dict | None, bord: bool = False) -> list[str]:
def section_frontiere(transit: dict | None, bord: bool = False,
ports: list[str] | None = None) -> list[str]:
"""Port du switch vers le pare-feu de bordure. Porte le VLAN de transit, et lui seul.
Distinct du trunk vers Proxmox : les hyperviseurs n'ont aucune interface sur le
@ -274,10 +291,9 @@ def section_frontiere(transit: dict | None, bord: bool = False) -> list[str]:
f"! Porte le seul VLAN {transit['vlan']} ({transit['nom']}). En lien dedie,",
f"! remplacer par : switchport access vlan {transit['vlan']}.",
"! Port terminal : rien derriere lui ne participe au spanning-tree.",
"interface <PORT-VERS-FRONTIERE>",
" switchport mode trunk",
f" switchport trunk allowed vlan add {transit['vlan']}",
] + ([" spanning-tree portfast trunk"] if bord else [])
] + [ligne
for port in (ports or ["<PORT-VERS-FRONTIERE>"])
for ligne in bloc_trunk(port, str(transit["vlan"]), bord=bord)]
def section_routes(underlay: dict | None) -> list[str]:
@ -439,12 +455,14 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
for zone in sorted(n["categories"])
]
vlans = ",".join(vlans_underlay + vlans_tenants)
out.append("interface <PORT-VERS-PROXMOX>")
out.append(" switchport mode trunk")
out.append(f" switchport trunk allowed vlan add {vlans}")
if underlay_mod.stp(underlay):
out.append(" spanning-tree portfast trunk")
out += ["!"] + section_frontiere(transit, bord=bool(underlay_mod.stp(underlay)))
routeur_h = hote_nomme(underlay, underlay_mod.routeur(underlay))
bord = bool(underlay_mod.stp(underlay))
for port in ports_ou_marqueur(routeur_h, "hyperviseurs", "<PORT-VERS-PROXMOX>"):
out += bloc_trunk(port, vlans, bord=bord)
out += ["!"] + section_frontiere(
transit, bord=bool(underlay_mod.stp(underlay)),
ports=ports_ou_marqueur(hote_nomme(underlay, underlay_mod.routeur(underlay)),
"frontiere", "<PORT-VERS-FRONTIERE>"))
out += ["!"] + section_rayons(underlay, ",".join(vlans_underlay + vlans_tenants))
out += ["!"] + section_routes(underlay)
out += section_stp(underlay, dialecte)

View file

@ -107,6 +107,31 @@ def routeur(underlay: dict | None) -> str | None:
return (underlay or {}).get("routeur") or None
def ports_de(hote: dict) -> dict:
"""Ports physiques declares d'un equipement. {} si aucun.
Trois clefs, qui correspondent aux trois natures de lien du devis :
`hyperviseurs` (liste) ports terminaux vers Proxmox ;
`frontiere` (liste) port(s) vers le pare-feu de bordure ;
`rayons` (table) {nom du switch d'acces: port}, cote routeur ;
`montante` (str) port vers le routeur, cote switch d'acces.
Non declares, le devis retombe sur ses marqueurs : il reste lisible, il n'est
simplement pas applicable tel quel.
"""
return (hote or {}).get("ports") or {}
def _ports_a_plat(hote: dict) -> list[str]:
"""Tous les ports d'un equipement, a plat — pour detecter un doublon."""
p = ports_de(hote)
plat = [str(x) for x in (p.get("hyperviseurs") or [])]
plat += [str(x) for x in (p.get("frontiere") or [])]
plat += [str(v) for v in (p.get("rayons") or {}).values()]
if p.get("montante"):
plat.append(str(p["montante"]))
return plat
def switches_acces(underlay: dict | None) -> list[dict]:
"""Les switches d'acces : hotes du reseau de management, hors routeur.
@ -211,6 +236,25 @@ def valider(underlay: dict | None,
erreurs.append(f"reseau '{nom}': passerelle_sortie '{r['passerelle_sortie']}' invalide")
vus_sous.append((net, nom))
# Ports physiques : un meme port ne peut pas servir deux fois sur un equipement,
# et un rayon ne peut viser qu'un switch d'acces reellement declare.
noms_acces = {h.get("nom") for h in switches_acces(underlay)}
r_courant = routeur(underlay)
for h in hotes(underlay):
hn = h.get("nom", "?")
plat = _ports_a_plat(h)
doublons = sorted({p for p in plat if plat.count(p) > 1})
if doublons:
erreurs.append(f"hote '{hn}': port(s) declare(s) deux fois : {', '.join(doublons)}")
p = ports_de(h)
for cible in (p.get("rayons") or {}):
if cible not in noms_acces:
erreurs.append(f"hote '{hn}': rayon vers '{cible}', qui n'est pas un switch d'acces")
if p.get("rayons") and hn != r_courant:
erreurs.append(f"hote '{hn}': des `rayons` mais ce n'est pas le routeur")
if p.get("montante") and hn == r_courant:
erreurs.append(f"hote '{hn}': une `montante` alors qu'il EST le routeur")
s = stp(underlay)
if s:
if str(s.get("mode", "")) not in STP_MODES:

View file

@ -75,9 +75,29 @@ underlay:
mtu: 1500
# Hotes fixes documentes (optionnel) : IP hors DHCP, verifiees dans leur reseau.
# PORTS PHYSIQUES (optionnel). Sans eux, le devis emet des marqueurs
# `<PORT-VERS-...>` a remplacer a la main — et le travail est perdu a chaque
# regeneration. Declares ici, le devis sort applicable tel quel.
# hyperviseurs : ports terminaux vers Proxmox (portfast)
# frontiere : port(s) vers le pare-feu de bordure (portfast)
# rayons : {switch d'acces: port}, cote ROUTEUR uniquement
# montante : port vers le routeur, cote SWITCH D'ACCES uniquement
# `make underlay` refuse un port declare deux fois sur un meme equipement, un rayon
# vers un switch inconnu, et une confusion rayons/montante.
hotes:
# Le switch designe `routeur` porte le SVI de management : son adresse de gestion
# EST la passerelle du reseau. `make underlay` refuse les deux valeurs divergentes.
- { nom: switch-01, reseau: management, ip: 10.0.0.1 }
- { nom: switch-02, reseau: management, ip: 10.0.0.3 }
- nom: switch-01
reseau: management
ip: 10.0.0.1
ports:
hyperviseurs: [Te1/0/1, Te1/0/2, Te1/0/3]
frontiere: [Gi1/0/23]
rayons: { switch-02: Te1/0/47, switch-03: Te1/0/48 }
- nom: switch-02
reseau: management
ip: 10.0.0.3
ports:
montante: Te1/0/48
hyperviseurs: [Te1/0/1, Te1/0/2, Te1/0/3]
- { nom: switch-03, reseau: management, ip: 10.0.0.4 }