modèles : l'underlay devient modélisable, et P17 le valide

Un modèle décrivait un tenant — ses services, ses zones, ses bases. Tous les
hébergeurs n'ont pas le même matériel : l'infrastructure physique mérite le
même traitement.

Le modèle public gagne un underlay volontairement minimal (un commutateur,
pas de fabric de stockage séparée), point de départ honnête d'un petit
hébergeur. Les montages plus riches 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.

`modeles.py verifier` le valide (P17), facultativement et sur sa cohérence
INTERNE seulement — pas contre les tenants fédérés réels, un modèle étant un
gabarit et non un site déployé. Il a fallu rendre paramétrables deux
hypothèses du validateur, qui lisait la nomenclature de l'instance active et
globait les dépôts frères ; comportement par défaut inchangé.

Cinq cas de rejet exercés : 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.

Preuves : 24 OK, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-02 17:11:18 -04:00
parent 0eae97c6ec
commit 7256486c9c
7 changed files with 161 additions and 7 deletions

View file

@ -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,

View file

@ -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. |

View file

@ -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-<tenant>`, 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-<tenant>` |
| `underlay.yml` | le **matériel** qui les porte | `OPS-<hébergeur>` |
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é.

View file

@ -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-<hebergeur>/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 `<PORT-VERS-...>` à 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 }

View file

@ -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

View file

@ -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"],

View file

@ -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")