Set-OPS-Public/scripts/underlay.py

861 lines
41 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Underlay = la fabric physique partagee par les instances federees.
Cluster-global : 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).
ADRESSAGE (D-77, 2026-08-12). Un site derive du meme `index` que son tenant, et son
adressage : le decalage de +10 est retire, l'index se lit dans l'adresse supernet_de rendait 10.(10+index).0.0/16. Personne ne savait plus pourquoi : ni le commentaire de la constante, ni le wiki, ni le commit fondateur 36a882b ne le justifiaient. Trois endroits consultes, zero raison ecrite. Ses deux effets constates : - il reservait 10.0-10.9 sous la plage tenant. Utile tant que l'underlay vivait la — mais D-77 l'a fait entrer dans la bande basse de son propre /16, ce qui a vide cette reserve de son role la veille ; - il eloignait le premier tenant de 10.0.0.0/16, la plage la plus repandue en reseau domestique. Ce risque revient donc aux index bas, et c'est ASSUME. En echange l'index se lit directement dans l'adresse (17 -> 10.17.x.x) et le plafond passe de 245 a 255 ecosystemes federes. Chezlepro (17) 10.27.0.0/16 -> 10.17.0.0/16 Technolibre (11) 10.21.0.0/16 -> 10.11.0.0/16 lab (1) 10.11.0.0/16 -> 10.1.0.0/16 Doc alignee : les trois pages du wiki, multi-instances.md (plafond et exemple devenus faux arithmetiquement), sdn-evpn.md, le libelle de la GUI, la docstring d'underlay.py, D-77, et le document de preparation d'un site hebergeur. Les CONSTATS DE TERRAIN dates sont laisses tels quels : ce sont des mesures. CE COMMIT NE RENUMEROTE RIEN. Il change ce que le plan DERIVE ; l'inventaire applique porte toujours 10.27.x.x et les quatorze VM tournent dessus. Appliquer sans reconstruire rendrait la flotte injoignable — le renumerotage est une operation a part, a mener a froid. Au passage, retire un debris : une copie de conflit Nextcloud de serveur_powerdns/defaults/main.yml, IDENTIQUE a l'original, commitee par accident dans 1295eea et jamais chargee par Ansible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:16:08 -04:00
underlay occupe la BANDE BASSE de ce supernet : `10.<index>.0-15.x`. La bande est
sure PAR LA REGLE et non par chance — les zones d'un tenant valent
adressage : le decalage de +10 est retire, l'index se lit dans l'adresse supernet_de rendait 10.(10+index).0.0/16. Personne ne savait plus pourquoi : ni le commentaire de la constante, ni le wiki, ni le commit fondateur 36a882b ne le justifiaient. Trois endroits consultes, zero raison ecrite. Ses deux effets constates : - il reservait 10.0-10.9 sous la plage tenant. Utile tant que l'underlay vivait la — mais D-77 l'a fait entrer dans la bande basse de son propre /16, ce qui a vide cette reserve de son role la veille ; - il eloignait le premier tenant de 10.0.0.0/16, la plage la plus repandue en reseau domestique. Ce risque revient donc aux index bas, et c'est ASSUME. En echange l'index se lit directement dans l'adresse (17 -> 10.17.x.x) et le plafond passe de 245 a 255 ecosystemes federes. Chezlepro (17) 10.27.0.0/16 -> 10.17.0.0/16 Technolibre (11) 10.21.0.0/16 -> 10.11.0.0/16 lab (1) 10.11.0.0/16 -> 10.1.0.0/16 Doc alignee : les trois pages du wiki, multi-instances.md (plafond et exemple devenus faux arithmetiquement), sdn-evpn.md, le libelle de la GUI, la docstring d'underlay.py, D-77, et le document de preparation d'un site hebergeur. Les CONSTATS DE TERRAIN dates sont laisses tels quels : ce sont des mesures. CE COMMIT NE RENUMEROTE RIEN. Il change ce que le plan DERIVE ; l'inventaire applique porte toujours 10.27.x.x et les quatorze VM tournent dessus. Appliquer sans reconstruire rendrait la flotte injoignable — le renumerotage est une operation a part, a mener a froid. Au passage, retire un debris : une copie de conflit Nextcloud de serveur_powerdns/defaults/main.yml, IDENTIQUE a l'original, commitee par accident dans 1295eea et jamais chargee par Ansible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:16:08 -04:00
`10.<index>.(15+categorie).0/24`, donc troisieme octet >= 16 ; les octets 0 a 15
ne sont jamais alloues. Deux sites ne peuvent alors pas se chevaucher, sans second
registre a tenir.
Trois cas, et un seul est une erreur :
- dans SON PROPRE supernet, bande basse -> conforme (c'est la regle)
- dans son propre supernet, bande HAUTE -> ERREUR : collision avec ses zones
- dans le supernet d'un AUTRE tenant -> ERREUR : collision entre sites
- hors de tout supernet (10.0.x, 192.168.x) -> conforme (heritage, et stockage)
site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai, c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan. SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent du registre des applications, les gardes ont suivi le plan (4 controles negatifs). Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout : - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ; - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ; - le certificat, PUBLIC par nature, restait en 0600 ; - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ; - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ; - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ; - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ; - le flux declarait 3000 en dur. Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher s'ecrivait vide. ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec CAP_NET_BIND_SERVICE, et son URL n'a plus de port. Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200. 42 preuves vertes, flux coherents (34 roles, 92 flux). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
UN SITE N'A PAS D'INDEX (2026-08-25). Il en portait un, et c'etait un vestige : il ne
derive rien, ses machines vivent sur un reseau de fabric. Cette valeur ne disait qu'une
chose — quel supernet de tenant est le sien — et elle le disait pour le site ENTIER alors
qu'un seul reseau etait concerne. L'exception se declare desormais sur le RESEAU, et elle
NOMME le tenant : `bande_basse_de: OPS-Chezlepro`, verifie contre le registre `tenants:`.
D-78 : destination ou chemin — et le VLAN 50 pour eviter une collision D-77 sortait deja le stockage de l'espace derive, mais par une EXCEPTION NOMMEE plutot que par une regle. La question de l'exploitant — « le VLAN 40 n'a aucune VM, pourquoi ne pas lui donner une adresse non routee ? » — a fait apparaitre le critere juste. Ce n'est pas « y a-t-il des machines dedans » (le VLAN de gestion n'en a pas plus qu'un autre), c'est : ce reseau est-il jamais une DESTINATION, ou seulement un CHEMIN ? gestion (10) atteinte depuis ailleurs -> 10.<index>.0.0/24, UNIQUE transit (40) prochains sauts seulement -> 192.168.40.0/24 transport VXLAN(50) VTEP <-> VTEP -> 192.168.50.0/24 stockage (20/30/31) baie <-> hyperviseurs -> 192.168.20/30/31.0/24 Sur six reseaux, CINQ cessent d'exiger la moindre coordination entre deux hebergeurs, et « unique » redevient signifiant. L'OS, trouve par l'exploitant avant ecriture : le VLAN 11 aurait produit 192.168.11.0/24, DEJA occupe par la gestion des hyperviseurs sur vmbr0 (passerelle .254). Le 11 se liberera quand cette gestion rejoindra 10.<index>.0.x — mais faire dependre un plan d'adressage de l'ORDRE d'une migration est le genre de dette qui se paie un an plus tard. Le transport passe au VLAN 50 : libre, 192.168.50.0/24 libre, et ne depend de rien. Verifie : rien ne code le 11 en dur. underlay.yml n'est PAS modifie : il decrit le materiel tel qu'il est, et y ecrire les nouvelles plages avant le deplacement physique le ferait mentir. Les adresses changeront avec le materiel, dans l'ordre du runbook §6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:50:05 -04:00
DESTINATION OU CHEMIN (D-78). Ce qui sort de l'espace derive n'est pas « le stockage »
mais tout reseau qui n'est jamais une DESTINATION. Le critere n'est pas la presence de
machines — le VLAN de gestion n'en a pas plus qu'un autre :
destination : on doit pouvoir l'ATTEINDRE depuis ailleurs (gestion : poste de
l'exploitant, VPN, lien inter-sites) -> 10.<index>.0-15.x, UNIQUE
chemin : seulement traverse, jamais joint depuis l'exterieur de son propre lien
(transit, transport VXLAN, stockage) -> 192.168.<vlan>.0/24, IDENTIQUE
a tous les sites — l'adresse dit son VLAN
La borne de bande basse ne concerne donc que les DESTINATIONS : un reseau en 192.168 est
hors de tout supernet tenant et passe par la branche « hors de tout supernet ».
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.
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"))
resolution d'instance : une seule, partagee — au lieu de neuf copies Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? » Neuf modules portaient chacun leur reponse. - 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ; - 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable ; - 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ; - 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme. Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier. LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(), dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve — c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut. Vingt-huit modules y sont branches. CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure : 17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose. P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees : instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de la resolution. make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
from inventory_rules import instance_courante, 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
# Premier troisieme-octet alloue aux ZONES d'un tenant. DERIVE de la regle
adressage : le decalage de +10 est retire, l'index se lit dans l'adresse supernet_de rendait 10.(10+index).0.0/16. Personne ne savait plus pourquoi : ni le commentaire de la constante, ni le wiki, ni le commit fondateur 36a882b ne le justifiaient. Trois endroits consultes, zero raison ecrite. Ses deux effets constates : - il reservait 10.0-10.9 sous la plage tenant. Utile tant que l'underlay vivait la — mais D-77 l'a fait entrer dans la bande basse de son propre /16, ce qui a vide cette reserve de son role la veille ; - il eloignait le premier tenant de 10.0.0.0/16, la plage la plus repandue en reseau domestique. Ce risque revient donc aux index bas, et c'est ASSUME. En echange l'index se lit directement dans l'adresse (17 -> 10.17.x.x) et le plafond passe de 245 a 255 ecosystemes federes. Chezlepro (17) 10.27.0.0/16 -> 10.17.0.0/16 Technolibre (11) 10.21.0.0/16 -> 10.11.0.0/16 lab (1) 10.11.0.0/16 -> 10.1.0.0/16 Doc alignee : les trois pages du wiki, multi-instances.md (plafond et exemple devenus faux arithmetiquement), sdn-evpn.md, le libelle de la GUI, la docstring d'underlay.py, D-77, et le document de preparation d'un site hebergeur. Les CONSTATS DE TERRAIN dates sont laisses tels quels : ce sont des mesures. CE COMMIT NE RENUMEROTE RIEN. Il change ce que le plan DERIVE ; l'inventaire applique porte toujours 10.27.x.x et les quatorze VM tournent dessus. Appliquer sans reconstruire rendrait la flotte injoignable — le renumerotage est une operation a part, a mener a froid. Au passage, retire un debris : une copie de conflit Nextcloud de serveur_powerdns/defaults/main.yml, IDENTIQUE a l'original, commitee par accident dans 1295eea et jamais chargee par Ansible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:16:08 -04:00
# (`base3_de` = 10.<index>.(15+categorie)) et jamais ecrit en dur : deplacer
# OCTET_ZONE deplacerait la frontiere de la bande basse avec lui.
from inventory_rules import OCTET_ZONE # noqa: E402
PREMIER_OCTET_ZONE = OCTET_ZONE + 1
def chemin() -> Path | None:
"""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.
"""
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
insemination : declarer le lien, l'emettre d'un seul cote, et un test rouge L'insemination avait un nom depuis ce matin ; elle n'avait pas de flux. Deux declarations, aux deux bouts, et rien d'autre : serveur_ops_site egress 22/tcp -> serveur_ops_tenant serveur_ops_tenant ingress 22/tcp <- runner_site partage: true ETROIT PAR CONSTRUCTION : il vise le GROUPE `serveur_ops_tenant`, qu'un ecosysteme ne pose que sur une machine. Au socle, il aurait ouvert le SSH du site vers toute la flotte du tenant. L'en-tete disait « ce role n'entre JAMAIS chez un tenant ». Frontiere intenable : `creer-vm` exige `_instance-requise`, et le runner du site avait deja du basculer son symlink `instance` sur OPS-Chezlepro pour materialiser ses VM. Declarer ne cree pas ce pouvoir — ca rend limitable un pouvoir qui s'exercait sans borne. Ce qui reste interdit n'est pas une regle mais un FAIT : il n'a pas la voute du tenant. LA REGLE EST EMISE D'UN SEUL COTE, et pas celui qu'on croit. Le paquet penetre le pare-feu par la patte du SITE, pas par le transit : la regle appartient au cote site du devis. L'emettre aussi depuis l'`ingress` du tenant aurait produit une seconde regle sur la mauvaise interface — jamais evaluee, indiscernable d'une regle utile. La declaration du tenant pose sa regle nftables, et elle seule : ip saddr { 10.0.31.11 } tcp dport 22 accept L'adresse DERIVE du plan du site. Ecrite a la main, elle aurait survecu au prochain deplacement du runner sans bruit — le site a deja deplace ses machines le 08-25. Plan de la frontiere : 2 objets a creer, 0 a retirer, 126 inchanges. RIEN D'APPLIQUE. P41 APPLIQUEE AU PLAN DU SITE : `resoudre_flux` en avait besoin a son tour ; les trois lecteurs demenagent dans `underlay` et `devis_opnsense` delegue. DEUX GARDES ONT TRAVAILLE : P33 a refuse `ingress 22` sur un hote portant deja le sshd du socle (reponse : `partage: true`, comme `serveur_backup`), et le devis a refuse d'emettre vers un alias vide. UN TEST ROUGE DEPUIS TROIS JOURS. `test_adressage_derive` construisait un site avec un `index` — or un SITE n'en a pas depuis add94f2 (08-25), remplace par `bande_basse_de`. Invisible parce que le geste quotidien est `make prouver`, qui ne joue pas les tests. Remis sur le contrat actuel, avec sa contrepartie : sans `bande_basse_de`, aucun chevauchement n'est tolere. make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 13:09:28 -04:00
# --- LE PLAN DU SITE, a cote de sa carte ---------------------------------------
#
# `underlay.yml` decrit ce qui est MESURE (commutateurs, VLAN, MTU) ; `plan/` decrit ce
# qui est VOULU (machines, services, intrants). Les deux vivent dans le meme depot
# d'hebergeur, et c'est `chemin()` qui sait ou il est.
#
# CES LECTEURS VIVENT ICI, ET PAS DANS LEURS APPELANTS. `devis_opnsense` les portait en
# prive ; `resoudre_flux` en avait besoin a son tour, et une seconde copie aurait
# fini par diverger de la premiere sans que rien ne le dise. C'est la lecon de P41
# appliquee au plan du site : une seule resolution, partagee.
def plan_site() -> Path | None:
"""Le dossier `plan/` du SITE, a cote de son `underlay.yml`."""
c = chemin()
return (c.resolve().parent / "plan") if c else None
def lire_plan_site(nom: str) -> dict:
"""Un fichier du plan du site, ou {} s'il n'y en a pas."""
d = plan_site()
f = (d / nom) if d else None
if not f or not f.is_file():
return {}
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
def machines_site() -> list[dict]:
"""Les machines ACTIVES du plan du site, chacune portant son `nom`."""
srv = (lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
return [{**s, "nom": nom} for nom, s in srv.items()
if str(s.get("etat", "actif")) == "actif"]
def services_site() -> dict[str, list[str]]:
"""{machine du site: [groupes qu'elle porte]}, depuis `plan/applications.yml`."""
apps = (lire_plan_site("applications.yml") or {}).get("applications") or {}
out: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
return out
gabarit : refabrique, minimal, et puise aux ressources du SITE REFABRIQUE (VMID 9006, modeleSetOPS-minimal). Quatre roles au lieu de dix-sept : qemu_guest_agent, cloud_init, sudo_ansible, ssh_baseline — des conditions d existence, pas des choix d efficacite. Tout le reste vient du socle, et P56 refuse qu un role retire ne soit repris par personne. FABRIQUE CHEZ LE SITE, ET C EST LA DECISION QUI COMPTE. « Les ressources du SITE font autorite pour tous ses artefacts ; elles servent les tenants jusqu a ce qu ils s emancipent. » Il se fabriquait DEHORS : `-i "<ip>,"` ne porte aucun group_vars, donc ni mandataire ni resolveur. L ancien gabarit allait chercher ses paquets chez Debian et resolvait chez l ancien LAN — 192.168.10.10, lu sur la VM 99998. Le site avait son cache et son resolveur, et son propre artefact les ignorait. Desormais la fabrication DERIVE ses ressources du plan du site (underlay --adresses) et tourne sur le reseau du genome. PROUVE : dix requetes de 10.0.33.31 servies par site-cache-01, resolveur pose a 10.0.34.11. L IDENTITE DU GABARIT VIENT DU SITE, PLUS DU TENANT. `proxmox_clone_vmid_modele` vivait dans les group_vars de l ecosysteme : deux tenants pouvaient cloner deux gabarits differents sans que rien ne le dise, et un tenant decidait d un objet dont descend chaque VM de chaque ecosysteme. Meme mouvement que l INDEX (2026-08-25) : le site ALLOUE, le tenant RECOIT. Cle `gabarit` du plan du site, lue par underlay --gabarit. PIEGE FERME EN CHEMIN : creer-vm passait VMID_MODELE="$SETOPS_VMID_MODELE" a cloner-vm, or inventory_host.py n emet PAS cette variable. Elle valait donc le VIDE, et ce vide ECRASAIT la valeur derivee — le gabarit du tenant reprenait la main sans bruit. ET UN PIEGE DEJA DOCUMENTE, PAYE UNE TROISIEME FOIS : le chemin du controleur contient une espace, et `lookup('pipe', ...)` le decoupait. Guillemets. EPROUVE DE BOUT EN BOUT : VM clonee du gabarit minimal — nom, adresse, machine-id neuf, cle d hote regeneree, agent invite actif ; puis socle applique dessus, changed=5, 0 echec, auditd compris. VM d essai retiree. make verifier : vert. make prouver : CONFORME, 56 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-01 13:46:31 -04:00
def gabarit() -> dict:
"""Le gabarit dore, tel que le SITE le declare — VMID, nom, noeud.
LE GABARIT EST UN ARTEFACT DU SITE (2026-09-01). C'est de lui que descend chaque VM de
chaque tenant ; son identite ne peut donc pas etre decidee par un tenant. Elle l'etait :
`proxmox_clone_vmid_modele` vivait dans les group_vars de l'ecosysteme, si bien que
deux tenants pouvaient cloner deux gabarits differents sans que rien ne le dise.
Meme mouvement que l'INDEX, tranche le 2026-08-25 : le site ALLOUE, le tenant RECOIT.
Rend {} quand la carte de l'hebergeur n'est pas montee : l'ecosysteme retombe alors sur
ce que son propre plan declare, comme avant. Degrader, jamais deviner.
"""
return dict((lire_plan_site("10-intrants.yml") or {}).get("gabarit") or {})
insemination : declarer le lien, l'emettre d'un seul cote, et un test rouge L'insemination avait un nom depuis ce matin ; elle n'avait pas de flux. Deux declarations, aux deux bouts, et rien d'autre : serveur_ops_site egress 22/tcp -> serveur_ops_tenant serveur_ops_tenant ingress 22/tcp <- runner_site partage: true ETROIT PAR CONSTRUCTION : il vise le GROUPE `serveur_ops_tenant`, qu'un ecosysteme ne pose que sur une machine. Au socle, il aurait ouvert le SSH du site vers toute la flotte du tenant. L'en-tete disait « ce role n'entre JAMAIS chez un tenant ». Frontiere intenable : `creer-vm` exige `_instance-requise`, et le runner du site avait deja du basculer son symlink `instance` sur OPS-Chezlepro pour materialiser ses VM. Declarer ne cree pas ce pouvoir — ca rend limitable un pouvoir qui s'exercait sans borne. Ce qui reste interdit n'est pas une regle mais un FAIT : il n'a pas la voute du tenant. LA REGLE EST EMISE D'UN SEUL COTE, et pas celui qu'on croit. Le paquet penetre le pare-feu par la patte du SITE, pas par le transit : la regle appartient au cote site du devis. L'emettre aussi depuis l'`ingress` du tenant aurait produit une seconde regle sur la mauvaise interface — jamais evaluee, indiscernable d'une regle utile. La declaration du tenant pose sa regle nftables, et elle seule : ip saddr { 10.0.31.11 } tcp dport 22 accept L'adresse DERIVE du plan du site. Ecrite a la main, elle aurait survecu au prochain deplacement du runner sans bruit — le site a deja deplace ses machines le 08-25. Plan de la frontiere : 2 objets a creer, 0 a retirer, 126 inchanges. RIEN D'APPLIQUE. P41 APPLIQUEE AU PLAN DU SITE : `resoudre_flux` en avait besoin a son tour ; les trois lecteurs demenagent dans `underlay` et `devis_opnsense` delegue. DEUX GARDES ONT TRAVAILLE : P33 a refuse `ingress 22` sur un hote portant deja le sshd du socle (reponse : `partage: true`, comme `serveur_backup`), et le devis a refuse d'emettre vers un alias vide. UN TEST ROUGE DEPUIS TROIS JOURS. `test_adressage_derive` construisait un site avec un `index` — or un SITE n'en a pas depuis add94f2 (08-25), remplace par `bande_basse_de`. Invisible parce que le geste quotidien est `make prouver`, qui ne joue pas les tests. Remis sur le contrat actuel, avec sa contrepartie : sans `bande_basse_de`, aucun chevauchement n'est tolere. make verifier : vert. make prouver : CONFORME, 52 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-08-28 13:09:28 -04:00
def adresses_site_portant(groupe: str) -> list[str]:
"""Les adresses des machines du SITE qui portent ce groupe.
DERIVE, JAMAIS ECRIT. L'adresse du runner de site est la seule source du flux
d'insemination : l'ecrire en dur quelque part la ferait mentir le jour ou le site
redecoupe ses zones — ce qui est arrive le 2026-08-25, et a coute cinq machines
bloquees.
"""
svc = services_site()
return sorted({str(m["ip"]) for m in machines_site()
if m.get("ip") and groupe in svc.get(m["nom"], [])})
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 []
def machines(underlay: dict | None) -> list[dict]:
site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai, c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan. SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent du registre des applications, les gardes ont suivi le plan (4 controles negatifs). Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout : - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ; - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ; - le certificat, PUBLIC par nature, restait en 0600 ; - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ; - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ; - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ; - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ; - le flux declarait 3000 en dur. Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher s'ecrivait vide. ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec CAP_NET_BIND_SERVICE, et son URL n'a plus de port. Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200. 42 preuves vertes, flux coherents (34 roles, 92 flux). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
"""TOUJOURS VIDE : les machines du site vivent dans son PLAN (2026-08-25).
site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai, c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan. SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent du registre des applications, les gardes ont suivi le plan (4 controles negatifs). Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout : - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ; - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ; - le certificat, PUBLIC par nature, restait en 0600 ; - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ; - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ; - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ; - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ; - le flux declarait 3000 en dur. Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher s'ecrivait vide. ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec CAP_NET_BIND_SERVICE, et son URL n'a plus de port. Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200. 42 preuves vertes, flux coherents (34 roles, 92 flux). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
Elles ont habite `underlay.yml` quelques heures, faute d'avoir vu qu'un SITE A BIEN
UN PLAN. Ce qui le distingue d'un tenant n'est pas l'absence de plan mais l'absence
de DERIVATION : un tenant tire son adressage de son `index`, un site le declare.
site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai, c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan. SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent du registre des applications, les gardes ont suivi le plan (4 controles negatifs). Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout : - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ; - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ; - le certificat, PUBLIC par nature, restait en 0600 ; - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ; - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ; - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ; - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ; - le flux declarait 3000 en dur. Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher s'ecrivait vide. ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec CAP_NET_BIND_SERVICE, et son URL n'a plus de port. Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200. 42 preuves vertes, flux coherents (34 roles, 92 flux). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
Cet accesseur reste pour qu'un appelant oublie ne trouve pas un attribut manquant,
mais il ne ment pas : la carte ne porte plus de machines. Voir
`site_inventaire.plan_dir()` et `plan/serveurs.yml`.
"""
site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai, c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan. SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent du registre des applications, les gardes ont suivi le plan (4 controles negatifs). Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout : - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ; - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ; - le certificat, PUBLIC par nature, restait en 0600 ; - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ; - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ; - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ; - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ; - le flux declarait 3000 en dur. Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher s'ecrivait vide. ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec CAP_NET_BIND_SERVICE, et son URL n'a plus de port. Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200. 42 preuves vertes, flux coherents (34 roles, 92 flux). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
return []
FABRIC_DEFAUT = "principal"
# 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
# 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")
STP_MODES = ("rstp", "mstp", "pvst")
STP_TOPOLOGIES = ("etoile", "anneau", "maille")
ROUTAGE_TENANTS = ("switch", "sdn")
# VXLAN coute 50 octets d'encapsulation : le TRANSPORT doit valoir au moins l'overlay
# plus cette surcharge. Sous ce seuil, il casse PARTIELLEMENT — le ping passe, les
# transferts echouent — la panne la plus couteuse a diagnostiquer.
SURCOUT_VXLAN = 50
MTU_OVERLAY_DEFAUT = 1450 # ce que voit une VM ; regle par zone dans Proxmox SDN
def mtu_overlay(underlay: dict | None) -> int:
"""MTU annonce aux VM par les VNet. Declare, parce qu'il commande le minimum du
transport : un overlay a 1450 tient dans un transport a 1500, pas un overlay a 1500."""
v = (underlay or {}).get("mtu_overlay")
return int(v) if isinstance(v, int) else MTU_OVERLAY_DEFAUT
frontiere : une frontiere ne police que les tenants de SON site Mesure du 2026-08-14, sur le second site. `make frontiere-plan` voulait poser sur la frontiere de Technolibre les regles ET les routes de CHEZLEPRO : 30 objets de plus, dont six routes vers des sous-reseaux 10.17.x qui n'existent pas la-bas. LE DEFAUT EST DE PORTEE, ET IL EST SILENCIEUX. Les devis partaient de `devis_reseau.decouvrir()`, qui rend TOUTE la federation — tout dossier frere portant une nomenclature avec un index. C'etait juste tant qu'il n'y avait qu'un site : l'hebergeur unique portait bien tous les tenants. Des le second, le boitier aurait accepte ces trente objets, aucun n'aurait jamais correspondu a un paquet, et rien ne l'aurait signale — une politique qui a l'air complete et ne protege rien. Encore le chèque vert sur un perimetre vide. CORRECTIF : l'hebergeur declare dans SON underlay les tenants qu'il porte (`underlay.tenants`), et `devis_opnsense` s'y limite. Cle ABSENTE = ancien comportement (toute la federation) : un site unique n'a rien a declarer, c'est le second qui doit se nommer. Un nom declare qu'aucun dossier frere ne fournit est signale, pas ignore. MEME HYPOTHESE AILLEURS, non corrigee ici : `devis_sdn` et `devis_reseau` partent du meme `decouvrir()`. A traiter quand ils serviront sur un second site. AU PASSAGE, un defaut du document ecrit la veille : le squelette d'`underlay.yml` de `implanter-un-tenant-sur-un-site.md` omettait `index`. Sans cette cle, `make underlay` refuse le reseau de gestion en le prenant pour le supernet d'un AUTRE site — message deroutant, cause triviale. Trouve en s'en servant, moins de 24 h apres l'avoir ecrit. prouver 37/37, make test 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 00:55:39 -04:00
def tenants_du_site(underlay: dict | None) -> list[str] | None:
"""Noms de dossier des tenants HEBERGES sur ce site. None = non declare.
POURQUOI CETTE CLE EXISTE (mesure du 2026-08-13, sur le second site).
Les devis partaient de `devis_reseau.decouvrir()`, qui liste TOUTE la federation :
tout dossier frere portant un `plan/nomenclature.yml` avec un index. C'etait juste
tant qu'il n'y avait qu'un site — l'hebergeur unique portait bien tous les tenants.
Des le second site, c'est faux et dangereux. Le devis de la frontiere du site B
voulait y poser les regles ET les routes du tenant du site A : trente regles et six
routes vers des sous-reseaux qui n'existent pas la-bas. Elles auraient ete acceptees
par le boitier, elles n'auraient jamais correspondu a un paquet, et rien ne l'aurait
signale — une politique qui a l'air complete et ne protege rien.
Absente, on garde l'ancien comportement (toute la federation) : un site unique n'a
rien a declarer. C'est le second site qui doit se nommer.
"""
v = (underlay or {}).get("underlay", underlay or {}).get("tenants")
if v is None:
return None
if isinstance(v, dict):
return [str(k) for k in v]
frontiere : une frontiere ne police que les tenants de SON site Mesure du 2026-08-14, sur le second site. `make frontiere-plan` voulait poser sur la frontiere de Technolibre les regles ET les routes de CHEZLEPRO : 30 objets de plus, dont six routes vers des sous-reseaux 10.17.x qui n'existent pas la-bas. LE DEFAUT EST DE PORTEE, ET IL EST SILENCIEUX. Les devis partaient de `devis_reseau.decouvrir()`, qui rend TOUTE la federation — tout dossier frere portant une nomenclature avec un index. C'etait juste tant qu'il n'y avait qu'un site : l'hebergeur unique portait bien tous les tenants. Des le second, le boitier aurait accepte ces trente objets, aucun n'aurait jamais correspondu a un paquet, et rien ne l'aurait signale — une politique qui a l'air complete et ne protege rien. Encore le chèque vert sur un perimetre vide. CORRECTIF : l'hebergeur declare dans SON underlay les tenants qu'il porte (`underlay.tenants`), et `devis_opnsense` s'y limite. Cle ABSENTE = ancien comportement (toute la federation) : un site unique n'a rien a declarer, c'est le second qui doit se nommer. Un nom declare qu'aucun dossier frere ne fournit est signale, pas ignore. MEME HYPOTHESE AILLEURS, non corrigee ici : `devis_sdn` et `devis_reseau` partent du meme `decouvrir()`. A traiter quand ils serviront sur un second site. AU PASSAGE, un defaut du document ecrit la veille : le squelette d'`underlay.yml` de `implanter-un-tenant-sur-un-site.md` omettait `index`. Sans cette cle, `make underlay` refuse le reseau de gestion en le prenant pour le supernet d'un AUTRE site — message deroutant, cause triviale. Trouve en s'en servant, moins de 24 h apres l'avoir ecrit. prouver 37/37, make test 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 00:55:39 -04:00
return [str(x) for x in v] if isinstance(v, list) else [str(v)]
def allocations(underlay: dict | None) -> dict[str, int]:
"""Index ATTRIBUE par ce site a chacun de ses tenants. Vide si aucune allocation.
C'EST LE SITE QUI DETERMINE L'INDEX D'UN TENANT (2026-08-25).
SITE et OPS sont deux CLASSES distinctes : le site decide de la fabric et du reseau et
produit les intrants ; le tenant consomme ces intrants et n'a d'intelligence que sur
ses applications. Or l'index est l'intrant reseau par excellence — de lui descendent le
supernet, les VLAN, les VMID, les noms de VNet SDN. Un tenant qui le DECIDE decide donc
du plan d'adressage de la fabric, ce qui n'est pas a lui.
Il etait declare DEUX FOIS jusqu'ici : dans `plan/nomenclature.yml` du tenant et dans
l'`underlay` du site. Deux sources de verite pour le meme nombre, dans deux classes
differentes — et une preuve (P21) dont le seul role etait de courir apres les doublons
que cette double declaration rendait possibles.
Le sens de la fleche change, pas les valeurs : le site ALLOUE, le tenant RECOIT, et la
nomenclature du tenant devient la copie verifiable d'une decision prise ailleurs. Un
tenant qui s'emancipe vers un autre site en recoit un neuf de son nouvel hote.
Forme heritee (une simple liste de noms) toujours acceptee : elle dit « ces tenants
sont ici » sans rien allouer, et ne vaut que pour un site qui n'a pas encore migre.
"""
v = (underlay or {}).get("underlay", underlay or {}).get("tenants")
if not isinstance(v, dict):
return {}
out: dict[str, int] = {}
for nom, idx in v.items():
if isinstance(idx, bool) or not isinstance(idx, int):
continue # signale par `valider`, pas ignore en silence
out[str(nom)] = idx
return out
def routage_tenants(underlay: dict | None) -> str:
"""Qui route entre les zones d'un tenant : `switch` (SVI) ou `sdn` (EVPN).
En `sdn`, une zone EVPN par tenant porte le VRF : le routage ET le filtrage
inter-zone d'un meme tenant vivent sur les hyperviseurs. Aucun VLAN de tenant ne
circule alors sur le fil — seulement du VXLAN encapsule dans de l'IP — et le devis
switch cesse d'emettre VLAN tenants, SVI et ACL.
L'inter-tenant, lui, sort du VRF et passe par la frontiere, qui le police.
"""
v = str((underlay or {}).get("routage_tenants") or "switch")
return v if v in ROUTAGE_TENANTS else "switch"
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)
def dialecte(underlay: dict | None) -> str:
"""Dialecte de CLI declare pour la fabric. '' si absent."""
return str((underlay or {}).get("dialecte") or "")
def stp(underlay: dict | None) -> dict:
"""Parametres de spanning-tree de la fabric principale. {} si non declares."""
return (underlay or {}).get("stp") or {}
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
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
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
# `passerelle_amont` : un routeur que nous N'ADMINISTRONS PAS. Il existe, il porte une
# adresse, des choses sortent par lui — mais aucun devis ne le configure et il ne recoit
# aucune regle. Le declarer n'est pas l'adopter : c'est empecher le validateur de crier
# a la passerelle fantome pour une passerelle qui, elle, est bien reelle.
ROLES_HOTE = ("switch", "hyperviseur", "frontiere", "passerelle_amont")
def role_hote(hote: dict) -> str:
"""Nature d'un equipement declare. `switch` par defaut (compatibilite).
Le reseau ne suffit pas a la deduire : un hyperviseur partage le reseau de
management avec les commutateurs, et recevrait sinon leur configuration.
"""
return str((hote or {}).get("role") or "switch")
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.
Filtre sur le ROLE autant que sur le reseau : un hyperviseur declare sur le meme
reseau ne doit recevoir aucune ligne de configuration de commutateur.
"""
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"]
and role_hote(h) == "switch"]
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
def charger_depuis(chemin_fichier: Path) -> dict | None:
"""Le bloc `underlay` d'un fichier DESIGNE — pour valider un modele, pas le site."""
if not chemin_fichier.is_file():
return None
data = yaml.safe_load(chemin_fichier.read_text(encoding="utf-8")) or {}
return data.get("underlay") or None
def octet_passerelle(plan_nomenclature: Path | None = None) -> int | None:
"""Dernier octet reserve aux points de routage, lu dans la nomenclature active.
Convention d'exploitation : un routeur porte LE MEME dernier octet sur tous les
sous-reseaux ou il participe — on retient une adresse, pas treize. Declare une
seule fois (`reservations.passerelle`), jamais code en dur ici.
"""
resolution d'instance : une seule, partagee — au lieu de neuf copies Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? » Neuf modules portaient chacun leur reponse. - 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ; - 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable ; - 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ; - 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme. Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier. LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(), dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve — c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut. Vingt-huit modules y sont branches. CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure : 17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose. P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees : instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de la resolution. make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
p = plan_nomenclature or (instance_courante() / "plan" / "nomenclature.yml")
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
def index_site(underlay: dict | None) -> int | None:
"""`index` du site — le seed dont derive tout son adressage (D-77). None si absent."""
v = (underlay or {}).get("underlay", underlay or {}).get("index")
return int(v) if isinstance(v, int) else None
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
underlay : les tenants declares sont confrontes aux dossiers reels Suite du filtre de portee : `underlay.tenants` nomme des DOSSIERS FRERES, et une faute de frappe y etait invisible — le tenant disparaissait des trois devis du site, qui restaient « conformes » sur ce qu'il en restait. Sur un site a UN SEUL tenant — le cas de la prochaine implantation — la faute rend un devis VIDE : une frontiere sans regle, un commutateur sans VLAN. Rien dans le mot « conforme » ne dirait qu'on vient de dessiner le vide. L'ecart est lisible sans toucher au materiel : d'un cote une liste de noms, de l'autre les dossiers presents. Il se dit donc a `make underlay` (D-75). Quatre situations, quatre messages distincts : dossier absent ; dossier sans plan/nomenclature.yml ; nomenclature non federee (index absent, categories vide, federe: false) ; plus aucun nom qui corresponde. CE QU'UN GABARIT NE DOIT PAS SUBIR. Un modele decrit du materiel, pas un site deploye : sans garde, tout modele portant un exemple de `tenants` echouerait chez quiconque n'a pas ce dossier, et P17 deviendrait rouge sur la machine du voisin. La distinction existait deja : modeles.py passe des reperes de tenants EXPLICITES (gabarit), le site les laisse deriver. La verification ne s'applique qu'au second cas. Trois tests dans test_adressage_derive.py — nom introuvable, clef absente, gabarit epargne — avec un nom absurde pour qu'aucun test ne depende des dossiers de la machine. make test 15 + 9 ; prouver 37 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:55:49 -04:00
def _erreurs_tenants_declares(underlay: dict | None) -> list[str]:
"""`tenants` ne nomme que des tenants que la decouverte trouve REELLEMENT.
POURQUOI STATIQUEMENT (D-75). Un nom mal orthographie dans `tenants` ne se voit pas :
le tenant disparait simplement des trois devis du site, qui restent « conformes » sur
ce qu'il en reste. Sur un site a un seul tenant, la faute de frappe rend un devis VIDE
— une frontiere sans regle, un commutateur sans VLAN — et rien dans le mot
« conforme » ne dit qu'on vient de dessiner le vide.
L'ecart est entierement lisible sans toucher au materiel : d'un cote une liste de
noms, de l'autre les dossiers freres. On le dit donc a `make underlay`, pas au
moment ou l'on pousse dans un boitier.
"""
portes = tenants_du_site(underlay)
if portes is None:
return [] # clef absente = toute la federation : rien a verifier
import devis_reseau # import paresseux : meme cycle que _tenants_reperes
connus = {nom for nom, _pfx, _n in devis_reseau.decouvrir()}
erreurs: list[str] = []
for nom in portes:
if nom in connus:
continue
dossier = RACINE.parent / nom
if not dossier.is_dir():
erreurs.append(f"tenants: « {nom} » — aucun dossier frere de ce nom "
f"(attendu : {dossier})")
elif not (dossier / "plan" / "nomenclature.yml").is_file():
erreurs.append(f"tenants: « {nom} » — dossier present, mais sans "
f"plan/nomenclature.yml : ce n'est pas une instance")
else:
erreurs.append(f"tenants: « {nom} » — nomenclature presente mais non federee "
f"(`index` absent, `categories` vide ou `federe: false`)")
if not (set(portes) & connus):
erreurs.append("tenants: aucun nom declare ne correspond a un tenant federe — "
"les devis de ce site n'auraient rien a poser")
# --- L'ALLOCATION, ET SA COHERENCE AVEC CE QUE LE TENANT PORTE ---------
#
# Le site ALLOUE, le tenant RECOIT. La nomenclature du tenant reste donc le lieu ou
# l'index se LIT — tout le moteur le lit la — mais elle n'est plus le lieu ou il se
# DECIDE. Ce qui suit compare les deux et refuse l'ecart : sans cette comparaison,
# « allouer » ne serait qu'un commentaire.
brut = (underlay or {}).get("underlay", underlay or {}).get("tenants")
if isinstance(brut, dict):
vus: dict[int, str] = {}
indices = {nom: n["index"] for nom, _pfx, n in devis_reseau.decouvrir()
if n.get("index") is not None}
for nom, idx in brut.items():
if isinstance(idx, bool) or not isinstance(idx, int):
erreurs.append(f"tenants: « {nom} » — index alloue « {idx!r} » n'est pas "
f"un entier ; une allocation qui n'en est pas une ne "
f"protege de rien")
continue
if idx in vus:
erreurs.append(f"tenants: index {idx} alloue a la fois a « {vus[idx]} » "
f"et a « {nom} » — c'est exactement la collision que "
f"l'allocation par le site doit rendre impossible")
vus[idx] = str(nom)
porte = indices.get(str(nom))
if porte is not None and porte != idx:
erreurs.append(
f"tenants: « {nom} » — le site lui alloue l'index {idx}, mais son "
f"plan/nomenclature.yml en porte {porte}. Le site decide ; corriger "
f"le plan du tenant, pas l'allocation.")
underlay : les tenants declares sont confrontes aux dossiers reels Suite du filtre de portee : `underlay.tenants` nomme des DOSSIERS FRERES, et une faute de frappe y etait invisible — le tenant disparaissait des trois devis du site, qui restaient « conformes » sur ce qu'il en restait. Sur un site a UN SEUL tenant — le cas de la prochaine implantation — la faute rend un devis VIDE : une frontiere sans regle, un commutateur sans VLAN. Rien dans le mot « conforme » ne dirait qu'on vient de dessiner le vide. L'ecart est lisible sans toucher au materiel : d'un cote une liste de noms, de l'autre les dossiers presents. Il se dit donc a `make underlay` (D-75). Quatre situations, quatre messages distincts : dossier absent ; dossier sans plan/nomenclature.yml ; nomenclature non federee (index absent, categories vide, federe: false) ; plus aucun nom qui corresponde. CE QU'UN GABARIT NE DOIT PAS SUBIR. Un modele decrit du materiel, pas un site deploye : sans garde, tout modele portant un exemple de `tenants` echouerait chez quiconque n'a pas ce dossier, et P17 deviendrait rouge sur la machine du voisin. La distinction existait deja : modeles.py passe des reperes de tenants EXPLICITES (gabarit), le site les laisse deriver. La verification ne s'applique qu'au second cas. Trois tests dans test_adressage_derive.py — nom introuvable, clef absente, gabarit epargne — avec un nom absurde pour qu'aucun test ne depende des dossiers de la machine. make test 15 + 9 ; prouver 37 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:55:49 -04:00
return erreurs
def valider(underlay: dict | None,
tenants_vlans: set[int] | None = None,
tenants_supernets: list[str] | None = None,
plan_nomenclature: Path | None = None) -> list[str]:
"""Liste des erreurs (vide = conforme). Verifie champs requis, unicite, et
surtout l'absence de collision avec la plage tenant (VLAN et sous-reseaux)."""
if underlay is None:
return [] # absent = rien a valider (le devis omettra la section)
underlay : les tenants declares sont confrontes aux dossiers reels Suite du filtre de portee : `underlay.tenants` nomme des DOSSIERS FRERES, et une faute de frappe y etait invisible — le tenant disparaissait des trois devis du site, qui restaient « conformes » sur ce qu'il en restait. Sur un site a UN SEUL tenant — le cas de la prochaine implantation — la faute rend un devis VIDE : une frontiere sans regle, un commutateur sans VLAN. Rien dans le mot « conforme » ne dirait qu'on vient de dessiner le vide. L'ecart est lisible sans toucher au materiel : d'un cote une liste de noms, de l'autre les dossiers presents. Il se dit donc a `make underlay` (D-75). Quatre situations, quatre messages distincts : dossier absent ; dossier sans plan/nomenclature.yml ; nomenclature non federee (index absent, categories vide, federe: false) ; plus aucun nom qui corresponde. CE QU'UN GABARIT NE DOIT PAS SUBIR. Un modele decrit du materiel, pas un site deploye : sans garde, tout modele portant un exemple de `tenants` echouerait chez quiconque n'a pas ce dossier, et P17 deviendrait rouge sur la machine du voisin. La distinction existait deja : modeles.py passe des reperes de tenants EXPLICITES (gabarit), le site les laisse deriver. La verification ne s'applique qu'au second cas. Trois tests dans test_adressage_derive.py — nom introuvable, clef absente, gabarit epargne — avec un nom absurde pour qu'aucun test ne depende des dossiers de la machine. make test 15 + 9 ; prouver 37 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:55:49 -04:00
# Les tenants reperes fournis par l'appelant = validation d'un GABARIT (modeles.py) :
# un modele decrit du materiel, pas un site deploye, et ne peut pas nommer de tenants
# reels. Seule la validation d'un SITE confronte `tenants` aux dossiers freres.
mode_site = tenants_vlans is None or tenants_supernets is None
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]
# UN SITE N'A PAS D'INDEX (2026-08-25). Il en portait un, et c'etait un vestige.
#
# Cette valeur ne disait pas « voici mon adressage » — le site ne derive rien, ses
# machines vivent sur un reseau de fabric. Elle disait une seule chose : « QUEL
# SUPERNET DE TENANT EST LE MIEN », pour autoriser un reseau du site a occuper sa
# bande basse. C'etait un reste de la coincidence hebergeur↔tenant chez Chezlepro,
# qui est a la fois les deux — et qui aurait induit en erreur partout ailleurs.
#
# L'exception se declare desormais SUR LE RESEAU qui en a besoin, et elle NOMME le
# tenant dont elle occupe la bande basse. C'est plus vrai (un seul reseau est
# concerne, pas le site entier) et plus verifiable : le nom se resout dans le registre
# d'allocation, donc une faute de frappe est refusee au lieu de passer.
alloc = allocations(underlay)
def _supernet_hote(r: dict) -> tuple[ipaddress.IPv4Network | None, str | None]:
"""Supernet dont ce reseau occupe la bande basse, et l'erreur s'il n'existe pas."""
nom_t = str(r.get("bande_basse_de") or "").strip()
if not nom_t:
return None, None
if nom_t not in alloc:
return None, (f"reseau '{r.get('nom', '?')}': `bande_basse_de: {nom_t}` — ce "
f"nom n'est pas dans le registre `tenants:` du site. Sans "
f"allocation connue, on ne peut pas savoir quel supernet il "
f"occupe, ni verifier qu'il reste dans la bande basse.")
return ipaddress.ip_network(supernet_de(alloc[nom_t]), strict=False), None
erreurs: list[str] = []
underlay : les tenants declares sont confrontes aux dossiers reels Suite du filtre de portee : `underlay.tenants` nomme des DOSSIERS FRERES, et une faute de frappe y etait invisible — le tenant disparaissait des trois devis du site, qui restaient « conformes » sur ce qu'il en restait. Sur un site a UN SEUL tenant — le cas de la prochaine implantation — la faute rend un devis VIDE : une frontiere sans regle, un commutateur sans VLAN. Rien dans le mot « conforme » ne dirait qu'on vient de dessiner le vide. L'ecart est lisible sans toucher au materiel : d'un cote une liste de noms, de l'autre les dossiers presents. Il se dit donc a `make underlay` (D-75). Quatre situations, quatre messages distincts : dossier absent ; dossier sans plan/nomenclature.yml ; nomenclature non federee (index absent, categories vide, federe: false) ; plus aucun nom qui corresponde. CE QU'UN GABARIT NE DOIT PAS SUBIR. Un modele decrit du materiel, pas un site deploye : sans garde, tout modele portant un exemple de `tenants` echouerait chez quiconque n'a pas ce dossier, et P17 deviendrait rouge sur la machine du voisin. La distinction existait deja : modeles.py passe des reperes de tenants EXPLICITES (gabarit), le site les laisse deriver. La verification ne s'applique qu'au second cas. Trois tests dans test_adressage_derive.py — nom introuvable, clef absente, gabarit epargne — avec un nom absurde pour qu'aucun test ne depende des dossiers de la machine. make test 15 + 9 ; prouver 37 OK, 0 echec, 0 saute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:55:49 -04:00
if mode_site:
erreurs += _erreurs_tenants_declares(underlay)
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")
# UN SEGMENT PHYSIQUE SEPARE N'A PAS D'ETIQUETTE (mesure du 2026-08-24).
# Le plan d'administration de Chezlepro arrive sur `igb0` de la frontiere — un
# PORT A LUI, non etiquete. Lui inventer un `vlan: 10` faisait croire a un VLAN
# trunke qui n'existe nulle part : trois sondes depuis asgard (bond0 tag 10,
# bond3 tag 10, vmbr1 natif) sont restees muettes, temoin positif a l'appui.
# `segment_physique: true` dit cette nature, et dispense d'etiquette.
if r.get("segment_physique") and vlan is None:
vlan = None
elif vlan is None or sous is None:
erreurs.append(f"reseau '{nom}': 'vlan' et 'sous_reseau' sont requis "
f"(ou `segment_physique: true` si le reseau n'est pas etiquete)")
continue
if sous is None:
erreurs.append(f"reseau '{nom}': 'sous_reseau' est requis")
continue
elif vlan is not None and (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 is not None and vlan in tenants_vlans:
erreurs.append(f"reseau '{nom}': VLAN {vlan} deja porte par un tenant federe")
if vlan is not None and vlan in vus_vlan:
erreurs.append(f"reseau '{nom}': VLAN {vlan} en double avec '{vus_vlan[vlan]}'")
if vlan is not None:
vus_vlan[vlan] = nom
try:
net = ipaddress.ip_network(sous, strict=False)
except ValueError:
erreurs.append(f"reseau '{nom}': sous_reseau '{sous}' invalide")
continue
# D-77 : l'underlay d'un site vit dans la BANDE BASSE de SON propre supernet.
# Chevaucher celui d'un AUTRE tenant reste une erreur — c'est ce qui empeche deux
# sites de se joindre. Et deborder sur la bande haute de son propre supernet en
# est une aussi : on entrerait en collision avec ses propres zones.
mien, err_bande = _supernet_hote(r)
if err_bande:
erreurs.append(err_bande)
for ts in t_supernets:
if not net.overlaps(ts):
continue
if mien is not None and ts == mien:
# Le sien : seule la bande basse est permise.
hauts = [o for o in range(net.network_address.packed[2],
net.broadcast_address.packed[2] + 1)
if o >= PREMIER_OCTET_ZONE]
if hauts:
erreurs.append(
f"reseau '{nom}': {sous} deborde sur la bande des ZONES de son "
f"propre tenant (3e octet {hauts[0]} >= {PREMIER_OCTET_ZONE}). "
f"L'underlay tient dans 10.x.0-{PREMIER_OCTET_ZONE - 1}.x (D-77).")
else:
erreurs.append(f"reseau '{nom}': {sous} chevauche le supernet tenant {ts} "
f"— c'est celui d'un AUTRE site, les deux ne pourront "
f"jamais etre relies")
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)")
routage : plus aucun commutateur ne route, la frontière est le seul L3 (D-49 à D-51) Aucun des trois SVI de sleipnir-01 n'avait de consommateur : les VTEP sont dans le même sous-réseau, les nœuds de sortie sont adjacents à la frontière, et les commutateurs peuvent sortir par l'OPNsense qui a déjà une patte sur le VLAN 10. Deux décisions séparées avaient vidé ce rôle sans qu'on regarde leur effet cumulé : le passage à l'EVPN a retiré les VLAN tenants du fil, puis la fusion du lien de sortie dans le VLAN 40 a rendu les nœuds de sortie adjacents. sleipnir-01 disparaît, pas seulement son rôle : en étoile, le centre est sur tous les chemins, donc un point de panne unique du plan de données — ce qui vidait aussi de son sens l'ajout d'une seconde carte à bond3. Deux switches L2 reliés, bond3 répartis. D-51 ; D-05 renversée. Le devis perd trois SVI, quatre routes, et surtout sa section 5 — celle qui coupait l'accès d'administration au switch en cas d'erreur. D-50 : `passerelle` signifiait « adresse du SVI du switch », une hypothèse déguisée en donnée. Elle signifie maintenant « la passerelle de ce sous-réseau, où qu'elle vive », et le devis dérive s'il doit émettre une interface routée — uniquement si le porteur déclaré a le rôle switch. Le même moteur sert les deux postures : le modèle public démontre celle où le switch route. Deux gardes remplacées, pas affaiblies. À la place de « passerelle_sortie exige passerelle » et « routeur.ip == passerelle », une règle plus forte : une passerelle doit être l'adresse d'un hôte déclaré sur ce réseau. Elle attrape en plus les passerelles fantômes. Éprouvée par trois sabotages, tous attrapés — et elle a trouvé une sous-déclaration dans le modèle public. Quatre trous corrigés, tous de la même famille (une liste figée finit par mentir) : port de frontière figé sur le transit, trunk Proxmox excluant le transit, switches d'accès sautant sa déclaration, et le switch de tête privé d'adresse de gestion par la suppression du SVI. D-03 renversée : le /29 élargi en /24 fait tomber l'exemption d'invariant, le .1 revient à la passerelle. 30 preuves OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:23:57 -04:00
elif False:
erreurs.append(f"reseau '{nom}': (inatteignable) "
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
f"(le SVI du switch sur le lien de transit)")
except ValueError:
erreurs.append(f"reseau '{nom}': passerelle_sortie '{r['passerelle_sortie']}' invalide")
vus_sous.append((net, nom))
for h in hotes(underlay):
r = (h or {}).get("role")
if r is not None and str(r) not in ROLES_HOTE:
erreurs.append(f"hote '{h.get('nom','?')}': role '{r}' inconnu "
f"(attendu : {', '.join(ROLES_HOTE)})")
# 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")
underlay : l'invariant du dernier octet retrouve sa portée (D-04, D-52 à D-54) Il valait partout ; il ne vaut que dans l'adressage dérivé des tenants, où passerelle_de(index, zone) produit le même .1 dans les treize sous-réseaux d'un tenant. C'est une propriété de la dérivation, pas une loi universelle. Dans l'underlay il produisait deux effets pervers. Un seuil arbitraire : les sous-réseaux plus étroits qu'un /24 étaient exemptés, donc élargir un /29 changeait la validité du fichier sans que rien d'autre bouge. Et une couture entre propriétaires : l'octet attendu venait de la nomenclature d'un tenant, appliquée à la fabric de l'hébergeur — la validité de l'underlay aurait dépendu du tenant actif. Ce qui reste est plus fort et suffit (D-52) : une passerelle doit être l'adresse d'un hôte déclaré sur ce réseau. Elle attrape les passerelles fantômes, ce que le comptage d'octets ne faisait pas. Vérifié : la garde mord toujours. À noter, parce que l'ordre était mauvais : le ré-adressage de l'OPNsense en .1 a été demandé au nom de cette règle, deux messages avant qu'elle soit recadrée. Pas perdu — .1 est la position conventionnelle d'une passerelle — mais la portée aurait dû être questionnée avant de faire changer une adresse en service. D-53 : le réseau et l'underlay de l'hébergeur méritent leur propre dépôt. underlay.yml décrit une infrastructure, le dépôt de tenant une organisation ; un tenant peut déménager, une fabric non. Consigné, non fait. 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. 30 preuves OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:46:06 -04:00
# L'INVARIANT DU DERNIER OCTET NE S'APPLIQUE PAS ICI (decide le 2026-08-04).
#
# Il vaut pour l'adressage DERIVE des tenants, ou `passerelle_de(index, zone)`
# produit le meme `.1` dans les treize sous-reseaux d'un tenant : on retient une
# adresse, pas treize. C'est une propriete de la derivation, pas une regle
# universelle.
#
# Dans l'underlay, l'adressage est dicte par les PARTICIPANTS d'un lien physique.
# La regle y produisait deux effets pervers : un seuil arbitraire (les sous-reseaux
# plus etroits qu'un /24 etaient exemptes, donc elargir un /29 changeait la
# validite du fichier), et une couture entre proprietaires — l'octet attendu venait
# de la nomenclature d'un TENANT, appliquee a la fabric de l'HEBERGEUR. La validite
# de l'underlay aurait dependu du tenant actif si l'un d'eux reservait autre chose.
#
# Ce qui reste, et qui est plus fort : une passerelle doit etre l'adresse d'un hote
# DECLARE sur ce reseau (verifie plus bas). Elle attrape les passerelles fantomes,
# ce que le comptage d'octets ne faisait pas.
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})")
rt = (underlay or {}).get("routage_tenants")
if rt is not None and str(rt) not in ROUTAGE_TENANTS:
erreurs.append(f"routage_tenants '{rt}' inconnu (attendu : {', '.join(ROUTAGE_TENANTS)})")
if routage_tenants(underlay) == "sdn":
# Le transport doit valoir l'overlay DECLARE plus la surcharge d'encapsulation.
minimal = mtu_overlay(underlay) + SURCOUT_VXLAN
fab = fabric_du_routeur(underlay)
for r in reseaux_de_fabric(underlay, fab):
mtu = int(r.get("mtu") or 1500)
if mtu < minimal:
erreurs.append(
f"reseau '{r.get('nom','?')}': MTU {mtu} < {minimal} "
f"(overlay {mtu_overlay(underlay)} + {SURCOUT_VXLAN} de VXLAN) — sous ce "
f"seuil le ping passe et les transferts echouent")
d = dialecte(underlay)
if d and d not in DIALECTES:
erreurs.append(f"dialecte '{d}' inconnu (attendu : {', '.join(DIALECTES)})")
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)}")
# 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")
routage : plus aucun commutateur ne route, la frontière est le seul L3 (D-49 à D-51) Aucun des trois SVI de sleipnir-01 n'avait de consommateur : les VTEP sont dans le même sous-réseau, les nœuds de sortie sont adjacents à la frontière, et les commutateurs peuvent sortir par l'OPNsense qui a déjà une patte sur le VLAN 10. Deux décisions séparées avaient vidé ce rôle sans qu'on regarde leur effet cumulé : le passage à l'EVPN a retiré les VLAN tenants du fil, puis la fusion du lien de sortie dans le VLAN 40 a rendu les nœuds de sortie adjacents. sleipnir-01 disparaît, pas seulement son rôle : en étoile, le centre est sur tous les chemins, donc un point de panne unique du plan de données — ce qui vidait aussi de son sens l'ajout d'une seconde carte à bond3. Deux switches L2 reliés, bond3 répartis. D-51 ; D-05 renversée. Le devis perd trois SVI, quatre routes, et surtout sa section 5 — celle qui coupait l'accès d'administration au switch en cas d'erreur. D-50 : `passerelle` signifiait « adresse du SVI du switch », une hypothèse déguisée en donnée. Elle signifie maintenant « la passerelle de ce sous-réseau, où qu'elle vive », et le devis dérive s'il doit émettre une interface routée — uniquement si le porteur déclaré a le rôle switch. Le même moteur sert les deux postures : le modèle public démontre celle où le switch route. Deux gardes remplacées, pas affaiblies. À la place de « passerelle_sortie exige passerelle » et « routeur.ip == passerelle », une règle plus forte : une passerelle doit être l'adresse d'un hôte déclaré sur ce réseau. Elle attrape en plus les passerelles fantômes. Éprouvée par trois sabotages, tous attrapés — et elle a trouvé une sous-déclaration dans le modèle public. Quatre trous corrigés, tous de la même famille (une liste figée finit par mentir) : port de frontière figé sur le transit, trunk Proxmox excluant le transit, switches d'accès sautant sa déclaration, et le switch de tête privé d'adresse de gestion par la suppression du SVI. D-03 renversée : le /29 élargi en /24 fait tomber l'exemption d'invariant, le .1 revient à la passerelle. 30 preuves OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:23:57 -04:00
# La passerelle d'un reseau doit APPARTENIR A UN HOTE DECLARE de ce reseau.
#
# Cette regle en remplace une plus etroite, qui exigeait que la passerelle soit
# l'adresse du switch routeur. Elle etait vraie tant qu'un switch routait ; depuis
# que la frontiere est le seul equipement L3 (2026-08-04), elle refusait la seule
# configuration correcte. La nouvelle est PLUS forte : elle attrape aussi une
# passerelle qui ne correspond a rien — une adresse inventee, un octet de trop.
par_reseau: dict[str, list[dict]] = {}
for h in hotes(underlay):
routage : plus aucun commutateur ne route, la frontière est le seul L3 (D-49 à D-51) Aucun des trois SVI de sleipnir-01 n'avait de consommateur : les VTEP sont dans le même sous-réseau, les nœuds de sortie sont adjacents à la frontière, et les commutateurs peuvent sortir par l'OPNsense qui a déjà une patte sur le VLAN 10. Deux décisions séparées avaient vidé ce rôle sans qu'on regarde leur effet cumulé : le passage à l'EVPN a retiré les VLAN tenants du fil, puis la fusion du lien de sortie dans le VLAN 40 a rendu les nœuds de sortie adjacents. sleipnir-01 disparaît, pas seulement son rôle : en étoile, le centre est sur tous les chemins, donc un point de panne unique du plan de données — ce qui vidait aussi de son sens l'ajout d'une seconde carte à bond3. Deux switches L2 reliés, bond3 répartis. D-51 ; D-05 renversée. Le devis perd trois SVI, quatre routes, et surtout sa section 5 — celle qui coupait l'accès d'administration au switch en cas d'erreur. D-50 : `passerelle` signifiait « adresse du SVI du switch », une hypothèse déguisée en donnée. Elle signifie maintenant « la passerelle de ce sous-réseau, où qu'elle vive », et le devis dérive s'il doit émettre une interface routée — uniquement si le porteur déclaré a le rôle switch. Le même moteur sert les deux postures : le modèle public démontre celle où le switch route. Deux gardes remplacées, pas affaiblies. À la place de « passerelle_sortie exige passerelle » et « routeur.ip == passerelle », une règle plus forte : une passerelle doit être l'adresse d'un hôte déclaré sur ce réseau. Elle attrape en plus les passerelles fantômes. Éprouvée par trois sabotages, tous attrapés — et elle a trouvé une sous-déclaration dans le modèle public. Quatre trous corrigés, tous de la même famille (une liste figée finit par mentir) : port de frontière figé sur le transit, trunk Proxmox excluant le transit, switches d'accès sautant sa déclaration, et le switch de tête privé d'adresse de gestion par la suppression du SVI. D-03 renversée : le /29 élargi en /24 fait tomber l'exemption d'invariant, le .1 revient à la passerelle. 30 preuves OK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:23:57 -04:00
if h.get("reseau"):
par_reseau.setdefault(h["reseau"], []).append(h)
for r in reseaux(underlay):
for cle, sens in (("passerelle", "passerelle"), ("passerelle_sortie", "sortie")):
adr = r.get(cle)
if not adr:
continue
porteurs = [h for h in par_reseau.get(r.get("nom"), []) if str(h.get("ip")) == str(adr)]
if not porteurs:
erreurs.append(
f"reseau '{r.get('nom','?')}': {sens} {adr} n'est l'adresse d'aucun hote "
f"declare sur ce reseau — passerelle fantome")
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")
gabarit : refabrique, minimal, et puise aux ressources du SITE REFABRIQUE (VMID 9006, modeleSetOPS-minimal). Quatre roles au lieu de dix-sept : qemu_guest_agent, cloud_init, sudo_ansible, ssh_baseline — des conditions d existence, pas des choix d efficacite. Tout le reste vient du socle, et P56 refuse qu un role retire ne soit repris par personne. FABRIQUE CHEZ LE SITE, ET C EST LA DECISION QUI COMPTE. « Les ressources du SITE font autorite pour tous ses artefacts ; elles servent les tenants jusqu a ce qu ils s emancipent. » Il se fabriquait DEHORS : `-i "<ip>,"` ne porte aucun group_vars, donc ni mandataire ni resolveur. L ancien gabarit allait chercher ses paquets chez Debian et resolvait chez l ancien LAN — 192.168.10.10, lu sur la VM 99998. Le site avait son cache et son resolveur, et son propre artefact les ignorait. Desormais la fabrication DERIVE ses ressources du plan du site (underlay --adresses) et tourne sur le reseau du genome. PROUVE : dix requetes de 10.0.33.31 servies par site-cache-01, resolveur pose a 10.0.34.11. L IDENTITE DU GABARIT VIENT DU SITE, PLUS DU TENANT. `proxmox_clone_vmid_modele` vivait dans les group_vars de l ecosysteme : deux tenants pouvaient cloner deux gabarits differents sans que rien ne le dise, et un tenant decidait d un objet dont descend chaque VM de chaque ecosysteme. Meme mouvement que l INDEX (2026-08-25) : le site ALLOUE, le tenant RECOIT. Cle `gabarit` du plan du site, lue par underlay --gabarit. PIEGE FERME EN CHEMIN : creer-vm passait VMID_MODELE="$SETOPS_VMID_MODELE" a cloner-vm, or inventory_host.py n emet PAS cette variable. Elle valait donc le VIDE, et ce vide ECRASAIT la valeur derivee — le gabarit du tenant reprenait la main sans bruit. ET UN PIEGE DEJA DOCUMENTE, PAYE UNE TROISIEME FOIS : le chemin du controleur contient une espace, et `lookup('pipe', ...)` le decoupait. Guillemets. EPROUVE DE BOUT EN BOUT : VM clonee du gabarit minimal — nom, adresse, machine-id neuf, cle d hote regeneree, agent invite actif ; puis socle applique dessus, changed=5, 0 echec, auditd compris. VM d essai retiree. make verifier : vert. make prouver : CONFORME, 56 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-01 13:46:31 -04:00
# LE SITE, INTERROGEABLE DEPUIS UN PLAYBOOK. La fabrication du gabarit doit puiser aux
# ressources du site (« elles font autorite pour tous ses artefacts »), et elle tourne
# sur un inventaire d'un seul hote — sans `group_vars`, donc sans rien deriver
# elle-meme. Elle demande ici, plutot que d'ecrire une adresse qui vieillirait.
ap.add_argument("--gabarit", action="store_true",
help="le gabarit dore declare par le SITE (vmid, nom, noeud)")
ap.add_argument("--adresses", metavar="GROUPE",
help="adresses des machines du SITE portant ce groupe, une par ligne")
args = ap.parse_args()
gabarit : refabrique, minimal, et puise aux ressources du SITE REFABRIQUE (VMID 9006, modeleSetOPS-minimal). Quatre roles au lieu de dix-sept : qemu_guest_agent, cloud_init, sudo_ansible, ssh_baseline — des conditions d existence, pas des choix d efficacite. Tout le reste vient du socle, et P56 refuse qu un role retire ne soit repris par personne. FABRIQUE CHEZ LE SITE, ET C EST LA DECISION QUI COMPTE. « Les ressources du SITE font autorite pour tous ses artefacts ; elles servent les tenants jusqu a ce qu ils s emancipent. » Il se fabriquait DEHORS : `-i "<ip>,"` ne porte aucun group_vars, donc ni mandataire ni resolveur. L ancien gabarit allait chercher ses paquets chez Debian et resolvait chez l ancien LAN — 192.168.10.10, lu sur la VM 99998. Le site avait son cache et son resolveur, et son propre artefact les ignorait. Desormais la fabrication DERIVE ses ressources du plan du site (underlay --adresses) et tourne sur le reseau du genome. PROUVE : dix requetes de 10.0.33.31 servies par site-cache-01, resolveur pose a 10.0.34.11. L IDENTITE DU GABARIT VIENT DU SITE, PLUS DU TENANT. `proxmox_clone_vmid_modele` vivait dans les group_vars de l ecosysteme : deux tenants pouvaient cloner deux gabarits differents sans que rien ne le dise, et un tenant decidait d un objet dont descend chaque VM de chaque ecosysteme. Meme mouvement que l INDEX (2026-08-25) : le site ALLOUE, le tenant RECOIT. Cle `gabarit` du plan du site, lue par underlay --gabarit. PIEGE FERME EN CHEMIN : creer-vm passait VMID_MODELE="$SETOPS_VMID_MODELE" a cloner-vm, or inventory_host.py n emet PAS cette variable. Elle valait donc le VIDE, et ce vide ECRASAIT la valeur derivee — le gabarit du tenant reprenait la main sans bruit. ET UN PIEGE DEJA DOCUMENTE, PAYE UNE TROISIEME FOIS : le chemin du controleur contient une espace, et `lookup('pipe', ...)` le decoupait. Guillemets. EPROUVE DE BOUT EN BOUT : VM clonee du gabarit minimal — nom, adresse, machine-id neuf, cle d hote regeneree, agent invite actif ; puis socle applique dessus, changed=5, 0 echec, auditd compris. VM d essai retiree. make verifier : vert. make prouver : CONFORME, 56 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-01 13:46:31 -04:00
if args.gabarit:
g = gabarit()
if not g:
return
# `cle=valeur` par ligne : consommable par `eval` en shell comme par Ansible.
gabarit : q35 n est pas un reglage, c est la raison de la procedure manuelle CONSTAT DE L EXPLOITANT, PAYE EN ANOMALIES : convertir une machine deja installee d `i440fx` a `q35` produit une serie de pannes dont chacune ressemble a autre chose qu a sa cause. Ce n est pas une correction, c est une transplantation. LE MECANISME, ECRIT POUR QU ON NE LE REDECOUVRE PAS : `i440fx` est un chipset PCI, `q35` est PCIe. La topologie des bus change, donc les NOMS D INTERFACES PREDICTIBLES changent avec le chemin PCI (enp0s3 -> enp1s0) et la machine perd le reseau ; les chemins de disques bougent ; l ordre d enumeration suit. C EST AUSSI POURQUOI SET-OPS N UTILISE PAS L IMAGE CLOUD OFFICIELLE DE DEBIAN : `genericcloud` est livree configuree pour `i440fx`. Une machine nait `q35`, ou elle ne le sera jamais proprement — et c est ce que l installation depuis l ISO garantit. Ces deux lignes de la procedure n etaient qu une ligne de tableau. Elles portent maintenant leur pourquoi, et le SITE les declare comme DONNEES (cle `gabarit`), plus seulement comme prose. `make gabarit-etat` compare le gabarit reel a ce que le site declare de lui. ON VERIFIE LA SOURCE, PAS CHAQUE COPIE. Ma premiere version gardait le CLONAGE : la propriete s herite, donc verifier chaque clone coute a chaque creation sans rien dire de plus que verifier le gabarit une fois. Retiree. TROIS FOIS J AI DEVINE LA FORME DE LA REPONSE AU LIEU DE LA REGARDER — regex_search a groupe qui rend None, proxmox_vm_info sans `config: current` qui ne rend que l etat. La garde a declare « ? » sur une VM parfaitement conforme : une garde qui crie toujours est pire qu aucune, on apprend a l ignorer. A la demande et non dans `make prouver` : ce controle exige le cluster, que le harnais ne suppose pas joignable. Meme nature que genome-etat et underlay-plan. Controle negatif verifie : declarer i440fx fait echouer, rc=1. make verifier : vert. make prouver : CONFORME, 56 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-01 14:42:58 -04:00
for k in ("vmid", "nom", "noeud", "machine", "bios"):
gabarit : refabrique, minimal, et puise aux ressources du SITE REFABRIQUE (VMID 9006, modeleSetOPS-minimal). Quatre roles au lieu de dix-sept : qemu_guest_agent, cloud_init, sudo_ansible, ssh_baseline — des conditions d existence, pas des choix d efficacite. Tout le reste vient du socle, et P56 refuse qu un role retire ne soit repris par personne. FABRIQUE CHEZ LE SITE, ET C EST LA DECISION QUI COMPTE. « Les ressources du SITE font autorite pour tous ses artefacts ; elles servent les tenants jusqu a ce qu ils s emancipent. » Il se fabriquait DEHORS : `-i "<ip>,"` ne porte aucun group_vars, donc ni mandataire ni resolveur. L ancien gabarit allait chercher ses paquets chez Debian et resolvait chez l ancien LAN — 192.168.10.10, lu sur la VM 99998. Le site avait son cache et son resolveur, et son propre artefact les ignorait. Desormais la fabrication DERIVE ses ressources du plan du site (underlay --adresses) et tourne sur le reseau du genome. PROUVE : dix requetes de 10.0.33.31 servies par site-cache-01, resolveur pose a 10.0.34.11. L IDENTITE DU GABARIT VIENT DU SITE, PLUS DU TENANT. `proxmox_clone_vmid_modele` vivait dans les group_vars de l ecosysteme : deux tenants pouvaient cloner deux gabarits differents sans que rien ne le dise, et un tenant decidait d un objet dont descend chaque VM de chaque ecosysteme. Meme mouvement que l INDEX (2026-08-25) : le site ALLOUE, le tenant RECOIT. Cle `gabarit` du plan du site, lue par underlay --gabarit. PIEGE FERME EN CHEMIN : creer-vm passait VMID_MODELE="$SETOPS_VMID_MODELE" a cloner-vm, or inventory_host.py n emet PAS cette variable. Elle valait donc le VIDE, et ce vide ECRASAIT la valeur derivee — le gabarit du tenant reprenait la main sans bruit. ET UN PIEGE DEJA DOCUMENTE, PAYE UNE TROISIEME FOIS : le chemin du controleur contient une espace, et `lookup('pipe', ...)` le decoupait. Guillemets. EPROUVE DE BOUT EN BOUT : VM clonee du gabarit minimal — nom, adresse, machine-id neuf, cle d hote regeneree, agent invite actif ; puis socle applique dessus, changed=5, 0 echec, auditd compris. VM d essai retiree. make verifier : vert. make prouver : CONFORME, 56 OK, 0 echec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-01 13:46:31 -04:00
if g.get(k) is not None:
print(f"{k}={g[k]}")
return
if args.adresses:
for a in adresses_site_portant(args.adresses):
print(a)
return
if args.verifier:
sys.exit(verifier())
afficher()
if __name__ == "__main__":
main()