2026-07-24 14:56:38 -04:00
|
|
|
#!/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).
|
|
|
|
|
|
underlay : monté par symlink depuis le dépôt de l'hébergeur
Il vivait gitignoré à la racine du moteur : consommé par deux générateurs,
validé par P23, versionné nulle part. La dizaine de modifications de la
journée n'était récupérable d'aucune façon, et un clone frais repartait du
gabarit.
Il appartient à l'hébergeur — ses switches, ses câbles — pas au moteur, qui
est générique, ni à un tenant, qui n'en possède pas. Chezlepro est ici
hébergeur ET tenant, d'où la confusion initiale.
Le moteur le monte par symlink comme il monte le plan par `instance/`. Ce
lien NE SUIT PAS `make instance-utiliser` : la fabric reste celle de
l'hébergeur quel que soit le tenant actif.
Vérifié : les deux devis sortent identiques octet pour octet avant et après,
P23 verte, 24 preuves. Symlink brisé (clone sans dépôt frère) : dégrade
proprement, sections omises au lieu d'échouer. Cas exercé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:05:12 -04:00
|
|
|
EMPLACEMENT : l'underlay appartient a l'HEBERGEUR — ses switches, ses cables. Il vit
|
|
|
|
|
donc dans SON depot, et le moteur le monte par symlink :
|
|
|
|
|
|
|
|
|
|
Set-OPS-public/underlay.yml -> ../OPS-<hebergeur>/underlay.yml
|
|
|
|
|
|
|
|
|
|
Ce symlink NE SUIT PAS `make instance-utiliser` : la fabric reste celle de l'hebergeur,
|
|
|
|
|
quel que soit le tenant actif. Surchargeable par `SETOPS_UNDERLAY`. Absent (ou lien
|
|
|
|
|
brise, cas d'un clone sans depot frere) => le devis omet simplement la section.
|
2026-07-24 14:56:38 -04:00
|
|
|
|
|
|
|
|
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:
|
underlay : monté par symlink depuis le dépôt de l'hébergeur
Il vivait gitignoré à la racine du moteur : consommé par deux générateurs,
validé par P23, versionné nulle part. La dizaine de modifications de la
journée n'était récupérable d'aucune façon, et un clone frais repartait du
gabarit.
Il appartient à l'hébergeur — ses switches, ses câbles — pas au moteur, qui
est générique, ni à un tenant, qui n'en possède pas. Chezlepro est ici
hébergeur ET tenant, d'où la confusion initiale.
Le moteur le monte par symlink comme il monte le plan par `instance/`. Ce
lien NE SUIT PAS `make instance-utiliser` : la fabric reste celle de
l'hébergeur quel que soit le tenant actif.
Vérifié : les deux devis sortent identiques octet pour octet avant et après,
P23 verte, 24 preuves. Symlink brisé (clone sans dépôt frère) : dégrade
proprement, sections omises au lieu d'échouer. Cas exercé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:05:12 -04:00
|
|
|
"""Emplacement du underlay.yml : SETOPS_UNDERLAY, sinon le symlink a la racine.
|
|
|
|
|
|
|
|
|
|
`exists()` suit le lien : un symlink brise (clone sans le depot de l'hebergeur)
|
|
|
|
|
rend None, et tout degrade proprement au lieu d'echouer.
|
|
|
|
|
"""
|
2026-07-24 14:56:38 -04:00
|
|
|
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 []
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 20:36:02 -04:00
|
|
|
FABRIC_DEFAUT = "principal"
|
|
|
|
|
|
2026-08-01 20:50:55 -04:00
|
|
|
# Priorites de pont : le routeur est la racine (basse), les switches d'acces ne
|
|
|
|
|
# doivent jamais l'etre. 4096 laisse 0 disponible pour une reprise d'urgence.
|
|
|
|
|
STP_PRIORITE_RACINE = 4096
|
|
|
|
|
STP_PRIORITE_ACCES = 32768
|
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>
2026-08-01 22:01:00 -04:00
|
|
|
# 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")
|
2026-08-01 20:50:55 -04:00
|
|
|
STP_MODES = ("rstp", "mstp", "pvst")
|
|
|
|
|
STP_TOPOLOGIES = ("etoile", "anneau", "maille")
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 00:45:56 -04:00
|
|
|
def acl_inter_tenant(underlay: dict | None) -> bool:
|
|
|
|
|
"""La fabric porte-t-elle l'isolation inter-tenant par ACL ? Vrai par defaut.
|
|
|
|
|
|
|
|
|
|
A mettre a `false` quand le materiel ne sait pas lier une ACL a une interface de
|
|
|
|
|
routage — cas du Binardat, dont les interfaces VLAN n'offrent aucun `access-group`.
|
|
|
|
|
Le devis cesse alors d'emettre des ACL qui ne seraient jamais liees : mieux vaut
|
|
|
|
|
aucune regle qu'une regle inoperante qu'on croit active.
|
|
|
|
|
|
|
|
|
|
Conséquence assumee, et ecrite dans le devis : l'isolation repose entierement sur
|
|
|
|
|
les nftables d'hote, et le plan de gestion de la fabric n'a plus de protection
|
|
|
|
|
RESEAU contre les tenants.
|
|
|
|
|
"""
|
|
|
|
|
v = (underlay or {}).get("acl_inter_tenant")
|
|
|
|
|
return True if v is None else bool(v)
|
|
|
|
|
|
|
|
|
|
|
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>
2026-08-01 22:01:00 -04:00
|
|
|
def dialecte(underlay: dict | None) -> str:
|
|
|
|
|
"""Dialecte de CLI declare pour la fabric. '' si absent."""
|
|
|
|
|
return str((underlay or {}).get("dialecte") or "")
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 20:50:55 -04:00
|
|
|
def stp(underlay: dict | None) -> dict:
|
|
|
|
|
"""Parametres de spanning-tree de la fabric principale. {} si non declares."""
|
|
|
|
|
return (underlay or {}).get("stp") or {}
|
|
|
|
|
|
2026-08-01 20:36:02 -04:00
|
|
|
|
|
|
|
|
def fabric_de(reseau: dict) -> str:
|
|
|
|
|
"""Fabric physique qui porte ce reseau. `principal` par defaut."""
|
|
|
|
|
return str(reseau.get("fabric") or FABRIC_DEFAUT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fabriques(underlay: dict | None) -> list[str]:
|
|
|
|
|
"""Les fabrics declarees, `principal` en tete."""
|
|
|
|
|
vues = {fabric_de(r) for r in reseaux(underlay)}
|
|
|
|
|
return [FABRIC_DEFAUT] * (FABRIC_DEFAUT in vues) + sorted(vues - {FABRIC_DEFAUT})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reseaux_de_fabric(underlay: dict | None, fabric: str) -> list[dict]:
|
|
|
|
|
return [r for r in reseaux(underlay) if fabric_de(r) == fabric]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fabric_du_routeur(underlay: dict | None) -> str:
|
|
|
|
|
"""Fabric du switch routeur : celle du reseau ou il est declare comme hote."""
|
|
|
|
|
r_nom = routeur(underlay)
|
|
|
|
|
par_nom = {r.get("nom"): r for r in reseaux(underlay)}
|
|
|
|
|
for h in hotes(underlay):
|
|
|
|
|
if h.get("nom") == r_nom and h.get("reseau") in par_nom:
|
|
|
|
|
return fabric_de(par_nom[h["reseau"]])
|
|
|
|
|
return FABRIC_DEFAUT
|
|
|
|
|
|
|
|
|
|
|
devis switch : un seul routeur, les autres en L2 pur
Sans MLAG, le routage est porté par un unique switch (`underlay.routeur`).
Le devis émettait un jeu unique de SVI sans dire à quel switch il
s'adressait : appliqué sur les trois, il aurait créé autant de conflits
d'adresses qu'il y a de zones.
Il se scinde désormais en deux :
- partie A (switch routeur) : VLANs, SVI, ACL, trunks, routes ;
- partie B (switches d'accès, L2 pur) : mêmes VLANs pour commuter les trames
étiquetées, une adresse de gestion par switch tirée de `underlay.hotes`
avec `ip default-gateway` vers le routeur, et les trunks. Aucun SVI de
zone, aucune ACL, aucune route.
Gardes : `make underlay` refuse un `routeur` inconnu des hôtes déclarés ; la
partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine.
Sans routeur désigné, l'en-tête signale le risque de duplication au lieu de
laisser croire que le devis est applicable partout.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:04:38 -04:00
|
|
|
def routeur(underlay: dict | None) -> str | None:
|
|
|
|
|
"""Nom du switch qui porte les SVI, ou None si aucun n'est designe.
|
|
|
|
|
|
|
|
|
|
Sans MLAG, UN SEUL switch route : les autres restent en L2 pur. Appliquer le
|
|
|
|
|
meme devis sur les trois creerait autant de conflits d'adresses que de SVI.
|
|
|
|
|
"""
|
|
|
|
|
return (underlay or {}).get("routeur") or None
|
|
|
|
|
|
|
|
|
|
|
underlay : les ports physiques entrent dans le modèle
Les noms de ports n'étaient modélisés nulle part — des marqueurs littéraux
dans le générateur, remplacés à la main dans la sortie et perdus à chaque
régénération. Seul endroit du devis où le travail était refait à répétition.
Ils se déclarent par équipement sous quatre clefs correspondant aux quatre
natures de lien : `hyperviseurs` et `frontiere` (terminaux, portfast),
`rayons` (côté routeur), `montante` (côté switch d'accès).
Le devis émet les vrais ports, y compris PLUSIEURS vers les hyperviseurs —
il n'en supposait qu'un, alors que le cluster en compte trois. Non déclarés,
les marqueurs reviennent, par équipement : un switch renseigné et un autre
non cohabitent.
La partie B devient par switch : gestion, montante et ports terminaux
diffèrent d'une machine à l'autre, un bloc commun n'avait plus de sens.
Gardes exercées : port déclaré deux fois, rayon vers un switch inconnu,
`rayons` sur autre chose que le routeur, `montante` sur le routeur.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:47:15 -04:00
|
|
|
def ports_de(hote: dict) -> dict:
|
|
|
|
|
"""Ports physiques declares d'un equipement. {} si aucun.
|
|
|
|
|
|
|
|
|
|
Trois clefs, qui correspondent aux trois natures de lien du devis :
|
|
|
|
|
`hyperviseurs` (liste) ports terminaux vers Proxmox ;
|
|
|
|
|
`frontiere` (liste) port(s) vers le pare-feu de bordure ;
|
|
|
|
|
`rayons` (table) {nom du switch d'acces: port}, cote routeur ;
|
|
|
|
|
`montante` (str) port vers le routeur, cote switch d'acces.
|
|
|
|
|
Non declares, le devis retombe sur ses marqueurs : il reste lisible, il n'est
|
|
|
|
|
simplement pas applicable tel quel.
|
|
|
|
|
"""
|
|
|
|
|
return (hote or {}).get("ports") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ports_a_plat(hote: dict) -> list[str]:
|
|
|
|
|
"""Tous les ports d'un equipement, a plat — pour detecter un doublon."""
|
|
|
|
|
p = ports_de(hote)
|
|
|
|
|
plat = [str(x) for x in (p.get("hyperviseurs") or [])]
|
|
|
|
|
plat += [str(x) for x in (p.get("frontiere") or [])]
|
|
|
|
|
plat += [str(v) for v in (p.get("rayons") or {}).values()]
|
|
|
|
|
if p.get("montante"):
|
|
|
|
|
plat.append(str(p["montante"]))
|
|
|
|
|
return plat
|
|
|
|
|
|
|
|
|
|
|
2026-08-01 21:31:31 -04:00
|
|
|
def switches_acces(underlay: dict | None) -> list[dict]:
|
|
|
|
|
"""Les switches d'acces : hotes du reseau de management, hors routeur.
|
|
|
|
|
|
|
|
|
|
Source unique du « qui est un switch d'acces » — utilisee pour leur devis (partie B)
|
|
|
|
|
ET pour les rayons de l'etoile emis cote routeur. Les deux ne peuvent pas diverger.
|
|
|
|
|
"""
|
|
|
|
|
r_nom = routeur(underlay)
|
|
|
|
|
if not r_nom:
|
|
|
|
|
return []
|
|
|
|
|
fabric = fabric_du_routeur(underlay)
|
|
|
|
|
mgmt = next((r for r in reseaux_de_fabric(underlay, fabric) if r.get("passerelle")), None)
|
|
|
|
|
if not mgmt:
|
|
|
|
|
return []
|
|
|
|
|
return [h for h in hotes(underlay)
|
|
|
|
|
if h.get("nom") != r_nom and h.get("reseau") == mgmt["nom"]]
|
|
|
|
|
|
|
|
|
|
|
frontière nord/sud : devis dérivé, lien de transit et les deux routes
La bordure devient un artefact dérivé, comme le devis switch — et le chemin
qui y mène est enfin déclaré.
`make devis-opnsense` (+ preuve P24) dérive la politique de bordure du
registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute
volontairement parce qu'ils relèvent de la frontière et non du pare-feu
d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur.
Le lien manquait dans tous les fichiers : le devis switch ne contenait pas
une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le
déclare — il vit dans l'underlay et non dans un tenant parce que la
frontière route vers TOUS les supernets tenants par le même saut, donc il
ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes :
l'aller (sortie générale) et le retour vers l'administration, dont l'absence
a coûté la passe de déploiement du 2026-07-29 — la réponse revient au
pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence.
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` :
même source unique que la garde anti-lockout des nftables et l'alias
SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger.
La frontière est réglable depuis la console (section « Frontière » du
panneau Intrants) ; les identifiants d'API restent interdits d'écriture par
le GUI et vivent dans la voûte.
Correctifs de la même passe :
- le panneau refusait d'enregistrer les intrants de la frontière : le
garde-fou confondait une référence de voûte `{{ vault_* }}` préservée
avec un secret soumis. Il regarde désormais la valeur, pas le nom.
- `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses
secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le
playbook de clonage, voûte unique en dernier.
- documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un
reliquat de compatibilité.
Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un
par un ; résolution du jeton Proxmox vérifiée en exécution réelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
|
|
|
def reseau_transit(underlay: dict | None) -> dict | None:
|
|
|
|
|
"""Le reseau de TRANSIT vers la frontiere nord/sud, s'il est declare.
|
|
|
|
|
|
|
|
|
|
Convention : c'est le reseau underlay qui porte `passerelle_sortie` — l'adresse
|
|
|
|
|
du pare-feu de bordure sur ce lien. Il vit dans l'underlay et non dans un tenant
|
|
|
|
|
parce qu'il est PARTAGE : la frontiere route vers tous les supernets tenants par
|
|
|
|
|
ce meme saut, il ne peut donc deriver d'aucun `index`.
|
|
|
|
|
"""
|
|
|
|
|
for r in reseaux(underlay):
|
|
|
|
|
if r.get("passerelle_sortie"):
|
|
|
|
|
return r
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
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>
2026-08-02 17:11:18 -04:00
|
|
|
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:
|
2026-08-02 16:06:55 -04:00
|
|
|
"""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.
|
|
|
|
|
"""
|
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>
2026-08-02 17:11:18 -04:00
|
|
|
p = plan_nomenclature or (RACINE / "instance" / "plan" / "nomenclature.yml")
|
2026-08-02 16:06:55 -04:00
|
|
|
if not p.is_file():
|
|
|
|
|
return None
|
|
|
|
|
n = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
|
|
|
|
v = (n.get("reservations") or {}).get("passerelle")
|
|
|
|
|
return int(v) if isinstance(v, int) else None
|
|
|
|
|
|
|
|
|
|
|
2026-07-24 14:56:38 -04:00
|
|
|
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,
|
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>
2026-08-02 17:11:18 -04:00
|
|
|
tenants_supernets: list[str] | None = None,
|
|
|
|
|
plan_nomenclature: Path | None = None) -> list[str]:
|
2026-07-24 14:56:38 -04:00
|
|
|
"""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")
|
frontière nord/sud : devis dérivé, lien de transit et les deux routes
La bordure devient un artefact dérivé, comme le devis switch — et le chemin
qui y mène est enfin déclaré.
`make devis-opnsense` (+ preuve P24) dérive la politique de bordure du
registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute
volontairement parce qu'ils relèvent de la frontière et non du pare-feu
d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur.
Le lien manquait dans tous les fichiers : le devis switch ne contenait pas
une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le
déclare — il vit dans l'underlay et non dans un tenant parce que la
frontière route vers TOUS les supernets tenants par le même saut, donc il
ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes :
l'aller (sortie générale) et le retour vers l'administration, dont l'absence
a coûté la passe de déploiement du 2026-07-29 — la réponse revient au
pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence.
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` :
même source unique que la garde anti-lockout des nftables et l'alias
SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger.
La frontière est réglable depuis la console (section « Frontière » du
panneau Intrants) ; les identifiants d'API restent interdits d'écriture par
le GUI et vivent dans la voûte.
Correctifs de la même passe :
- le panneau refusait d'enregistrer les intrants de la frontière : le
garde-fou confondait une référence de voûte `{{ vault_* }}` préservée
avec un secret soumis. Il regarde désormais la valeur, pas le nom.
- `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses
secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le
playbook de clonage, voûte unique en dernier.
- documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un
reliquat de compatibilité.
Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un
par un ; résolution du jeton Proxmox vérifiée en exécution réelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
|
|
|
# Transit vers la frontiere : la sortie doit etre SUR le lien, et distincte du SVI.
|
|
|
|
|
if r.get("passerelle_sortie"):
|
|
|
|
|
try:
|
|
|
|
|
sortie = ipaddress.ip_address(r["passerelle_sortie"])
|
|
|
|
|
if sortie not in net:
|
|
|
|
|
erreurs.append(f"reseau '{nom}': passerelle_sortie {sortie} hors de {sous}")
|
|
|
|
|
elif r.get("passerelle") and str(sortie) == str(r["passerelle"]):
|
|
|
|
|
erreurs.append(f"reseau '{nom}': passerelle_sortie {sortie} identique au SVI "
|
|
|
|
|
f"(le switch ne peut pas etre sa propre sortie)")
|
|
|
|
|
elif not r.get("passerelle"):
|
|
|
|
|
erreurs.append(f"reseau '{nom}': passerelle_sortie exige aussi 'passerelle' "
|
|
|
|
|
f"(le SVI du switch sur le lien de transit)")
|
|
|
|
|
except ValueError:
|
|
|
|
|
erreurs.append(f"reseau '{nom}': passerelle_sortie '{r['passerelle_sortie']}' invalide")
|
2026-07-24 14:56:38 -04:00
|
|
|
vus_sous.append((net, nom))
|
|
|
|
|
|
underlay : les ports physiques entrent dans le modèle
Les noms de ports n'étaient modélisés nulle part — des marqueurs littéraux
dans le générateur, remplacés à la main dans la sortie et perdus à chaque
régénération. Seul endroit du devis où le travail était refait à répétition.
Ils se déclarent par équipement sous quatre clefs correspondant aux quatre
natures de lien : `hyperviseurs` et `frontiere` (terminaux, portfast),
`rayons` (côté routeur), `montante` (côté switch d'accès).
Le devis émet les vrais ports, y compris PLUSIEURS vers les hyperviseurs —
il n'en supposait qu'un, alors que le cluster en compte trois. Non déclarés,
les marqueurs reviennent, par équipement : un switch renseigné et un autre
non cohabitent.
La partie B devient par switch : gestion, montante et ports terminaux
diffèrent d'une machine à l'autre, un bloc commun n'avait plus de sens.
Gardes exercées : port déclaré deux fois, rayon vers un switch inconnu,
`rayons` sur autre chose que le routeur, `montante` sur le routeur.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:47:15 -04:00
|
|
|
# Ports physiques : un meme port ne peut pas servir deux fois sur un equipement,
|
|
|
|
|
# et un rayon ne peut viser qu'un switch d'acces reellement declare.
|
|
|
|
|
noms_acces = {h.get("nom") for h in switches_acces(underlay)}
|
|
|
|
|
r_courant = routeur(underlay)
|
|
|
|
|
for h in hotes(underlay):
|
|
|
|
|
hn = h.get("nom", "?")
|
|
|
|
|
plat = _ports_a_plat(h)
|
|
|
|
|
doublons = sorted({p for p in plat if plat.count(p) > 1})
|
|
|
|
|
if doublons:
|
|
|
|
|
erreurs.append(f"hote '{hn}': port(s) declare(s) deux fois : {', '.join(doublons)}")
|
|
|
|
|
p = ports_de(h)
|
|
|
|
|
for cible in (p.get("rayons") or {}):
|
|
|
|
|
if cible not in noms_acces:
|
|
|
|
|
erreurs.append(f"hote '{hn}': rayon vers '{cible}', qui n'est pas un switch d'acces")
|
|
|
|
|
if p.get("rayons") and hn != r_courant:
|
|
|
|
|
erreurs.append(f"hote '{hn}': des `rayons` mais ce n'est pas le routeur")
|
|
|
|
|
if p.get("montante") and hn == r_courant:
|
|
|
|
|
erreurs.append(f"hote '{hn}': une `montante` alors qu'il EST le routeur")
|
|
|
|
|
|
2026-08-02 16:06:55 -04:00
|
|
|
# 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.
|
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>
2026-08-02 17:11:18 -04:00
|
|
|
attendu = octet_passerelle(plan_nomenclature)
|
2026-08-02 16:06:55 -04:00
|
|
|
if attendu is not None:
|
|
|
|
|
for r in reseaux(underlay):
|
|
|
|
|
gw = r.get("passerelle")
|
|
|
|
|
if not gw:
|
|
|
|
|
continue
|
|
|
|
|
try:
|
|
|
|
|
net = ipaddress.ip_network(r["sous_reseau"], strict=False)
|
|
|
|
|
except ValueError:
|
|
|
|
|
continue
|
|
|
|
|
if net.prefixlen > 24:
|
|
|
|
|
continue # lien etroit : exempte, cf. le /29 de transit
|
|
|
|
|
octet = int(str(gw).rsplit(".", 1)[1])
|
|
|
|
|
if octet != attendu:
|
|
|
|
|
erreurs.append(
|
|
|
|
|
f"reseau '{r.get('nom','?')}': passerelle {gw} finit par .{octet} alors que "
|
|
|
|
|
f"les points de routage portent .{attendu} partout "
|
|
|
|
|
f"(nomenclature: reservations.passerelle)")
|
|
|
|
|
|
2026-08-03 00:45:56 -04:00
|
|
|
v = (underlay or {}).get("acl_inter_tenant")
|
|
|
|
|
if v is not None and not isinstance(v, bool):
|
|
|
|
|
erreurs.append(f"acl_inter_tenant doit etre un booleen (recu : {v!r})")
|
|
|
|
|
|
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>
2026-08-01 22:01:00 -04:00
|
|
|
d = dialecte(underlay)
|
|
|
|
|
if d and d not in DIALECTES:
|
|
|
|
|
erreurs.append(f"dialecte '{d}' inconnu (attendu : {', '.join(DIALECTES)})")
|
|
|
|
|
|
2026-08-01 20:50:55 -04:00
|
|
|
s = stp(underlay)
|
|
|
|
|
if s:
|
|
|
|
|
if str(s.get("mode", "")) not in STP_MODES:
|
|
|
|
|
erreurs.append(f"stp.mode '{s.get('mode')}' inconnu (attendu : {', '.join(STP_MODES)})")
|
|
|
|
|
if str(s.get("topologie", "")) not in STP_TOPOLOGIES:
|
|
|
|
|
erreurs.append(f"stp.topologie '{s.get('topologie')}' inconnue "
|
|
|
|
|
f"(attendu : {', '.join(STP_TOPOLOGIES)})")
|
|
|
|
|
if not routeur(underlay):
|
|
|
|
|
erreurs.append("stp declare sans `routeur` : impossible de designer le pont racine")
|
|
|
|
|
|
frontière nord/sud : devis dérivé, lien de transit et les deux routes
La bordure devient un artefact dérivé, comme le devis switch — et le chemin
qui y mène est enfin déclaré.
`make devis-opnsense` (+ preuve P24) dérive la politique de bordure du
registre des flux : les flux `pair: externe`, que `resoudre_flux.py` saute
volontairement parce qu'ils relèvent de la frontière et non du pare-feu
d'hôte. Aucun port, aucune adresse, aucun nom d'hôte dans le générateur.
Le lien manquait dans tous les fichiers : le devis switch ne contenait pas
une seule `ip route`. Un réseau underlay portant `passerelle_sortie` le
déclare — il vit dans l'underlay et non dans un tenant parce que la
frontière route vers TOUS les supernets tenants par le même saut, donc il
ne peut dériver d'aucun `index`. `devis-reseau` en tire deux routes :
l'aller (sortie générale) et le retour vers l'administration, dont l'absence
a coûté la passe de déploiement du 2026-07-29 — la réponse revient au
pare-feu par une autre interface que celle où l'état a été créé, et se fait
jeter en silence.
Les réseaux d'administration viennent de l'intrant `nftables_admin_ssh` :
même source unique que la garde anti-lockout des nftables et l'alias
SETOPS_ADMIN. Les trois pare-feux et les routes ne peuvent plus diverger.
La frontière est réglable depuis la console (section « Frontière » du
panneau Intrants) ; les identifiants d'API restent interdits d'écriture par
le GUI et vivent dans la voûte.
Correctifs de la même passe :
- le panneau refusait d'enregistrer les intrants de la frontière : le
garde-fou confondait une référence de voûte `{{ vault_* }}` préservée
avec un secret soumis. Il regarde désormais la valeur, pas le nom.
- `supprimer_vm_debian.yml` ne chargeait que `proxmox.vault.yml` pour ses
secrets ; retirer ce reliquat aurait cassé `make detruire`. Aligné sur le
playbook de clonage, voûte unique en dernier.
- documentation : la voûte est unique, `proxmox.vault.yml` n'est qu'un
reliquat de compatibilité.
Preuves : 24 OK, 0 échec. Cas de rejet du validateur d'underlay exercés un
par un ; résolution du jeton Proxmox vérifiée en exécution réelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:32:04 -04:00
|
|
|
# Une seule sortie possible : deux transits donneraient deux routes par defaut.
|
|
|
|
|
transits = [r.get("nom", "?") for r in reseaux(underlay) if r.get("passerelle_sortie")]
|
|
|
|
|
if len(transits) > 1:
|
|
|
|
|
erreurs.append(f"un seul reseau de transit autorise (passerelle_sortie), "
|
|
|
|
|
f"trouves : {', '.join(transits)}")
|
|
|
|
|
|
devis switch : un seul routeur, les autres en L2 pur
Sans MLAG, le routage est porté par un unique switch (`underlay.routeur`).
Le devis émettait un jeu unique de SVI sans dire à quel switch il
s'adressait : appliqué sur les trois, il aurait créé autant de conflits
d'adresses qu'il y a de zones.
Il se scinde désormais en deux :
- partie A (switch routeur) : VLANs, SVI, ACL, trunks, routes ;
- partie B (switches d'accès, L2 pur) : mêmes VLANs pour commuter les trames
étiquetées, une adresse de gestion par switch tirée de `underlay.hotes`
avec `ip default-gateway` vers le routeur, et les trunks. Aucun SVI de
zone, aucune ACL, aucune route.
Gardes : `make underlay` refuse un `routeur` inconnu des hôtes déclarés ; la
partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine.
Sans routeur désigné, l'en-tête signale le risque de duplication au lieu de
laisser croire que le devis est applicable partout.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:04:38 -04:00
|
|
|
# Le routeur designe doit etre un hote declare : sinon le devis ne saurait pas
|
|
|
|
|
# quelle adresse de gestion donner aux switches d'acces.
|
|
|
|
|
r_nom = routeur(underlay)
|
|
|
|
|
if r_nom and r_nom not in {h.get("nom") for h in hotes(underlay)}:
|
|
|
|
|
erreurs.append(f"routeur '{r_nom}': aucun hote de ce nom n'est declare")
|
2026-08-01 20:10:42 -04:00
|
|
|
# Le routeur PORTE le SVI : son adresse de gestion est la passerelle du reseau, pas
|
|
|
|
|
# une adresse d'hote a part. Deux valeurs differentes se contrediraient — une
|
|
|
|
|
# interface VLAN n'a qu'une adresse primaire.
|
|
|
|
|
for h in hotes(underlay):
|
|
|
|
|
if h.get("nom") != r_nom:
|
|
|
|
|
continue
|
|
|
|
|
r = {x.get("nom"): x for x in reseaux(underlay)}.get(h.get("reseau")) or {}
|
|
|
|
|
gw = r.get("passerelle")
|
|
|
|
|
if gw and str(h.get("ip")) != str(gw):
|
|
|
|
|
erreurs.append(f"routeur '{r_nom}': ip {h.get('ip')} != passerelle {gw} du reseau "
|
|
|
|
|
f"'{h.get('reseau')}' — le routeur porte ce SVI, les deux doivent coincider")
|
devis switch : un seul routeur, les autres en L2 pur
Sans MLAG, le routage est porté par un unique switch (`underlay.routeur`).
Le devis émettait un jeu unique de SVI sans dire à quel switch il
s'adressait : appliqué sur les trois, il aurait créé autant de conflits
d'adresses qu'il y a de zones.
Il se scinde désormais en deux :
- partie A (switch routeur) : VLANs, SVI, ACL, trunks, routes ;
- partie B (switches d'accès, L2 pur) : mêmes VLANs pour commuter les trames
étiquetées, une adresse de gestion par switch tirée de `underlay.hotes`
avec `ip default-gateway` vers le routeur, et les trunks. Aucun SVI de
zone, aucune ACL, aucune route.
Gardes : `make underlay` refuse un `routeur` inconnu des hôtes déclarés ; la
partie B avertit qu'un seul de ses blocs de gestion va sur chaque machine.
Sans routeur désigné, l'en-tête signale le risque de duplication au lieu de
laisser croire que le devis est applicable partout.
Preuves : 24 OK, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:04:38 -04:00
|
|
|
|
2026-07-24 14:56:38 -04:00
|
|
|
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()
|