From 89f33c5b43e6368afd5b4cf83bb87e1a5c13867c Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Tue, 4 Aug 2026 16:20:56 -0400 Subject: [PATCH] =?UTF-8?q?r=C3=A9seau=20:=20le=20VNet=20d'une=20VM=20se?= =?UTF-8?q?=20d=C3=A9rive,=20un=20hyperviseur=20a=20plusieurs=20pattes=20(?= =?UTF-8?q?D-55=20=C3=A0=20D-59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 75 ++++++++++++++++++++++++++++ Makefile | 2 +- docs/audit/preuve-2026-08-04.md | 4 +- docs/decisions-architecture.md | 7 ++- docs/hebergeur-exploitation.md | 60 +++++++++++++++++++++- scripts/devis_reseau.py | 57 ++++++++++++++++++++- scripts/instancier.py | 20 ++++++++ scripts/inventory_host.py | 21 ++++++-- scripts/inventory_rules.py | 5 ++ scripts/tests/test_inventory_host.py | 24 +++++++++ 10 files changed, 263 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0571954..fb886fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,80 @@ # 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** : + +``` + trunk 11,40 + 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 Il valait partout ; il ne vaut que là où il a un sens : l'**adressage dérivé des diff --git a/Makefile b/Makefile index a981e4f..a10713b 100644 --- a/Makefile +++ b/Makefile @@ -612,6 +612,7 @@ creer-vm: _instance-requise CIDR="$$SETOPS_CIDR" \ PASSERELLE="$$SETOPS_PASSERELLE" \ VLAN="$$SETOPS_VLAN" \ + PONT_PROXMOX="$${SETOPS_PONT:-$(PONT_PROXMOX)}" \ STOCKAGE_PROXMOX="$${SETOPS_STOCKAGE:-$(STOCKAGE_PROXMOX)}" \ TAILLE_DISQUE="$${SETOPS_DISQUE:-$(TAILLE_DISQUE)}" \ COEURS="$${SETOPS_COEURS:-$(COEURS)}" \ @@ -624,7 +625,6 @@ creer-vm: _instance-requise DHCP="$(DHCP)" \ CIUSER="$(CIUSER)" \ CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \ - PONT_PROXMOX="$(PONT_PROXMOX)" \ DEMARRER="$(DEMARRER)" \ CLONE_COMPLET="$(CLONE_COMPLET)" diff --git a/docs/audit/preuve-2026-08-04.md b/docs/audit/preuve-2026-08-04.md index e964000..10c4423 100644 --- a/docs/audit/preuve-2026-08-04.md +++ b/docs/audit/preuve-2026-08-04.md @@ -14,7 +14,7 @@ | # | Preuve | Affirmations | Statut | Detail | |---|---|---|---|---| | 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. | | P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ 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. | | 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). | -| 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. | | 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. | diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index 27812f4..8d14af3 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -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-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-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-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-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 | — | diff --git a/docs/hebergeur-exploitation.md b/docs/hebergeur-exploitation.md index 141a93d..812861d 100644 --- a/docs/hebergeur-exploitation.md +++ b/docs/hebergeur-exploitation.md @@ -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 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 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, 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 sysadmin uniquement. diff --git a/scripts/devis_reseau.py b/scripts/devis_reseau.py index 4337499..3a33671 100644 --- a/scripts/devis_reseau.py +++ b/scripts/devis_reseau.py @@ -345,6 +345,30 @@ def partie_acces(underlay: dict | None, tenants: list, vlans: str, 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, repli: dict | None = None) -> list[dict]: """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 # « parce qu'aucun hyperviseur n'y est » serait faux — et le trunk laisserait # 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"]) for r in vlans_du_role(underlay, "hyperviseur")] # 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) routeur_h = hote_nomme(underlay, underlay_mod.routeur(underlay)) bord = bool(underlay_mod.stp(underlay)) - for port in ports_ou_marqueur(routeur_h, "hyperviseurs", ""): - out += bloc_trunk(port, vlans, bord=bord) + if not par_via_hyp: + # 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""): + 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( transit, bord=bool(underlay_mod.stp(underlay)), ports=ports_ou_marqueur(hote_nomme(underlay, underlay_mod.routeur(underlay)), diff --git a/scripts/instancier.py b/scripts/instancier.py index 3c43429..fd286f0 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -27,6 +27,8 @@ from pathlib import Path import yaml +import underlay as underlay_mod # noqa: E402 +from devis_sdn import vnet_de # noqa: E402 from inventory_rules import ( charger_applications, charger_domaines, @@ -168,6 +170,19 @@ def generer() -> dict: # Politique d'integration : lue une fois, appliquee a tous (voir D-33). 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 = { "modeles_vm": {"hosts": {}}, "hotes_actifs": {"hosts": {}}, @@ -184,6 +199,11 @@ def generer() -> dict: "proxmox_vlan": d.get("vlan"), "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: if str(srv.get(cle_srv, "")).strip(): hostvars[cle_var] = srv[cle_srv] diff --git a/scripts/inventory_host.py b/scripts/inventory_host.py index 60740cc..165f1d5 100644 --- a/scripts/inventory_host.py +++ b/scripts/inventory_host.py @@ -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_IP", "ansible_host", True), ("SETOPS_CIDR", "proxmox_cidr", 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_DISQUE", "proxmox_disque_taille", False), ("SETOPS_NOEUD", "proxmox_noeud", False), @@ -179,8 +185,15 @@ def parametres_proxmox_hote(data: dict, host: str) -> list[str]: lignes: list[str] = [] manquants: list[str] = [] - for var_shell, cle, requis in CHAMPS_PROXMOX: - valeur = vars_for_host.get(cle) + for champ in CHAMPS_PROXMOX: + 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 requis: manquants.append(cle) diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 0238fb4..f790d98 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -514,6 +514,11 @@ def deriver_nomenclature(fonction: str, seq: int, nomenclature: dict) -> dict | "adresse_ip": f"{base3_de(index, int(cat))}.{hote}", "passerelle": passerelle_de(index, int(cat)), "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}")), } diff --git a/scripts/tests/test_inventory_host.py b/scripts/tests/test_inventory_host.py index 203e77c..72ab14c 100644 --- a/scripts/tests/test_inventory_host.py +++ b/scripts/tests/test_inventory_host.py @@ -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: lignes = parametres_proxmox_hote(_inventaire_factice(), "app-01") attendu = { @@ -66,6 +89,7 @@ def test_hote_complet_avec_noeud() -> None: "SETOPS_CIDR='24'", "SETOPS_PASSERELLE='10.0.2.1'", "SETOPS_VLAN='13'", + "SETOPS_PONT=''", "SETOPS_STOCKAGE='stockage-a'", "SETOPS_DISQUE='160G'", "SETOPS_NOEUD='noeud-a'",