diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b3ad6..78d8252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # CHANGELOG — Set-OPS +## 2026-08-02 (suite 6) — l'underlay devient modélisable + +Un modèle décrivait jusqu'ici un **tenant** : ses services, ses zones, ses bases. Or tous les +hébergeurs n'ont pas le même matériel, et l'infrastructure physique mérite le même traitement. + +### Ajouté — `exemples/modeles/socle/underlay.yml` +Le modèle public gagne un underlay **volontairement minimal** : un seul commutateur, pas de +fabric de stockage séparée. C'est le point de départ honnête d'un petit hébergeur ; les +montages plus riches (étoile à trois commutateurs, paire en MLAG, stockage jumbo dédié) sont +d'autres modèles, conformément à la doctrine — un générique public, les étoffés en privé. + +Le modèle contient désormais **deux moitiés qui ne vont pas au même endroit** : `plan/` et +`inventories/` chez le tenant, `underlay.yml` chez l'hébergeur. Chez un hébergeur qui est son +propre tenant, les deux atterrissent au même dépôt — c'est le cas particulier, pas la règle. + +### Étendu — `modeles.py verifier` valide l'underlay (preuve P17) +La validation est **facultative** (un modèle sans underlay reste valide) et porte sur la +cohérence **interne** seulement : VLAN sous la plage tenant, sous-réseaux disjoints, +passerelle dans son réseau, routeur déclaré, ports non dupliqués, dernier octet partagé. + +Elle n'est **pas** confrontée aux tenants fédérés réels : un modèle est un gabarit, pas un +site déployé. Il a fallu pour cela rendre paramétrables deux hypothèses du validateur, qui +lisait la nomenclature de l'instance *active* et globait les dépôts frères — sur un modèle, +les deux auraient été faux. `charger_depuis()` et `plan_nomenclature=` ; comportement par +défaut inchangé. + +Cinq cas de rejet exercés sur un modèle fautif : VLAN empiétant sur la plage tenant, +passerelle au mauvais dernier octet (lue dans la nomenclature **du modèle**), routeur inconnu, +sortie hors du lien, port déclaré deux fois. + ## 2026-08-02 (suite 5) — l'underlay rejoint le dépôt de l'hébergeur `underlay.yml` vivait **gitignoré** à la racine du moteur : consommé par deux générateurs, diff --git a/docs/audit/preuve-2026-08-02.md b/docs/audit/preuve-2026-08-02.md index dad5912..26d1143 100644 --- a/docs/audit/preuve-2026-08-02.md +++ b/docs/audit/preuve-2026-08-02.md @@ -29,7 +29,7 @@ | P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | | P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 32 groupes (inventaire dechiffre et parse). | -| P17 | Tous les modeles d'instance valident | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | +| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | | P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 24 secret(s) exige(s), tous presents. | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. | diff --git a/exemples/modeles/socle/README.md b/exemples/modeles/socle/README.md index 215438f..2f7c2dd 100644 --- a/exemples/modeles/socle/README.md +++ b/exemples/modeles/socle/README.md @@ -6,3 +6,24 @@ vers un écosystème complet (voir modèle `integral`). Zones : Frontière (VLAN 16) · Services-infra (VLAN 19). VMID `ip-miroir` 9 chiffres. Copier vers `OPS-`, adapter `index`/domaine/intrants, puis `make instancier`. + +## Deux moitiés, deux propriétaires + +Ce modèle en contient **deux**, qui ne vont pas au même endroit : + +| Fichiers | Décrit | Copier vers | +|---|---|---| +| `plan/`, `inventories/` | les **services** d'une organisation | `OPS-` | +| `underlay.yml` | le **matériel** qui les porte | `OPS-` | + +Un tenant qui se fait héberger prend la première moitié seulement : l'underlay appartient à +qui possède les commutateurs. Chez un hébergeur qui est aussi son propre tenant, les deux +atterrissent dans le même dépôt — c'est un cas particulier, pas la règle. + +L'underlay fourni est **volontairement minimal** : un seul commutateur, pas de fabric de +stockage séparée. Tous les hébergeurs n'ont pas le même matériel ; les montages plus riches +(étoile à trois commutateurs, paire en MLAG, stockage jumbo dédié) relèvent d'autres modèles. + +Il est **facultatif** : un modèle sans `underlay.yml` reste valide. Présent, il est vérifié +pour sa cohérence interne par la preuve **P17**, sans être confronté aux tenants réels — un +modèle est un gabarit, pas un site déployé. diff --git a/exemples/modeles/socle/underlay.yml b/exemples/modeles/socle/underlay.yml new file mode 100644 index 0000000..4929f39 --- /dev/null +++ b/exemples/modeles/socle/underlay.yml @@ -0,0 +1,67 @@ +# Underlay du modèle « socle » — l'infrastructure PHYSIQUE la plus simple qui tienne. +# +# Un modèle d'underlay décrit le matériel d'un HÉBERGEUR, pas un plan de services : +# tous les hébergeurs n'ont pas les mêmes équipements. Celui-ci est volontairement +# minimal — un seul commutateur, pas de fabric de stockage séparée — parce que c'est +# le point de départ honnête d'un petit hébergeur. Les montages plus riches (étoile à +# trois commutateurs, paire en MLAG, stockage jumbo dédié) sont d'autres modèles. +# +# À l'usage : copier ce fichier dans le dépôt de l'hébergeur, l'adapter, puis le monter +# ln -s ../OPS-/underlay.yml underlay.yml +# Validé par `python3 scripts/modeles.py verifier` (preuve P17, structure du modèle) +# puis, une fois adapté et monté, par `make underlay` (site réel, preuve P23). +--- +underlay: + # LE commutateur, qui route tout. Avec un seul équipement il n'y a pas de switches + # d'accès : le devis n'émet donc que sa partie A. + routeur: switch-01 + + # Dialecte de CLI : décide la forme des masques d'ACL, des routes, du spanning-tree. + dialecte: cisco + + # Un seul commutateur = aucun lien redondant, donc aucune boucle par construction. + # RSTP reste actif comme filet : deux ports brassés ensemble par mégarde suffiraient + # à provoquer une tempête de diffusion. + stp: + mode: rstp + topologie: etoile + + reseaux: + # Plan de gestion : le commutateur lui-même, l'hyperviseur, l'OOB/IPMI. + - nom: management + description: Commutateur, mgmt de l'hyperviseur, OOB/IPMI + vlan: 10 + sous_reseau: 10.0.0.0/24 + passerelle: 10.0.0.1 + mtu: 1500 + + # Lien vers le pare-feu de bordure. SANS LUI, la flotte n'a ni sortie vers + # Internet ni chemin de retour vers l'administration : elle serait routée + # jusqu'à la bordure puis muette, ce qui est très difficile à diagnostiquer. + # `passerelle_sortie` = l'adresse du pare-feu sur ce lien ; c'est elle qui fait + # émettre la route par défaut et les routes de retour. + - nom: transit-frontiere + description: Lien commutateur L3 <-> pare-feu de bordure + vlan: 40 + sous_reseau: 10.0.4.0/29 + # Le /29 loge deux pare-feux (transition, ou haute disponibilité plus tard) : + # ils occupent le bas de la plage, le SVI du commutateur le haut. + passerelle: 10.0.4.6 # SVI du commutateur + passerelle_sortie: 10.0.4.1 # pare-feu = sortie par défaut de la flotte + mtu: 1500 + + # Équipements fixes. Le routeur PORTE le SVI de management : son adresse de gestion + # EST la passerelle du réseau — `make underlay` refuse deux valeurs divergentes. + hotes: + - nom: switch-01 + reseau: management + ip: 10.0.0.1 + # Ports physiques (optionnel). Déclarés, le devis sort applicable tel quel ; + # absents, il émet des marqueurs `` à remplacer à la main — et + # le travail est perdu à chaque régénération. + ports: + hyperviseurs: [Gi1/0/1] + frontiere: [Gi1/0/23] + # Le pare-feu de bordure : hors flotte Ansible, déclaré ici pour documenter le + # lien et réserver son nom. + - { nom: pare-feu-1, reseau: transit-frontiere, ip: 10.0.4.1 } diff --git a/scripts/modeles.py b/scripts/modeles.py index 4e24841..f9e631e 100644 --- a/scripts/modeles.py +++ b/scripts/modeles.py @@ -31,6 +31,7 @@ import sys from pathlib import Path RACINE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(RACINE / "scripts")) MODELES_DEPOT = RACINE / "exemples" / "modeles" VALIDATEURS = ("domaines", "serveurs", "applications", "bases_donnees") @@ -63,8 +64,33 @@ def decouvrir() -> list[Path]: return list(vus.values()) +def verifier_underlay(modele: Path) -> list[str]: + """Valide l'underlay du modele, s'il en porte un. Liste vide sinon. + + Un modele d'underlay decrit le MATERIEL d'un hebergeur : tous n'ont pas les memes + equipements, d'ou l'interet d'en avoir plusieurs. On le valide pour sa coherence + INTERNE (VLAN sous la plage tenant, sous-reseaux disjoints, passerelle dans son + reseau, routeur declare, ports non dupliques, dernier octet partage) — mais PAS + contre les tenants federes reels : un modele est un gabarit, pas un site deploye. + D'ou les listes de tenants vides, et la nomenclature du modele plutot que celle de + l'instance active. + """ + fichier = modele / "underlay.yml" + if not fichier.is_file(): + return [] + import underlay as underlay_mod + u = underlay_mod.charger_depuis(fichier) + if u is None: + return ["underlay: fichier present mais bloc `underlay:` absent ou vide"] + erreurs = underlay_mod.valider( + u, tenants_vlans=set(), tenants_supernets=[], + plan_nomenclature=modele / "plan" / "nomenclature.yml", + ) + return [f"underlay: {e}" for e in erreurs] + + def verifier_un(modele: Path) -> list[str]: - """Rejoue les 4 validateurs sur un modele. Renvoie la liste des erreurs.""" + """Rejoue les validateurs de registres sur un modele, + son underlay s'il en a un.""" erreurs: list[str] = [] env = {**os.environ, "SETOPS_INSTANCE": str(modele)} env.pop("SETOPS_INVENTAIRE", None) @@ -74,6 +100,7 @@ def verifier_un(modele: Path) -> list[str]: if r.returncode != 0: lignes = (r.stdout + r.stderr).strip().splitlines() erreurs.append(f"{nom}: {lignes[-1] if lignes else 'rc!=0'}") + erreurs += verifier_underlay(modele) return erreurs diff --git a/scripts/prouver.py b/scripts/prouver.py index 3175e58..7e45735 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -275,7 +275,7 @@ PREUVES: list[dict] = [ "func": preuve_pas_lab_code_en_dur}, {"id": "P15", "titre": "Modele public socle valide", "refs": ["AFF-022", "AFF-099"], "func": preuve_modele_socle}, - {"id": "P17", "titre": "Tous les modeles d'instance valident", + {"id": "P17", "titre": "Tous les modeles valident (registres + underlay)", "refs": ["AFF-022", "AFF-099"], "cmds": [[sys.executable, "scripts/modeles.py", "verifier"]]}, {"id": "P18", "titre": "Gabarit de voute complet", "refs": ["AFF-026"], diff --git a/scripts/underlay.py b/scripts/underlay.py index 31f39a9..d64830f 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -181,14 +181,22 @@ def reseau_transit(underlay: dict | None) -> dict | None: return None -def octet_passerelle() -> int | None: +def charger_depuis(chemin_fichier: Path) -> dict | None: + """Le bloc `underlay` d'un fichier DESIGNE — pour valider un modele, pas le site.""" + if not chemin_fichier.is_file(): + return None + data = yaml.safe_load(chemin_fichier.read_text(encoding="utf-8")) or {} + return data.get("underlay") or None + + +def octet_passerelle(plan_nomenclature: Path | None = None) -> int | None: """Dernier octet reserve aux points de routage, lu dans la nomenclature active. Convention d'exploitation : un routeur porte LE MEME dernier octet sur tous les sous-reseaux ou il participe — on retient une adresse, pas treize. Declare une seule fois (`reservations.passerelle`), jamais code en dur ici. """ - p = RACINE / "instance" / "plan" / "nomenclature.yml" + p = plan_nomenclature or (RACINE / "instance" / "plan" / "nomenclature.yml") if not p.is_file(): return None n = yaml.safe_load(p.read_text(encoding="utf-8")) or {} @@ -207,7 +215,8 @@ def _tenants_reperes() -> tuple[set[int], list[str]]: def valider(underlay: dict | None, tenants_vlans: set[int] | None = None, - tenants_supernets: list[str] | None = None) -> list[str]: + tenants_supernets: list[str] | None = None, + plan_nomenclature: Path | 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: @@ -291,7 +300,7 @@ def valider(underlay: dict | None, # Invariant du dernier octet : tous les points de routage doivent le partager. # Exemption des sous-reseaux plus etroits qu'un /24 : sur un /29 de transit, # l'adressage est dicte par les participants du lien, pas par la convention. - attendu = octet_passerelle() + attendu = octet_passerelle(plan_nomenclature) if attendu is not None: for r in reseaux(underlay): gw = r.get("passerelle")