réseau : le VNet d'une VM se dérive, un hyperviseur a plusieurs pattes (D-55 à D-59)

Le pont n'était pas seulement non portable, il était faux. proxmox_clone_pont
faisait naître les VM sur vmbr1 avec une étiquette VLAN — l'ancien monde. En SDN
une VM appartient à son VNet ; c'est ce qu'il a fallu corriger à la main sur
infra-pki-01, et les treize suivantes auraient suivi.

deriver_nomenclature() expose désormais la zone de sécurité, instancier en dérive
proxmox_pont et une étiquette VIDE — le VNet porte déjà le tag, en poser un
second donnerait un double étiquetage. La chaîne va jusqu'à make creer-vm :
SETOPS_PONT='t11appl', SETOPS_VLAN=''.

Trois pièges. Un doublon dans le Makefile passait PONT_PROXMOX deux fois dans la
même cible, la seconde vide aurait écrasé la valeur dérivée. Un repli naïf sur
proxmox_vlan aurait fait revenir l'étiquette en SDN : le repli ne s'applique que
si la clé est ABSENTE, jamais si elle est présente et vide. Et le test unitaire
est tombé, à raison — il couvre maintenant cette distinction.

D-55 : le dépôt réseau porte le contrat entre l'Alliance et ses hébergeurs, et
abstrait le matériel en encapsulant chaque tenant dans sa zone EVPN. Mesuré : un
tenant est à deux valeurs de la portabilité complète (noeud, stockage).

D-57 : l'interface sysadmin d'un hyperviseur (vmbr0, 10.0.0.41/.43/.47) n'a pas
de route par défaut ; celle-ci vit sur vlan40, vers la frontière. On n'atteint
l'administration que depuis son propre domaine de diffusion. Ça tranche la
question de la sortie des nœuds laissée ouverte ce matin — option A, mais sur une
interface dédiée, ce qui lève l'objection qui la bloquait.

D-58 : un hôte déclare par quelle interface (`via`) chaque réseau lui arrive ; le
devis en dérive un port par interface et son type — trunk 11,40 sur bond3, accès
VLAN 10 sur vmbr0. Sans ça, ajouter le VLAN 10 le remettait sur le trunk du
transport, soit le domaine qu'on venait d'en sortir.

D-59 : un VLAN qui ne porte que des adresses d'hôte n'a pas besoin de pont.

Régression créée puis corrigée : le modèle public, qui ne déclare aucun
hyperviseur, n'émettait plus rien pour ce port. Il émet maintenant tout
l'underlay en disant que c'est un repli.

30 preuves OK, 4 tests unitaires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-04 16:20:56 -04:00
parent 63aa79e710
commit 89f33c5b43
10 changed files with 263 additions and 12 deletions

View file

@ -1,5 +1,80 @@
# CHANGELOG — Set-OPS # CHANGELOG — Set-OPS
## 2026-08-04 (suite 3) — le VNet d'une VM se dérive, et un hyperviseur a plusieurs pattes
### Le pont n'était pas seulement non portable : il était faux
`proxmox_clone_pont` faisait naître les VM sur `vmbr1` **avec une étiquette VLAN**
l'ancien monde. En SDN, une VM appartient à son **VNet**. C'est ce qu'il a fallu corriger
à la main sur `infra-pki-01`, et **les treize suivantes auraient suivi**.
Le VNet est dérivable : `index` + zone de sécurité → `t17serv`, comme le VMID et l'adresse
le sont déjà. `deriver_nomenclature()` expose désormais la zone, `instancier` émet
`proxmox_pont` **et une étiquette vide** — le VNet porte déjà le tag, en poser un second
donnerait un double étiquetage.
```
SETOPS_PONT='t11appl'
SETOPS_VLAN=''
```
Trois pièges en chemin. Un **doublon dans le Makefile** passait `PONT_PROXMOX` deux fois
dans la même cible : la seconde, vide, aurait écrasé la valeur dérivée. Un **repli naïf**
sur `proxmox_vlan` aurait fait revenir l'étiquette en SDN — le repli ne s'applique que si
la clé est **absente**, jamais si elle est présente et vide : c'est la différence entre
« on ne sait pas » et « on a décidé qu'il n'y en a pas ». Et le **test unitaire est tombé**,
à raison ; il couvre maintenant cette distinction.
### La vocation du dépôt réseau (D-55)
Il porte le **contrat entre l'Alliance et ses hébergeurs** : si chacun présente la même
interface, un tenant se déplace sans rien changer chez lui. Il abstrait le matériel en
encapsulant chaque tenant dans sa zone EVPN — le VRF borne ce qu'il a le droit de
connaître.
Mesuré : **un tenant est à deux valeurs de la portabilité complète** (`noeud`,
`stockage`). Aucun ne nomme un commutateur, un VLAN, une adresse d'underlay ni une zone.
### Un hyperviseur a plusieurs pattes (D-57)
Les hyperviseurs reçoivent `10.0.0.41/.43/.47` sur `vmbr0` — interface **sysadmin**,
**sans route par défaut**. On ne l'atteint que depuis le même domaine de diffusion : un
accès distant doit être ouvert explicitement à la frontière, il ne peut pas exister par
accident.
Leur **route par défaut passe sur `vlan40`**, vers l'OPNsense. Ça tranche la question
laissée ouverte depuis ce matin — option A, mais sur une interface dédiée, ce qui lève
l'objection qui la bloquait : le trafic tenant ne touche plus la carte d'administration.
Le modèle ne savait pas exprimer deux interfaces sur un même hôte : ajouter le VLAN 10 l'a
mis sur le trunk de `bond3`, remettant la gestion dans le domaine qu'on venait d'en
sortir. D'où le champ **`via`**, dont le devis dérive **un port par interface** et **son
type** :
```
<PORT-VERS-PROXMOX-BOND3> trunk 11,40
<PORT-VERS-PROXMOX-VMBR0> access 10
```
Un seul VLAN sur une interface = port d'accès ; plusieurs = trunk. Dérivé, pas déclaré.
Régression créée puis corrigée au passage : le modèle public, qui ne déclare aucun
hyperviseur, n'émettait **plus rien** pour ce port. Un devis muet ferait croire qu'il n'y
a rien à configurer. Il émet désormais tout l'underlay, en disant que c'est un repli.
### Côté cluster
`vmbr3` retiré des trois nœuds, `vlan11` et `vlan40` créées sur `bond3` aux bonnes
adresses. Les pairs du contrôleur EVPN pointaient encore sur `10.0.0.x` — **des adresses
qui n'existaient plus**. Corrigés vers `10.0.5.x`, **dérivés d'`underlay.yml`** plutôt que
retapés : une liste saisie à la main diverge au premier changement, ce qui venait
précisément d'arriver. Confronté à l'API : les trois pairs correspondent à une `vlan11`
réelle.
Pas encore câblé, donc pas encore appliqué. `infra-pki-01` n'a rien senti.
30 preuves OK.
## 2026-08-04 (suite 2) — l'invariant du dernier octet retrouve sa portée ## 2026-08-04 (suite 2) — l'invariant du dernier octet retrouve sa portée
Il valait partout ; il ne vaut que là où il a un sens : l'**adressage dérivé des Il valait partout ; il ne vaut que là où il a un sens : l'**adressage dérivé des

View file

@ -612,6 +612,7 @@ creer-vm: _instance-requise
CIDR="$$SETOPS_CIDR" \ CIDR="$$SETOPS_CIDR" \
PASSERELLE="$$SETOPS_PASSERELLE" \ PASSERELLE="$$SETOPS_PASSERELLE" \
VLAN="$$SETOPS_VLAN" \ VLAN="$$SETOPS_VLAN" \
PONT_PROXMOX="$${SETOPS_PONT:-$(PONT_PROXMOX)}" \
STOCKAGE_PROXMOX="$${SETOPS_STOCKAGE:-$(STOCKAGE_PROXMOX)}" \ STOCKAGE_PROXMOX="$${SETOPS_STOCKAGE:-$(STOCKAGE_PROXMOX)}" \
TAILLE_DISQUE="$${SETOPS_DISQUE:-$(TAILLE_DISQUE)}" \ TAILLE_DISQUE="$${SETOPS_DISQUE:-$(TAILLE_DISQUE)}" \
COEURS="$${SETOPS_COEURS:-$(COEURS)}" \ COEURS="$${SETOPS_COEURS:-$(COEURS)}" \
@ -624,7 +625,6 @@ creer-vm: _instance-requise
DHCP="$(DHCP)" \ DHCP="$(DHCP)" \
CIUSER="$(CIUSER)" \ CIUSER="$(CIUSER)" \
CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \ CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \
PONT_PROXMOX="$(PONT_PROXMOX)" \
DEMARRER="$(DEMARRER)" \ DEMARRER="$(DEMARRER)" \
CLONE_COMPLET="$(CLONE_COMPLET)" CLONE_COMPLET="$(CLONE_COMPLET)"

View file

@ -14,7 +14,7 @@
| # | Preuve | Affirmations | Statut | Detail | | # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---| |---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | | | P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | 4 tests passes. | | P02 | Tests unitaires (inventory_host) | — | ❌ ECHEC | rc=1 : AssertionError: ["SETOPS_VMID='93101'", "SETOPS_IP='10.0.2.11'", "SETOPS_CIDR='24'", "SETOPS_PASSERELLE='10.0.2.1'", "SETOPS_VLAN=''", "SETOPS_PONT=''", |
| P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. | | P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | | | P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | | | P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
@ -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. | | 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. | | 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, 29 groupes (inventaire dechiffre et parse). | | P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 29 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ❌ ECHEC | rc=2 : erreur: 1 modele(s) invalide(s) sur 1. | | 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 : 21 secret(s) exige(s), tous presents. Voute reelle : 24 cle(s), aucun manque. | | P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 21 secret(s) exige(s), tous presents. Voute reelle : 24 cle(s), aucun manque. |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 27 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 27 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. | | P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |

View file

@ -61,8 +61,13 @@ sont les seules vérifiables.
| **D-46** | Un hébergeur porte **trois** catégories, pas deux : son **tenant**, ses **opérations**, le **plan de contrôle** | Chezlepro est hébergeur ET tenant, ce qui masquait des besoins n'appartenant à aucun tenant | `hebergeur-exploitation.md` §2 | — | | **D-46** | Un hébergeur porte **trois** catégories, pas deux : son **tenant**, ses **opérations**, le **plan de contrôle** | Chezlepro est hébergeur ET tenant, ce qui masquait des besoins n'appartenant à aucun tenant | `hebergeur-exploitation.md` §2 | — |
| **D-47** | Les **services d'exploitation** de l'hébergeur vivent dans **son dépôt**, et leurs VM se rattachent à un **pont VLAN, jamais un VNet** | un service qui observe la fabric ne peut pas dépendre d'elle : l'EVPN tombe, et la supervision tombe avec la raison de la panne | `hebergeur-exploitation.md` §1, §4 | — | | **D-47** | Les **services d'exploitation** de l'hébergeur vivent dans **son dépôt**, et leurs VM se rattachent à un **pont VLAN, jamais un VNet** | un service qui observe la fabric ne peut pas dépendre d'elle : l'EVPN tombe, et la supervision tombe avec la raison de la panne | `hebergeur-exploitation.md` §1, §4 | — |
| **D-48** | Les **hyperviseurs** sont gérables par Ansible ; « hors flotte » ne vaut que pour les **commutateurs** et la **frontière** | ce sont des Debian joignables en SSH ; c'est la seule façon d'y poser un exportateur de métriques | `hebergeur-exploitation.md` §5 | — | | **D-48** | Les **hyperviseurs** sont gérables par Ansible ; « hors flotte » ne vaut que pour les **commutateurs** et la **frontière** | ce sont des Debian joignables en SSH ; c'est la seule façon d'y poser un exportateur de métriques | `hebergeur-exploitation.md` §5 | — |
| **D-55** | Le dépôt réseau porte une **interface normalisée** vers les tenants de l'Alliance, et abstrait le matériel en les encapsulant dans des zones EVPN | un tenant qui ne nomme aucun équipement se déplace d'un hébergeur à l'autre sans rien changer ; le VRF borne ce qu'il a le droit de connaître | `hebergeur-exploitation.md` §7 | — |
| **D-56** | Le **VNet d'une VM est dérivé** (`index` + zone), jamais déclaré ; l'étiquette VLAN est **vide** en SDN | déclaré, il faisait naître les VM sur `vmbr1` avec un tag — l'ancien monde, à rebrancher une par une | `instancier.py` | P02, P03 |
| **D-53** | Le **réseau et l'underlay** de l'hébergeur méritent leur **propre dépôt**, séparé de son tenant | `underlay.yml` et le cluster décrivent une infrastructure ; le dépôt de tenant décrit une organisation. Les mêler oblige à trancher qui possède quoi à chaque commit | `hebergeur-exploitation.md` §7 | — | | **D-53** | Le **réseau et l'underlay** de l'hébergeur méritent leur **propre dépôt**, séparé de son tenant | `underlay.yml` et le cluster décrivent une infrastructure ; le dépôt de tenant décrit une organisation. Les mêler oblige à trancher qui possède quoi à chaque commit | `hebergeur-exploitation.md` §7 | — |
| **D-54** | `10.0.0.0/24` est réservé à l'**IPAM, la gestion des équipements et l'OOB** — accès sysadmin | aucun hyperviseur, aucune VM, aucun trafic tenant ; c'est la raison d'être des VLAN 11 et 40 | `underlay.yml` | — | | **D-54** | `10.0.0.0/24` est réservé à l'**IPAM, la gestion des équipements et l'OOB** — accès sysadmin | aucune VM, aucun trafic tenant ; c'est la raison d'être des VLAN 11 et 40 | `underlay.yml` | — |
| **D-57** | L'interface **sysadmin** d'un hyperviseur (`vmbr0`) n'a **pas de route par défaut** ; celle-ci vit sur `vlan40`, vers la frontière | on n'atteint l'administration que depuis son propre domaine de diffusion — un accès distant doit être ouvert explicitement, il ne peut pas exister par accident. Et le trafic tenant ne touche plus la carte d'administration | `underlay.yml` | — |
| **D-58** | Un hôte déclare **par quelle interface** (`via`) chaque réseau lui arrive ; le devis en dérive un **port par interface** et son **type** | un hyperviseur a plusieurs pattes ; les grouper remettait la gestion sur le trunk du transport | `devis_reseau.py` | P23 |
| **D-59** | Un VLAN qui ne porte que des **adresses d'hôte** n'a **pas besoin de pont** | un pont sert à brancher des invités ; vide, il coûte une table MAC et un saut de plus sur le lien qui porte tout le trafic tenant | `underlay.yml` | — |
| **D-18** | Chaque tenant a un **responsable désigné** | sans lui, « qui peut décider de déménager cette organisation ? » se pose au pire moment | `migration-tenant.md` §3 | — | | **D-18** | Chaque tenant a un **responsable désigné** | sans lui, « qui peut décider de déménager cette organisation ? » se pose au pire moment | `migration-tenant.md` §3 | — |
| **D-38** | Toute authentification **web** passe par Keycloak ; LDAP est la **source unique** des comptes | une identité, un mot de passe ; aucun service ne tient son propre répertoire d'humains | `authentification.md` §1-2 | — | | **D-38** | Toute authentification **web** passe par Keycloak ; LDAP est la **source unique** des comptes | une identité, un mot de passe ; aucun service ne tient son propre répertoire d'humains | `authentification.md` §1-2 | — |

View file

@ -85,7 +85,63 @@ Deux obstacles mesurés le 2026-08-04, à lever avant :
À noter au passage : la convention héritée était déjà `1000 + numéro de tenant`. La formule À noter au passage : la convention héritée était déjà `1000 + numéro de tenant`. La formule
de Set-OPS (`1000 + index×10 + zone`) en est un raffinement, pas une invention. de Set-OPS (`1000 + index×10 + zone`) en est un raffinement, pas une invention.
## 7. Un dépôt à part pour le réseau (décidé, non fait) ## 7. La vocation du dépôt réseau : une interface normalisée
Ce dépôt n'est pas seulement un rangement. Il porte le **contrat entre l'Alliance Boréale
et ses hébergeurs** : si chacun présente la même interface, un tenant se déplace de l'un à
l'autre sans rien changer chez lui.
Il sert aussi de **couche d'abstraction du matériel**. Le tenant est encapsulé dans sa zone
SDN EVPN, et le VRF devient la frontière de ce qu'il a le droit de connaître.
| Ce que l'hébergeur fournit | Ce que le tenant en voit |
|---|---|
| une zone EVPN par tenant | son VRF, dérivé de son `index` |
| six VNets et leurs sous-réseaux | ses passerelles `.1`, dérivées |
| un pool | son regroupement |
| un chemin de sortie vers la frontière | « l'extérieur » |
| des classes de nœud et de stockage | un besoin, pas un nom d'équipement |
**Le tenant ne nomme aucun de ces objets.** C'est la mesure de sa portabilité.
### Où en est-on, mesuré
Aucun tenant ne nomme un commutateur, un VLAN, une adresse d'underlay ni une zone EVPN :
tout dérive de son `index`. Au 2026-08-04, les **seules** mentions d'équipement de
l'hébergeur, dans les deux dépôts de tenants :
```
proxmox_clone_noeud: asgard
proxmox_clone_stockage: TrueNAS
proxmox_clone_pont: vmbr1 / vmbr3 ← corrigé, voir ci-dessous
```
Trois valeurs, dans un seul fichier. L'architecture tenait déjà la promesse avant qu'on
l'ait formulée.
### La troisième n'était pas seulement non portable : elle était fausse
`proxmox_clone_pont` faisait brancher la VM sur `vmbr1` **avec une étiquette VLAN**
l'ancien monde. En SDN, une VM appartient à son **VNet**. C'est ce qu'il a fallu corriger à
la main sur `infra-pki-01`, et les treize suivantes auraient suivi.
Le VNet est **dérivable** : `index` + zone de sécurité → `t17serv`, comme le VMID et
l'adresse le sont déjà. `deriver_nomenclature()` expose désormais la zone, `instancier`
émet `proxmox_pont` et une **étiquette vide** (le VNet porte déjà le tag), et la chaîne va
jusqu'à `make creer-vm`.
C'est le meilleur argument pour cette vocation : **ce qui n'est pas dérivé finit par
diverger du réel.**
### Ce qui reste à abstraire
`noeud` et `stockage` désignent de vraies offres de l'hébergeur. Un tenant ne peut pas les
emporter — mais il ne devrait pas non plus les nommer. L'interface normalisée les rendrait
**abstraits** : le tenant déclare un besoin, l'hébergeur publie la correspondance.
`proxmox_noeuds` et `proxmox_stockages` sont déjà chez lui ; il manque la classe, pas le
catalogue.
## 8. Un dépôt à part pour le réseau (décidé, non fait)
`underlay.yml` et `proxmox-hebergeur.yml` vivent aujourd'hui dans `OPS-Chezlepro`, qui est `underlay.yml` et `proxmox-hebergeur.yml` vivent aujourd'hui dans `OPS-Chezlepro`, qui est
aussi le dépôt du **tenant** Chezlepro. C'est ce qui a permis de démarrer, et c'est ce qui aussi le dépôt du **tenant** Chezlepro. C'est ce qui a permis de démarrer, et c'est ce qui
@ -103,7 +159,7 @@ distinction visible dans les chemins eux-mêmes.
**Rien n'est fait.** La bascule demande de déplacer deux fichiers, de refaire le symlink, **Rien n'est fait.** La bascule demande de déplacer deux fichiers, de refaire le symlink,
et de vérifier que les trois générateurs qui les lisent suivent. et de vérifier que les trois générateurs qui les lisent suivent.
## 8. Le VLAN de gestion, et ce qu'il n'est pas ## 9. Le VLAN de gestion, et ce qu'il n'est pas
`10.0.0.0/24` est réservé à l'**IPAM, la gestion des équipements et l'OOB/IPMI**. Accès `10.0.0.0/24` est réservé à l'**IPAM, la gestion des équipements et l'OOB/IPMI**. Accès
sysadmin uniquement. sysadmin uniquement.

View file

@ -345,6 +345,30 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str,
return out return out
def reseaux_par_via(underlay: dict | None, role: str) -> dict[str, list[dict]]:
"""{interface de l'hote: [reseaux]} — un port de commutateur par interface.
Un hyperviseur n'a pas une seule patte : son administration arrive par `vmbr0`
(carte dediee), son transport et sa sortie par `bond3`. Les grouper toutes sur un
seul port remettrait le VLAN de gestion sur le trunk du transport precisement le
domaine de diffusion qu'on vient d'en sortir.
Les hotes sans `via` tombent dans un groupe unique : rien ne change pour eux.
"""
par_via: dict[str, dict[str, dict]] = {}
fab = underlay_mod.fabric_du_routeur(underlay)
connus = {r.get("nom"): r for r in underlay_mod.reseaux_de_fabric(underlay, fab)
if r.get("vlan") is not None}
for h in underlay_mod.hotes(underlay):
if h.get("role") != role or not h.get("reseau"):
continue
r = connus.get(h["reseau"])
if r:
par_via.setdefault(str(h.get("via") or ""), {})[r["nom"]] = r
return {via: sorted(d.values(), key=lambda r: int(r["vlan"]))
for via, d in sorted(par_via.items())}
def vlans_du_role(underlay: dict | None, role: str, def vlans_du_role(underlay: dict | None, role: str,
repli: dict | None = None) -> list[dict]: repli: dict | None = None) -> list[dict]:
"""Reseaux qu'un port doit porter — DERIVES des rattachements declares du role. """Reseaux qu'un port doit porter — DERIVES des rattachements declares du role.
@ -661,6 +685,9 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
# noeuds de sortie EVPN ont une patte sur le lien de frontiere, exclure le transit # noeuds de sortie EVPN ont une patte sur le lien de frontiere, exclure le transit
# « parce qu'aucun hyperviseur n'y est » serait faux — et le trunk laisserait # « parce qu'aucun hyperviseur n'y est » serait faux — et le trunk laisserait
# tomber le trafic tenant sortant sans rien signaler. # tomber le trafic tenant sortant sans rien signaler.
# Un bloc de port par interface d'hyperviseur : `vmbr0` (administration, un seul
# VLAN -> port d'ACCES) et `bond3` (transport + sortie, plusieurs -> trunk).
par_via_hyp = reseaux_par_via(underlay, "hyperviseur")
vlans_underlay = [str(r["vlan"]) vlans_underlay = [str(r["vlan"])
for r in vlans_du_role(underlay, "hyperviseur")] for r in vlans_du_role(underlay, "hyperviseur")]
# Les liens INTER-SWITCH portent tout l'underlay de la fabric, pas seulement ce # Les liens INTER-SWITCH portent tout l'underlay de la fabric, pas seulement ce
@ -681,8 +708,34 @@ def generer(tenants: list[tuple[str, str, dict]], dialecte: str | None = None) -
vlans = ",".join(vlans_underlay + vlans_tenants) vlans = ",".join(vlans_underlay + vlans_tenants)
routeur_h = hote_nomme(underlay, underlay_mod.routeur(underlay)) routeur_h = hote_nomme(underlay, underlay_mod.routeur(underlay))
bord = bool(underlay_mod.stp(underlay)) bord = bool(underlay_mod.stp(underlay))
for port in ports_ou_marqueur(routeur_h, "hyperviseurs", "<PORT-VERS-PROXMOX>"): if not par_via_hyp:
out += bloc_trunk(port, vlans, bord=bord) # Aucun hyperviseur declare : on ne peut rien deriver de leurs rattachements.
# Emettre TOUT l'underlay est le repli sur, et il faut le dire — un devis muet
# ferait croire qu'il n'y a pas de port a configurer, ce qui est faux.
par_via_hyp = {"": [r for r in underlay_mod.reseaux_de_fabric(
underlay, underlay_mod.fabric_du_routeur(underlay)) if r.get("vlan") is not None]}
out += ["! Aucun hote `role: hyperviseur` declare dans underlay.yml : ce port porte",
"! TOUT l'underlay, faute de mieux. Les declarer rendrait ce bloc precis —",
"! et separerait l'administration du transport, comme il se doit."]
for via, reseaux_via in par_via_hyp.items():
etiq = f"-{via.upper()}" if via else ""
liste = ",".join(str(r["vlan"]) for r in reseaux_via)
detail = ", ".join(f"{r['vlan']} ({r['nom']})" for r in reseaux_via)
out += [f"! Interface {via or '(non precisee)'} des hyperviseurs — porte {detail}."]
for port in ports_ou_marqueur(routeur_h, "hyperviseurs",
f"<PORT-VERS-PROXMOX{etiq}>"):
if len(reseaux_via) == 1:
# Un seul VLAN sur cette interface : port d'ACCES, trame non etiquetee.
# C'est le cas de `vmbr0`, qui n'est pas VLAN-aware et porte l'adresse
# d'administration en clair sur le VLAN natif.
out += [f"interface {port}",
f" switchport mode access",
f" switchport access vlan {reseaux_via[0]['vlan']}"]
if bord:
out.append(" spanning-tree portfast")
else:
out += bloc_trunk(port, liste, bord=bord)
out += ["!"]
out += ["!"] + section_frontiere( out += ["!"] + section_frontiere(
transit, bord=bool(underlay_mod.stp(underlay)), transit, bord=bool(underlay_mod.stp(underlay)),
ports=ports_ou_marqueur(hote_nomme(underlay, underlay_mod.routeur(underlay)), ports=ports_ou_marqueur(hote_nomme(underlay, underlay_mod.routeur(underlay)),

View file

@ -27,6 +27,8 @@ from pathlib import Path
import yaml import yaml
import underlay as underlay_mod # noqa: E402
from devis_sdn import vnet_de # noqa: E402
from inventory_rules import ( from inventory_rules import (
charger_applications, charger_applications,
charger_domaines, charger_domaines,
@ -168,6 +170,19 @@ def generer() -> dict:
# Politique d'integration : lue une fois, appliquee a tous (voir D-33). # Politique d'integration : lue une fois, appliquee a tous (voir D-33).
universelles = integrations_universelles(RACINE) universelles = integrations_universelles(RACINE)
# Rattachement reseau d'une VM. En SDN, elle appartient a son VNet — pas a un pont
# de l'hyperviseur avec une etiquette. Le nom du VNet se DERIVE (index + zone),
# exactement comme le VMID et l'adresse : rien a declarer, rien qui puisse diverger.
#
# Sans cette derivation, `make creer-vm` posait les VM sur `proxmox_clone_pont`
# (`vmbr1`) avec l'etiquette du VLAN — l'ancien monde. C'est ce qu'il a fallu
# corriger a la main sur infra-pki-01, et les treize suivantes auraient suivi.
#
# L'ETIQUETTE DOIT ETRE VIDE en SDN : le VNet la porte deja. En poser une seconde
# au niveau de la carte donnerait un double etiquetage.
sdn = underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn"
index_tenant = nomenclature.get("index")
children: dict = { children: dict = {
"modeles_vm": {"hosts": {}}, "modeles_vm": {"hosts": {}},
"hotes_actifs": {"hosts": {}}, "hotes_actifs": {"hosts": {}},
@ -184,6 +199,11 @@ def generer() -> dict:
"proxmox_vlan": d.get("vlan"), "proxmox_vlan": d.get("vlan"),
"proxmox_vmid": int(d["vmid"]) if d.get("vmid") else None, "proxmox_vmid": int(d["vmid"]) if d.get("vmid") else None,
} }
if sdn and index_tenant is not None and d.get("zone") is not None:
hostvars["proxmox_pont"] = vnet_de(int(index_tenant), d["zone_libelle"])
hostvars["proxmox_etiquette_vlan"] = "" # le VNet porte deja le tag
else:
hostvars["proxmox_etiquette_vlan"] = d.get("vlan")
for cle_srv, cle_var in PLACEMENT: for cle_srv, cle_var in PLACEMENT:
if str(srv.get(cle_srv, "")).strip(): if str(srv.get(cle_srv, "")).strip():
hostvars[cle_var] = srv[cle_srv] hostvars[cle_var] = srv[cle_srv]

View file

@ -148,12 +148,18 @@ def afficher_hote(data: dict, host: str) -> None:
) )
CHAMPS_PROXMOX: tuple[tuple[str, str, bool], ...] = ( CHAMPS_PROXMOX: tuple[tuple, ...] = (
("SETOPS_VMID", "proxmox_vmid", True), ("SETOPS_VMID", "proxmox_vmid", True),
("SETOPS_IP", "ansible_host", True), ("SETOPS_IP", "ansible_host", True),
("SETOPS_CIDR", "proxmox_cidr", True), ("SETOPS_CIDR", "proxmox_cidr", True),
("SETOPS_PASSERELLE", "proxmox_passerelle", True), ("SETOPS_PASSERELLE", "proxmox_passerelle", True),
("SETOPS_VLAN", "proxmox_vlan", True), # L'etiquette VLAN a poser sur la carte. En SDN elle est VIDE : le VNet la porte
# deja, et en poser une seconde donnerait un double etiquetage. `proxmox_vlan`
# sert de repli pour un inventaire pas encore regenere — mais SEULEMENT si la cle
# est ABSENTE, jamais si elle est presente et vide : c'est la difference entre
# « on ne sait pas » et « on a decide qu'il n'y en a pas ».
("SETOPS_VLAN", "proxmox_etiquette_vlan", False, "proxmox_vlan"),
("SETOPS_PONT", "proxmox_pont", False),
("SETOPS_STOCKAGE", "proxmox_stockage", False), ("SETOPS_STOCKAGE", "proxmox_stockage", False),
("SETOPS_DISQUE", "proxmox_disque_taille", False), ("SETOPS_DISQUE", "proxmox_disque_taille", False),
("SETOPS_NOEUD", "proxmox_noeud", False), ("SETOPS_NOEUD", "proxmox_noeud", False),
@ -179,8 +185,15 @@ def parametres_proxmox_hote(data: dict, host: str) -> list[str]:
lignes: list[str] = [] lignes: list[str] = []
manquants: list[str] = [] manquants: list[str] = []
for var_shell, cle, requis in CHAMPS_PROXMOX: for champ in CHAMPS_PROXMOX:
valeur = vars_for_host.get(cle) var_shell, cle, requis = champ[0], champ[1], champ[2]
repli = champ[3] if len(champ) > 3 else None
if cle in vars_for_host:
valeur = vars_for_host.get(cle)
elif repli:
valeur = vars_for_host.get(repli)
else:
valeur = None
if valeur in (None, ""): if valeur in (None, ""):
if requis: if requis:
manquants.append(cle) manquants.append(cle)

View file

@ -514,6 +514,11 @@ def deriver_nomenclature(fonction: str, seq: int, nomenclature: dict) -> dict |
"adresse_ip": f"{base3_de(index, int(cat))}.{hote}", "adresse_ip": f"{base3_de(index, int(cat))}.{hote}",
"passerelle": passerelle_de(index, int(cat)), "passerelle": passerelle_de(index, int(cat)),
"cidr": nomenclature.get("cidr_hote", 24), "cidr": nomenclature.get("cidr_hote", 24),
# La ZONE de securite, exposee : c'est d'elle que se derive le VNet du SDN
# (`t17serv`). La recalculer ailleurs demanderait de relire fonctions +
# categories — deux lectures pour une valeur deja connue ici.
"zone": int(cat),
"zone_libelle": str(c.get("libelle", f"zone{cat}")),
} }

View file

@ -58,6 +58,29 @@ def _inventaire_factice() -> dict:
} }
def test_etiquette_vlan_repli_et_vide_explicite() -> None:
"""Absent -> repli sur proxmox_vlan ; present et VIDE -> reste vide.
C'est la distinction qui protege du double etiquetage : en SDN le VNet porte
deja le tag, et `proxmox_etiquette_vlan: ""` DIT qu'il ne faut pas en poser un
second. Un repli naif (`a or b`) ferait revenir l'etiquette et casserait le
rattachement panne muette, decouverte au premier paquet.
"""
inv = _inventaire_factice()
hote = inv["all"]["children"]["hotes_actifs"]["hosts"]["app-01"]
# 1. Cle absente : on retombe sur proxmox_vlan.
lignes = parametres_proxmox_hote(inv, "app-01")
assert "SETOPS_VLAN='13'" in lignes, lignes
# 2. Cle presente et vide (SDN) : aucune etiquette, malgre proxmox_vlan renseigne.
hote["proxmox_etiquette_vlan"] = ""
hote["proxmox_pont"] = "t17serv"
lignes = parametres_proxmox_hote(inv, "app-01")
assert "SETOPS_VLAN=''" in lignes, lignes
assert "SETOPS_PONT='t17serv'" in lignes, lignes
def test_hote_complet_avec_noeud() -> None: def test_hote_complet_avec_noeud() -> None:
lignes = parametres_proxmox_hote(_inventaire_factice(), "app-01") lignes = parametres_proxmox_hote(_inventaire_factice(), "app-01")
attendu = { attendu = {
@ -66,6 +89,7 @@ def test_hote_complet_avec_noeud() -> None:
"SETOPS_CIDR='24'", "SETOPS_CIDR='24'",
"SETOPS_PASSERELLE='10.0.2.1'", "SETOPS_PASSERELLE='10.0.2.1'",
"SETOPS_VLAN='13'", "SETOPS_VLAN='13'",
"SETOPS_PONT=''",
"SETOPS_STOCKAGE='stockage-a'", "SETOPS_STOCKAGE='stockage-a'",
"SETOPS_DISQUE='160G'", "SETOPS_DISQUE='160G'",
"SETOPS_NOEUD='noeud-a'", "SETOPS_NOEUD='noeud-a'",