diff --git a/CHANGELOG.md b/CHANGELOG.md index 01dc024..d84961e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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** : `` et consorts diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index 43df6c5..9204300 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -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: diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 000bdc4..bcf085a 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -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. diff --git a/scripts/underlay.py b/scripts/underlay.py index 88beacb..c1ad9dd 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -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: diff --git a/underlay.yml.example b/underlay.yml.example index 52547f1..9ce728e 100644 --- a/underlay.yml.example +++ b/underlay.yml.example @@ -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