GUI : section « Fabric » — l'underlay se règle depuis la console

Tout le modèle d'underlay bâti aujourd'hui s'éditait à la main dans un YAML,
pendant que la doctrine dit qu'un sysadmin doit exploiter l'outil sans IA.
La frontière avait eu sa section ; l'underlay, non.

La section couvre les valeurs plates : switch routeur, dialecte de CLI, mode
et topologie de spanning-tree. Les listes de tables (`reseaux`, `hotes`, donc
les ports) restent hors de portée du panneau — elles demandent une vue
dédiée, comme celle des serveurs.

Le dialecte devient un intrant déclaré : il ne vivait que dans
SETOPS_DIALECTE. C'est une propriété du matériel, donc de la fabric.
Précédence : `--dialecte` > environnement > intrant déclaré > cisco.

Écriture chirurgicale plutôt que safe_dump : underlay.yml porte 23 lignes de
commentaires qui expliquent des décisions d'architecture, et un dump les
aurait effacées comme c'est arrivé à plan/applications.yml. Vérifié : trois
valeurs modifiées, 73 lignes et 23 commentaires avant comme après. Une clef
absente du fichier est refusée plutôt qu'inventée à un endroit arbitraire.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-01 22:01:00 -04:00
parent 76e82bfb96
commit d00b60149d
5 changed files with 128 additions and 8 deletions

View file

@ -1,5 +1,33 @@
# CHANGELOG — Set-OPS
## 2026-08-01 (suite 12) — la fabric se règle depuis la console
Tout le modèle d'underlay bâti aujourd'hui — routeur, spanning-tree, fabrics, transit —
s'éditait à la main dans un YAML, pendant que la doctrine dit qu'un sysadmin doit exploiter
l'outil **sans IA**. La frontière avait eu sa section dans le panneau ; l'underlay, non.
Une section **Fabric** couvre désormais les valeurs plates : le switch routeur, le dialecte
de CLI, le mode et la topologie de spanning-tree. Les listes de tables (`reseaux`, `hotes`,
et donc les ports) restent hors de portée du panneau — elles demandent une vue dédiée, comme
celle des serveurs.
### Le dialecte devient un intrant déclaré
Il ne vivait que dans `SETOPS_DIALECTE`, une variable d'environnement. C'est une propriété du
**matériel**, donc de la fabric : elle se déclare dans `underlay.yml`. Précédence désormais
explicite : drapeau `--dialecte` > variable d'environnement > intrant déclaré > `cisco`.
`make underlay` refuse un dialecte inconnu.
### Écriture chirurgicale, pas de `safe_dump`
`underlay.yml` porte 23 lignes de commentaires qui expliquent des décisions d'architecture —
pourquoi un seul routeur, pourquoi le transit vit dans l'underlay, pourquoi les rayons ne
sont pas des ports de bord. Un `safe_dump` les aurait toutes effacées, comme c'est arrivé aux
commentaires de `plan/applications.yml`. Le panneau remplace donc la ligne existante en
respectant son indentation. Vérifié : trois valeurs modifiées, **73 lignes et 23 commentaires
avant comme après**.
Une clef absente du fichier n'est pas créée : le panneau refuse explicitement plutôt que de
l'inventer à un endroit arbitraire.
## 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

View file

@ -21,8 +21,8 @@ import yaml
# Dialecte de CLI du commutateur. Cisco exige un masque INVERSE (wildcard 0.0.255.255)
# dans les ACL ; Binardat exige un masque NORMAL (255.255.0.0). Le SVI (ip address) prend
# un masque normal sur les deux. Reglable via --dialecte ou SETOPS_DIALECTE.
DIALECTES = ("cisco", "binardat")
DIALECTE_DEFAUT = os.environ.get("SETOPS_DIALECTE", "cisco")
# Liste des dialectes : source unique dans `underlay` (propriete du materiel).
DIALECTE_SECOURS = "cisco"
RACINE = Path(__file__).resolve().parent.parent
DOSSIER_INSTANCES = RACINE.parent
@ -35,6 +35,18 @@ import underlay as underlay_mod # noqa: E402 (fabric physique, cluster-global,
# frontiere (intrant `nftables_admin_ssh`). On reutilise le resolveur, pas une copie.
from resoudre_flux import _sources_admin_ssh # noqa: E402
DIALECTES = underlay_mod.DIALECTES
def dialecte_effectif(explicite: str | None = None) -> str:
"""Drapeau > variable d'environnement > intrant de fabric > secours.
L'intrant `underlay.dialecte` est la valeur DECLAREE (reglable depuis le panneau) ;
l'environnement reste un surcharge ponctuelle pour un essai.
"""
return (explicite or os.environ.get("SETOPS_DIALECTE")
or underlay_mod.dialecte(underlay_mod.charger()) or DIALECTE_SECOURS)
def masque(cidr: int) -> str:
return str(ipaddress.IPv4Network(f"0.0.0.0/{cidr}").netmask)
@ -199,7 +211,7 @@ def section_stp(underlay: dict | None, dialecte: str) -> list[str]:
def partie_acces(underlay: dict | None, tenants: list, vlans: str,
dialecte_b: str = DIALECTE_DEFAUT) -> list[str]:
dialecte_b: str = DIALECTE_SECOURS) -> list[str]:
"""Devis des switches d'ACCES : L2 pur, aucun SVI de zone, aucune ACL, aucune route.
Sans MLAG, un seul switch route (cf. `underlay.routeur`). Les autres commutent les
@ -366,7 +378,7 @@ def decouvrir() -> list[tuple[str, str, dict]]:
def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -> str:
dialecte = dialecte or DIALECTE_DEFAUT
dialecte = dialecte_effectif(dialecte)
underlay = underlay_mod.charger()
r_nom = underlay_mod.routeur(underlay)
out = ["configure terminal", f"! dialecte CLI : {dialecte}", "!"]
@ -474,8 +486,8 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--dialecte", choices=DIALECTES, default=DIALECTE_DEFAUT,
help="CLI du commutateur (defaut : SETOPS_DIALECTE ou 'cisco')")
ap.add_argument("--dialecte", choices=DIALECTES, default=None,
help="CLI du commutateur (defaut : SETOPS_DIALECTE, sinon underlay.dialecte)")
args = ap.parse_args()
tenants = decouvrir()
if not tenants:

View file

@ -88,8 +88,23 @@ INTRANTS_PROXMOX = INVENTAIRE_MODELE.parent / "group_vars/proxmox.yml"
# Frontiere nord/sud (OPNsense) : parametres NON sensibles du pare-feu de bordure.
# La cle/secret d'API n'y entrent JAMAIS — voute uniquement (cf. INTRANTS_CLES_INTERDITES).
INTRANTS_FRONTIERE = INVENTAIRE_DEFAUT.parent / "group_vars/opnsense.yml"
# Fabric physique : `underlay.yml` a la RACINE du moteur (pas dans l'instance) — il est
# cluster-global. Imbrique sous `underlay:` et riche en commentaires : on l'ecrit
# chirurgicalement, jamais par un safe_dump qui les effacerait.
FICHIER_UNDERLAY = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml"))
FICHIERS_INTRANTS = {"identite": INTRANTS_IDENTITE, "proxmox": INTRANTS_PROXMOX,
"reseau": FICHIER_NOMENCLATURE, "frontiere": INTRANTS_FRONTIERE}
"reseau": FICHIER_NOMENCLATURE, "frontiere": INTRANTS_FRONTIERE,
"fabric": FICHIER_UNDERLAY}
# Intrants de fabric -> chemin dans underlay.yml. Seules les valeurs PLATES sont ici :
# `reseaux` et `hotes` sont des listes de tables, hors de portee du panneau (cf. la vue
# dediee, a venir). Un intrant absent d'ici s'edite encore dans le YAML.
CHEMINS_UNDERLAY = {
"underlay_routeur": ("routeur",),
"underlay_dialecte": ("dialecte",),
"underlay_stp_mode": ("stp", "mode"),
"underlay_stp_topologie": ("stp", "topologie"),
}
# (variable, fichier, classe, section, libelle, type)
INTRANTS_SCHEMA = [
@ -115,6 +130,10 @@ INTRANTS_SCHEMA = [
("opnsense_api_verifier_certs", "frontiere", "defaut", "Frontière", "Valider le certificat TLS de la frontière", "bool"),
("opnsense_if_wan", "frontiere", "defaut", "Frontière", "Interface WAN", "str"),
("opnsense_if_transit", "frontiere", "defaut", "Frontière", "Interface/trunk vers les switches (transit)", "str"),
("underlay_routeur", "fabric", "constante", "Fabric", "Switch qui porte le routage (SVI, ACL, routes) — les autres restent en L2 pur", "str"),
("underlay_dialecte", "fabric", "constante", "Fabric", "Dialecte de CLI du commutateur (cisco | binardat)", "str"),
("underlay_stp_mode", "fabric", "defaut", "Fabric", "Mode de spanning-tree (rstp | mstp | pvst)", "str"),
("underlay_stp_topologie", "fabric", "defaut", "Fabric", "Topologie de câblage (etoile | anneau | maille)", "str"),
# `opnsense_prochain_saut` n'est PLUS un intrant : il derive du reseau de transit de
# l'underlay (`passerelle` du reseau portant `passerelle_sortie`). Le saisir ici en
# doublon ouvrirait la porte a deux valeurs contradictoires pour un seul lien.
@ -407,12 +426,25 @@ def instance_est_production(path: Path) -> bool:
return path.resolve() == INVENTAIRE_PRODUCTION
def _lire_fabric_a_plat() -> dict:
"""Les intrants PLATS de underlay.yml, aplatis dans l'espace de noms du panneau."""
data = _lire_yaml_dict(FICHIER_UNDERLAY).get("underlay") or {}
plat = {}
for variable, chemin in CHEMINS_UNDERLAY.items():
noeud = data
for clef in chemin:
noeud = (noeud or {}).get(clef) if isinstance(noeud, dict) else None
plat[variable] = noeud
return plat
def intrants_courants() -> dict:
cache: dict = {}
valeurs: dict = {}
for variable, fcle, _classe, _section, _libelle, typ in INTRANTS_SCHEMA:
if fcle not in cache:
cache[fcle] = _lire_yaml_dict(FICHIERS_INTRANTS[fcle])
cache[fcle] = (_lire_fabric_a_plat() if fcle == "fabric"
else _lire_yaml_dict(FICHIERS_INTRANTS[fcle]))
v = cache[fcle].get(variable)
if typ == "liste":
items = v if isinstance(v, list) else []
@ -558,10 +590,42 @@ def ecrire_intrants(payload: dict) -> None:
"#\n"
"# Le boitier est HORS FLOTTE Ansible : on ne fait que piloter son API REST\n"
"# depuis le controleur. Cf. docs/frontiere-opnsense.md.\n---\n")
if "fabric" in par_fichier:
_ecrire_intrants_fabric(par_fichier["fabric"])
if "reseau" in par_fichier and "index" in par_fichier["reseau"]:
_ecrire_index_nomenclature(int(par_fichier["reseau"]["index"]))
def _ecrire_intrants_fabric(valeurs: dict) -> None:
"""Met a jour les cles PLATES de underlay.yml SANS reformater le fichier.
Meme motif que `_ecrire_index_nomenclature` : ce fichier porte des commentaires qui
expliquent des decisions d'architecture (pourquoi un seul routeur, pourquoi le
transit vit dans l'underlay...). Un `safe_dump` les effacerait toutes. On remplace
donc la ligne existante, en respectant son indentation, et on n'invente rien : une
clef absente du fichier n'est PAS creee — elle n'a pas de place evidente ou aller.
"""
if not FICHIER_UNDERLAY.exists():
return
texte = FICHIER_UNDERLAY.read_text(encoding="utf-8")
for variable, chemin in CHEMINS_UNDERLAY.items():
if variable not in valeurs:
continue
val = str(valeurs[variable]).strip()
if not val:
continue
feuille = chemin[-1]
# Indentation attendue : 2 espaces a la racine du bloc `underlay`, 4 si imbrique.
indent = " " * len(chemin)
motif = re.compile(rf"(?m)^{indent}{re.escape(feuille)}:[ \t]*\S.*$")
remplacement = f"{indent}{feuille}: {val}"
texte, n = motif.subn(remplacement, texte, count=1)
if not n:
raise ValueError(f"underlay.yml : clef '{feuille}' introuvable — "
f"l'ajouter au fichier avant de la regler ici.")
FICHIER_UNDERLAY.write_text(texte, encoding="utf-8")
def _ecrire_index_nomenclature(index: int) -> None:
"""Met a jour `index:` dans plan/nomenclature.yml SANS reformater le reste.

View file

@ -64,10 +64,18 @@ FABRIC_DEFAUT = "principal"
# doivent jamais l'etre. 4096 laisse 0 disponible pour une reprise d'urgence.
STP_PRIORITE_RACINE = 4096
STP_PRIORITE_ACCES = 32768
# Dialecte de CLI du commutateur : c'est une propriete du MATERIEL, donc de la fabric.
# Source unique (devis_reseau y lit ses choix d'argparse).
DIALECTES = ("cisco", "binardat")
STP_MODES = ("rstp", "mstp", "pvst")
STP_TOPOLOGIES = ("etoile", "anneau", "maille")
def dialecte(underlay: dict | None) -> str:
"""Dialecte de CLI declare pour la fabric. '' si absent."""
return str((underlay or {}).get("dialecte") or "")
def stp(underlay: dict | None) -> dict:
"""Parametres de spanning-tree de la fabric principale. {} si non declares."""
return (underlay or {}).get("stp") or {}
@ -255,6 +263,10 @@ def valider(underlay: dict | None,
if p.get("montante") and hn == r_courant:
erreurs.append(f"hote '{hn}': une `montante` alors qu'il EST le routeur")
d = dialecte(underlay)
if d and d not in DIALECTES:
erreurs.append(f"dialecte '{d}' inconnu (attendu : {', '.join(DIALECTES)})")
s = stp(underlay)
if s:
if str(s.get("mode", "")) not in STP_MODES:

View file

@ -26,6 +26,10 @@ underlay:
# d'adresses qu'il y a de zones. Doit nommer un hote declare dans `hotes:`.
routeur: switch-01
# Dialecte de CLI du commutateur (cisco | binardat). Propriete du MATERIEL :
# decide la forme des masques d'ACL, des routes et du spanning-tree.
dialecte: cisco
# Spanning-tree de la fabric principale. `topologie` documente le cablage :
# `etoile` = rayons depuis le routeur (aucun lien redondant, donc aucune boucle),
# `anneau`/`maille` = liens redondants, RSTP devient indispensable. Le routeur est