From 272f6369d8be925ee43bfe02642e959ef9392ced Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 24 Jul 2026 14:56:38 -0400 Subject: [PATCH] Underlay : fabric physique cluster-global (mgmt/iSCSI/Ceph) + preuve P23 Le modele derive l'adressage par tenant (VLAN 1000+index*10+zone), mais la fabric physique qui porte la flotte (mgmt switches/Proxmox/OOB, iSCSI, Ceph public+cluster) n'appartient a aucun tenant. Elle est desormais codifiee. - scripts/underlay.py + make underlay : charge/affiche/valide underlay.yml (VLAN < 1000, sous-reseaux hors des supernets tenant 10.(10+index).0.0/16). - underlay.yml gitignore (comme le vault) ; gabarit public underlay.yml.example ; surchargeable par SETOPS_UNDERLAY. - devis_reseau : section 0. Underlay + VLAN underlay sur le trunk, selon dialecte. - P23 : underlay.py --verifier ; sautee si underlay.yml absent (comme P16 sans vault). 23/23 preuves. Doc : docs/audit/README.md, wiki page reseau, CHANGELOG. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + CHANGELOG.md | 23 ++++ Makefile | 4 + docs/audit/README.md | 1 + scripts/devis_reseau.py | 36 +++++- scripts/prouver.py | 15 +++ scripts/underlay.py | 181 +++++++++++++++++++++++++++ underlay.yml.example | 45 +++++++ wiki/Multi-instance-et-fédération.md | 18 +++ 9 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 scripts/underlay.py create mode 100644 underlay.yml.example diff --git a/.gitignore b/.gitignore index e5293ae..fceea71 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ facts_cache/ *.vault *.vault.yml **/vault.yml +# Underlay = fabric physique de l'operateur (cluster-global) ; gabarit public seul. +/underlay.yml *.secret *.pem *.key diff --git a/CHANGELOG.md b/CHANGELOG.md index 244ab36..9861493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # CHANGELOG — Set-OPS +## 2026-07-24 — underlay (fabric physique, cluster-global) + +### Ajouté — l'underlay comme concept de premier plan +Le modèle dérive l'adressage **par tenant** (VLAN `1000+index×10+zone`), mais la **fabric +physique** qui porte la flotte — mgmt des switches, mgmt Proxmox/OOB, iSCSI, Ceph — n'appartient +à aucun tenant et ne dérive d'aucun `index`. Elle vit dans le *sous-sol* du modèle. Jusqu'ici +elle n'était pas codifiée. Elle l'est. + +- **`scripts/underlay.py`** + **`make underlay`** — charge/affiche/**valide** `underlay.yml` : + réseaux (nom, VLAN, sous-réseau, passerelle optionnelle → SVI, MTU/jumbo) et hôtes fixes + documentés (les switches). La validation refuse toute **collision avec la plage tenant** : + VLAN < 1000, sous-réseaux hors des supernets `10.(10+index).0.0/16`. +- **`underlay.yml`** (racine du moteur, **gitignore** comme le vault ; gabarit public + `underlay.yml.example`), surchargeable par `SETOPS_UNDERLAY`. Absent → tout reste inchangé. +- **`make devis-reseau`** émet désormais une **section 0. Underlay** (VLANs, SVI, hints jumbo, + IP des switches en commentaire) et ajoute les VLAN underlay au **trunk** Proxmox, avant les + tenants. Respecte le dialecte (`cisco`/`binardat`). +- **Preuve P23** — `underlay.py --verifier` : la fabric n'empiète pas sur la plage tenant. + **Sautée** (⚪) si `underlay.yml` est absent (dépôt public), comme P16 sans vault. + +Le plafond tenant (245) est inchangé : l'underlay occupe `10.0.0.0/16 .. 10.10.0.0/16`, laissé +libre par la dérivation (index ≥ 1 → `10.11+`). + ## 2026-07-23 (suite 7) ### Ajouté — dialecte de CLI du commutateur (`devis-reseau`) diff --git a/Makefile b/Makefile index 492bffb..99011a2 100644 --- a/Makefile +++ b/Makefile @@ -344,6 +344,10 @@ flux: ansible-runtime devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau converge, derive des nomenclatures. DIALECTE=cisco|binardat python3 scripts/devis_reseau.py $(if $(DIALECTE),--dialecte $(DIALECTE),) +.PHONY: underlay +underlay: ## Underlay (fabric physique cluster-global : mgmt/iSCSI/Ceph) : affiche + valide (P23) + python3 scripts/underlay.py + flux-verifier: python3 scripts/resoudre_flux.py verifier diff --git a/docs/audit/README.md b/docs/audit/README.md index 1de1db8..662385d 100644 --- a/docs/audit/README.md +++ b/docs/audit/README.md @@ -60,6 +60,7 @@ make prouver | P20 | Adressage dérivé du seed | aucune nomenclature ne **stocke** d'adressage (supernet, sous-réseau, passerelle, VLAN) : tout se dérive du seul `index`. Attrape toute rechute vers l'écriture manuelle. | | P21 | Fédération sans collision | aucune paire d'instances **fédérées** ne partage un `index` (mêmes VLAN/VMID sur le trunk). Le garde-fou du multi-instances ; vue avec `make instances`. | | P22 | Plan de recette à jour | `docs/audit/plan-de-recette.md` (les 78 gestes manuels, générés des exercices du wiki) est **à jour** — il ne peut pas dériver du wiki. Régénérer : `make plan-recette`. | +| P23 | Underlay sans collision | la **fabric physique** (`underlay.yml` : mgmt/iSCSI/Ceph, cluster-global) n'empiète pas sur la plage tenant — VLAN < 1000 et sous-réseaux hors des supernets `10.(10+index).0.0/16`. Sautée si `underlay.yml` absent. Vue : `make underlay`. | > **P17, P18, P19 ferment les angles morts du harnais** : il ne vérifiait qu'*une* instance > et le seul modèle `socle`. P17 aurait attrapé l'hôte fantôme d'`integral` ; P18, les neuf diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index ccf6be3..de727ff 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -30,6 +30,7 @@ DOSSIER_INSTANCES = RACINE.parent # Adressage et VLAN : derives du seul index, source unique dans inventory_rules. sys.path.insert(0, str(RACINE / "scripts")) from inventory_rules import passerelle_de, supernet_de, vlan_de # noqa: E402 +import underlay as underlay_mod # noqa: E402 (fabric physique, cluster-global, hors tenant) def masque(cidr: int) -> str: @@ -54,6 +55,30 @@ def remarque(dialecte: str, texte: str) -> list[str]: return [] if dialecte == "binardat" else [f" remark {texte}"] +def section_underlay(underlay: dict | None) -> list[str]: + """VLANs + SVIs (si passerelle) de la fabric physique. Vide si aucun underlay.""" + reseaux = underlay_mod.reseaux(underlay) + if not reseaux: + return [] + out = ["! ----- 0. Underlay (fabric physique, cluster-global, hors tenant) -----"] + for r in reseaux: + out.append(f"vlan {r['vlan']}") + out.append(f" name {r['nom']}") + if r.get("mtu") and int(r["mtu"]) > 1500: + out.append(f" ! jumbo MTU {r['mtu']} — a appliquer sur les ports + bridges Proxmox") + for r in reseaux: + if r.get("passerelle"): + net = ipaddress.ip_network(r["sous_reseau"], strict=False) + out.append(f"interface Vlan{r['vlan']}") + out.append(f" description underlay {r['nom']}") + out.append(f" ip address {r['passerelle']} {net.netmask}") + out.append(" no shutdown") + for h in underlay_mod.hotes(underlay): + out.append(f"! {h.get('nom','?')} = {h.get('ip','?')} ({h.get('reseau','?')})") + out.append("!") + return out + + def prefixe(nom_dossier: str) -> str: base = re.sub(r"^OPS-", "", nom_dossier) base = re.sub(r"-lab$", "", base) @@ -76,16 +101,19 @@ 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 + underlay = underlay_mod.charger() out = ["configure terminal", f"! dialecte CLI : {dialecte}", "!"] out += [ "! ============================================================", "! DEVIS SWITCH — reseau converge multi-tenant (Set-OPS)", "! Genere par scripts/devis_reseau.py depuis les nomenclatures.", "! VLAN = 1000 + index*10 + zone (unique globalement sur le trunk).", + "! Underlay (VLAN < 1000) = fabric physique, cluster-global.", "! ============================================================", "!", - "! ----- 1. VLANs -----", ] + out += section_underlay(underlay) + out += ["! ----- 1. VLANs (tenants) -----"] for nom, pfx, n in tenants: out.append(f"! {nom} (index {n['index']})") for zone in sorted(n["categories"]): @@ -117,11 +145,13 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) - 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) -----"] - vlans = ",".join( + vlans_underlay = [str(r["vlan"]) for r in underlay_mod.reseaux(underlay)] + vlans_tenants = [ str(vlan_de(n["index"], zone)) for _, _, n in tenants for zone in sorted(n["categories"]) - ) + ] + vlans = ",".join(vlans_underlay + vlans_tenants) out.append("interface ") out.append(" switchport mode trunk") out.append(f" switchport trunk allowed vlan add {vlans}") diff --git a/scripts/prouver.py b/scripts/prouver.py index 9de5bb7..a8e3f5b 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -290,6 +290,8 @@ PREUVES: list[dict] = [ "cmds": [[sys.executable, "scripts/instances.py", "--verifier"]]}, {"id": "P22", "titre": "Plan de recette a jour (genere du wiki)", "refs": ["AFF-002"], "cmds": [[sys.executable, "scripts/plan_recette.py", "--verifier"]]}, + {"id": "P23", "titre": "Underlay sans collision avec la plage tenant", "refs": ["AFF-001"], + "cmds": [[sys.executable, "scripts/underlay.py", "--verifier"]]}, ] @@ -310,6 +312,12 @@ def _vault_requis_absent() -> bool: return False +def _underlay_absent() -> bool: + """Vrai si aucun underlay.yml n'est defini (fabric non decrite : rien a prouver).""" + import underlay + return underlay.chemin() is None + + def _executer(preuve: dict) -> tuple[str, str]: """Renvoie (statut, detail). statut in {OK, ECHEC, SAUTE}.""" if "func" in preuve: @@ -344,6 +352,13 @@ def main(argv: list[str] | None = None) -> int: else: liste.append(preuve_inv) + # Preuve conditionnelle : underlay (fabric physique). Sautee si non definie. + if _underlay_absent(): + p23 = next((p for p in liste if p["id"] == "P23"), None) + if p23: + liste.remove(p23) + resultats.append((p23, "SAUTE", "Aucun underlay.yml (fabric non definie) — rien a prouver.")) + for preuve in liste: statut, detail = _executer(preuve) resultats.append((preuve, statut, detail)) diff --git a/scripts/underlay.py b/scripts/underlay.py new file mode 100644 index 0000000..99377a9 --- /dev/null +++ b/scripts/underlay.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Underlay = la fabric physique partagee par les instances federees. + +Cluster-global, HORS de la derivation par-tenant (aucun index) : management des +switches et du mgmt Proxmox/OOB, stockage iSCSI, reseaux Ceph (public + cluster). +Ce sont des reseaux du *sous-sol* : ils portent la flotte, ils n'appartiennent a +aucun tenant. + +Convention : VLAN < 1000, franchement SOUS la plage tenant (VLAN = 1000+index*10+zone). +Les sous-reseaux ne doivent chevaucher aucun supernet tenant (10.(10+index).0.0/16). + +Fichier : `underlay.yml` a la racine du moteur (gitignore ; voir `underlay.yml.example`), +surchargeable par `SETOPS_UNDERLAY`. Absent => le devis omet simplement la section underlay. + +CLI : `python3 scripts/underlay.py` (affiche + valide), `--verifier` (P23, code de sortie). +""" +from __future__ import annotations +import argparse +import ipaddress +import os +import sys +from pathlib import Path + +import yaml + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE / "scripts")) +from inventory_rules import supernet_de, vlan_de # noqa: E402 + +# Les VLAN tenant commencent a 1000+index*10+zone (>= 1011). L'underlay reste en dessous. +SEUIL_VLAN_TENANT = 1000 + + +def chemin() -> Path | None: + """Emplacement du underlay.yml : SETOPS_UNDERLAY, sinon racine du moteur. None si absent.""" + env = os.environ.get("SETOPS_UNDERLAY") + if env: + p = Path(env).expanduser() + return p if p.exists() else None + p = RACINE / "underlay.yml" + return p if p.exists() else None + + +def charger() -> dict | None: + """Le bloc `underlay` du fichier, ou None si absent/vide.""" + c = chemin() + if not c: + return None + data = yaml.safe_load(c.read_text(encoding="utf-8")) or {} + return data.get("underlay") or None + + +def reseaux(underlay: dict | None) -> list[dict]: + return (underlay or {}).get("reseaux", []) or [] + + +def hotes(underlay: dict | None) -> list[dict]: + return (underlay or {}).get("hotes", []) or [] + + +def _tenants_reperes() -> tuple[set[int], list[str]]: + """VLAN et supernets des tenants federes (import paresseux : evite le cycle avec devis).""" + import devis_reseau + tenants = devis_reseau.decouvrir() + vlans = {vlan_de(n["index"], z) for _, _, n in tenants for z in n["categories"]} + supernets = [supernet_de(n["index"]) for _, _, n in tenants] + return vlans, supernets + + +def valider(underlay: dict | None, + tenants_vlans: set[int] | None = None, + tenants_supernets: list[str] | None = None) -> list[str]: + """Liste des erreurs (vide = conforme). Verifie champs requis, unicite, et + surtout l'absence de collision avec la plage tenant (VLAN et sous-reseaux).""" + if underlay is None: + return [] # absent = rien a valider (le devis omettra la section) + if tenants_vlans is None or tenants_supernets is None: + tenants_vlans, tenants_supernets = _tenants_reperes() + t_supernets = [ipaddress.ip_network(s, strict=False) for s in tenants_supernets] + + erreurs: list[str] = [] + vus_vlan: dict[int, str] = {} + vus_sous: list[tuple[ipaddress.IPv4Network, str]] = [] + noms_reseaux: set[str] = set() + + for r in reseaux(underlay): + nom = r.get("nom", "?") + noms_reseaux.add(nom) + vlan, sous = r.get("vlan"), r.get("sous_reseau") + if vlan is None or sous is None: + erreurs.append(f"reseau '{nom}': 'vlan' et 'sous_reseau' sont requis") + continue + if not isinstance(vlan, int) or vlan >= SEUIL_VLAN_TENANT: + erreurs.append(f"reseau '{nom}': VLAN {vlan} doit etre un entier < {SEUIL_VLAN_TENANT} " + f"(sinon empiete sur la plage tenant)") + if vlan in tenants_vlans: + erreurs.append(f"reseau '{nom}': VLAN {vlan} deja porte par un tenant federe") + if vlan in vus_vlan: + erreurs.append(f"reseau '{nom}': VLAN {vlan} en double avec '{vus_vlan[vlan]}'") + vus_vlan[vlan] = nom + try: + net = ipaddress.ip_network(sous, strict=False) + except ValueError: + erreurs.append(f"reseau '{nom}': sous_reseau '{sous}' invalide") + continue + for ts in t_supernets: + if net.overlaps(ts): + erreurs.append(f"reseau '{nom}': {sous} chevauche le supernet tenant {ts}") + for autre, autre_nom in vus_sous: + if net.overlaps(autre): + erreurs.append(f"reseau '{nom}': {sous} chevauche le reseau underlay '{autre_nom}'") + if r.get("passerelle"): + try: + if ipaddress.ip_address(r["passerelle"]) not in net: + erreurs.append(f"reseau '{nom}': passerelle {r['passerelle']} hors de {sous}") + except ValueError: + erreurs.append(f"reseau '{nom}': passerelle '{r['passerelle']}' invalide") + vus_sous.append((net, nom)) + + par_nom = {r.get("nom"): r for r in reseaux(underlay)} + for h in hotes(underlay): + hn = h.get("nom", "?") + r = par_nom.get(h.get("reseau")) + if not r: + erreurs.append(f"hote '{hn}': reseau '{h.get('reseau')}' inconnu") + continue + try: + if ipaddress.ip_address(h["ip"]) not in ipaddress.ip_network(r["sous_reseau"], strict=False): + erreurs.append(f"hote '{hn}': IP {h.get('ip')} hors de {r['sous_reseau']}") + except (ValueError, KeyError): + erreurs.append(f"hote '{hn}': IP absente ou invalide") + return erreurs + + +def verifier() -> int: + underlay = charger() + if underlay is None: + print("Underlay : aucun fichier (underlay.yml absent) — section omise, rien a prouver.") + return 0 + erreurs = valider(underlay) + if erreurs: + print("Underlay NON conforme :") + for e in erreurs: + print(f" - {e}") + return 1 + n = len(reseaux(underlay)) + print(f"Underlay conforme : {n} reseau(x), aucune collision avec la plage tenant.") + return 0 + + +def afficher() -> None: + underlay = charger() + if underlay is None: + print("Aucun underlay defini (underlay.yml absent). Copier underlay.yml.example pour commencer.") + return + print(f"Underlay (fabric physique, cluster-global) — source : {chemin()}") + print(f"{'RESEAU':<16}{'VLAN':>6} {'SOUS-RESEAU':<18}{'PASSERELLE':<15}{'MTU':>6} DESCRIPTION") + for r in reseaux(underlay): + print(f"{r.get('nom','?'):<16}{str(r.get('vlan','?')):>6} " + f"{r.get('sous_reseau','?'):<18}{str(r.get('passerelle') or '-'):<15}" + f"{str(r.get('mtu') or '-'):>6} {r.get('description','')}") + if hotes(underlay): + print("\nHotes fixes documentes :") + for h in hotes(underlay): + print(f" {h.get('nom','?'):<16} {h.get('ip','?'):<15} ({h.get('reseau','?')})") + erreurs = valider(underlay) + print("\n" + ("Conforme (aucune collision tenant)." if not erreurs + else "ERREURS :\n - " + "\n - ".join(erreurs))) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--verifier", action="store_true", help="valide l'underlay (P23), code de sortie") + args = ap.parse_args() + if args.verifier: + sys.exit(verifier()) + afficher() + + +if __name__ == "__main__": + main() diff --git a/underlay.yml.example b/underlay.yml.example new file mode 100644 index 0000000..86f1b81 --- /dev/null +++ b/underlay.yml.example @@ -0,0 +1,45 @@ +# Underlay — la fabric physique partagee par les instances federees. +# +# Cluster-global : ces reseaux portent TOUTE la flotte, ils n'appartiennent a aucun +# tenant et ne derivent d'aucun `index`. Ils vivent dans le *sous-sol* du modele. +# +# Copier vers `underlay.yml` (a la racine du moteur ; gitignore) et adapter a ta fabric. +# Surchargeable par SETOPS_UNDERLAY=/chemin/underlay.yml. Absent => le devis switch +# omet simplement la section underlay (retro-compatible). +# +# Regles (prouvees par P23 / `make underlay`) : +# - VLAN < 1000 : franchement SOUS la plage tenant (VLAN tenant = 1000+index*10+zone). +# - sous_reseau : ne chevauche aucun supernet tenant (10.(10+index).0.0/16, index >= 1). +# => la plage 10.0.0.0/16 .. 10.10.0.0/16 est libre pour l'underlay. +# - passerelle : OPTIONNELLE. Presente => un SVI est genere (routage inter-VLAN). +# Storage/Ceph restent en general L2 pur (pas de passerelle). +--- +underlay: + reseaux: + - nom: management + description: Switches, mgmt Proxmox, OOB/IPMI + vlan: 10 + sous_reseau: 10.0.0.0/24 + passerelle: 10.0.0.1 # SVI (retirer pour du L2 pur) + mtu: 1500 + - nom: stockage-iscsi + description: iSCSI MPIO + vlan: 20 + sous_reseau: 10.0.1.0/24 + mtu: 9000 # jumbo + - nom: ceph-public + description: Clients <-> MON/OSD + vlan: 30 + sous_reseau: 10.0.2.0/24 + mtu: 9000 + - nom: ceph-cluster + description: OSD <-> OSD (replication, backfill, recovery) + vlan: 31 + sous_reseau: 10.0.3.0/24 + mtu: 9000 + + # Hotes fixes documentes (optionnel) : IP hors DHCP, verifiees dans leur reseau. + hotes: + - { nom: switch-01, reseau: management, ip: 10.0.0.2 } + - { nom: switch-02, reseau: management, ip: 10.0.0.3 } + - { nom: switch-03, reseau: management, ip: 10.0.0.4 } diff --git a/wiki/Multi-instance-et-fédération.md b/wiki/Multi-instance-et-fédération.md index 0c41403..0e0b1e4 100644 --- a/wiki/Multi-instance-et-fédération.md +++ b/wiki/Multi-instance-et-fédération.md @@ -60,6 +60,24 @@ Choisir le dialecte : `make devis-reseau DIALECTE=binardat`, la variable d'envir `SETOPS_DIALECTE=binardat` (défaut permanent), ou l'option `--dialecte`. La GUI (vue Réseau, lecture seule) suit l'environnement. **Le dépôt public reste générique** (`cisco`). +**L'underlay — le sous-sol.** Les VLAN tenant (`1000+index×10+zone`) sont les *overlays*. En +dessous vit la **fabric physique** partagée par toute la flotte : management des switches et de +Proxmox/OOB, iSCSI, Ceph (public + cluster). Elle **n'appartient à aucun tenant** et ne dérive +d'aucun `index`. On la décrit dans `underlay.yml` (racine du moteur, gitignore comme le vault ; +gabarit `underlay.yml.example`) : + +| Underlay | VLAN | Sous-réseau | +|---|---|---| +| management (switches, Proxmox, OOB) | 10 | `10.0.0.0/24` | +| stockage / iSCSI | 20 | `10.0.1.0/24` | +| ceph-public | 30 | `10.0.2.0/24` | +| ceph-cluster | 31 | `10.0.3.0/24` | + +`make underlay` l'affiche et le **valide** : VLAN < 1000 et sous-réseaux hors des supernets +tenant (`10.(10+index).0.0/16`) — **aucune collision possible** avec les overlays. `make +devis-reseau` en émet la config (section 0) et l'ajoute au trunk. La **preuve P23** garde la +règle ; elle est *sautée* si aucun `underlay.yml` n'est défini. + --- ## ③ Pourquoi c'est transférable