Set-OPS-Public/scripts/instances.py
Daniel Allaire 5419ee2d71 un site prend son propre index, et la garde les compte enfin
Decision de l exploitant : sites et locataires se partagent la classe A,
chacun avec son propre index. L exception du guide — un site derive du meme
index que son tenant — disparait.

Ce qu elle cachait : le plan d administration du site vit dans 10.17.0.0/24,
a l interieur du supernet du locataire OPS-Chezlepro. Pas dangereux, mais
10.17.0.0/16 designait deux choses. Et les zones du site ne derivaient de
rien — le site etait la seule partie du systeme sans seed.

La seconde liste nait avec cette decision : un site et un locataire peuvent
desormais reclamer le meme nombre, et make instances ne voyait que les depots
OPS-*. La decouverte lit maintenant SITE-*/underlay.yml et son champ index.
Un site sans index declare reste hors du compte.

SITE-Technolibre : squelette du deuxieme site pour la visite du 14. Un seul
noeud Proxmox en version 9, meme forme que Chezlepro, adresse depuis l index
31. COLLECTE.md liste les 35 valeurs a relever.

Reste a l exploitant : OPS-Chezlepro passe a 37, ce qui libere 17 pour le
site qui l utilise deja.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 14:31:51 -04:00

190 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Vue d'ensemble des instances (ecosystemes) de la federation Set-OPS.
Decouvre les depots freres `../*/plan/nomenclature.yml` et montre, pour chacun :
- nom du depot,
- index (le seed dont derive tout l'adressage),
- ACTIVE (le symlink `instance` pointe-t-il ici ?),
- federe (participe au reseau converge, cf. devis-reseau) — sinon bac a sable local,
- production (setops_production) — pour la prudence au deploiement,
- plage VLAN derivee (1000 + index x 10 + zone).
Et surtout : SIGNALE toute COLLISION d'index entre instances FEDEREES — deux
instances de meme index ont les memes VLAN et VMID, ce qui casse le reseau converge.
C'est le garde-fou du modele multi-instances.
Usage :
python3 scripts/instances.py # tableau
python3 scripts/instances.py --verifier # rc=2 si collision d'index entre federees
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
FRERES = RACINE.parent
LIEN_INSTANCE = RACINE / "instance"
sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import INDEX_MIN, INDEX_MAX # noqa: E402
def _actif() -> Path | None:
try:
return LIEN_INSTANCE.resolve()
except OSError:
return None
def _production(depot: Path) -> bool | None:
"""Lit setops_production dans les group_vars/all de l'instance (repere de prudence)."""
for inv in sorted((depot / "inventories").glob("*")) if (depot / "inventories").is_dir() else []:
base = inv / "group_vars" / "all"
fichiers = list(base.glob("*.yml")) if base.is_dir() else []
plat = inv / "group_vars" / "all.yml"
if plat.exists():
fichiers.append(plat)
for f in fichiers:
try:
d = yaml.safe_load(f.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
continue
if isinstance(d, dict) and "setops_production" in d:
return bool(d["setops_production"])
return None
def _sites() -> list[dict]:
"""Les depots de SITE et l'index qu'ils declarent dans leur underlay.
POURQUOI ILS ENTRENT DANS LE COMPTE (2026-09-12). Tant que les sites n'avaient pas
d'index, ils ne pouvaient heurter personne : leur adressage etait tape a la main,
hors de la derivation. L'exploitant a decide que SITES et OPS partagent la classe A,
chacun avec son propre index — un site et un locataire peuvent donc desormais
reclamer le meme nombre, et rien ne l'attrapait.
C'est une seconde liste qui suit une premiere. Sa garde s'ecrit le jour meme.
L'index d'un site vit dans son `underlay.yml`, pas dans une `nomenclature.yml` :
c'est la carte de son MATERIEL, et un site n'a pas de categories de zones a la
maniere d'un locataire. Un site sans `index:` declare reste invisible ici — il
n'entre dans le compte que le jour ou il en prend un.
"""
trouves = []
for chemin in sorted(FRERES.glob("SITE-*/underlay.yml")):
try:
d = (yaml.safe_load(chemin.read_text(encoding="utf-8")) or {}).get("underlay") or {}
except (OSError, yaml.YAMLError):
continue
if d.get("index") is None:
continue
trouves.append({"depot": chemin.parent, "index": d["index"]})
return trouves
def decouvrir() -> list[dict]:
actif = _actif()
instances = []
for site in _sites():
instances.append({
"nom": site["depot"].name,
"index": site["index"],
"actif": False,
"federe": True, # un site partage la fabric qu'il fournit
"production": None,
"zones": [],
"vlans": None,
"site": True,
})
for chemin in sorted(FRERES.glob("*/plan/nomenclature.yml")):
try:
n = yaml.safe_load(chemin.read_text(encoding="utf-8")) or {}
except (OSError, yaml.YAMLError):
continue
if n.get("index") is None:
continue
depot = chemin.parent.parent
zones = sorted(int(z) for z in (n.get("categories") or {}))
idx = n["index"]
instances.append({
"nom": depot.name,
"index": idx,
"actif": actif is not None and depot.resolve() == actif,
"federe": bool(n.get("federe", True)),
"production": _production(depot),
"vlans": (1000 + idx * 10 + zones[0], 1000 + idx * 10 + zones[-1]) if zones else None,
})
instances.sort(key=lambda i: (i["index"], i["nom"]))
return instances
def collisions(instances: list[dict]) -> dict[int, list[str]]:
"""Index partages par >1 instance FEDEREE -> {index: [noms]}."""
par_index: dict[int, list[str]] = {}
for i in instances:
if i["federe"]:
par_index.setdefault(i["index"], []).append(i["nom"])
return {idx: noms for idx, noms in par_index.items() if len(noms) > 1}
def main() -> int:
p = argparse.ArgumentParser(description="Vue d'ensemble des instances Set-OPS.")
p.add_argument("--verifier", action="store_true",
help="Ne rien afficher sauf en cas de collision d'index (rc=2).")
args = p.parse_args()
instances = decouvrir()
coll = collisions(instances)
# Un index hors bornes ne produit pas une adresse mais une chaine qui y ressemble.
# Le lever ICI, avec le nom du depot fautif, plutot que de laisser `ip_network()`
# echouer plus tard sur une valeur dont on ne saura plus d'ou elle vient.
hors_bornes = [(i["nom"], i["index"]) for i in instances
if not (INDEX_MIN <= int(i["index"]) <= INDEX_MAX)]
if args.verifier:
if hors_bornes:
print(f"erreur: index hors bornes [{INDEX_MIN}-{INDEX_MAX}] :", file=sys.stderr)
for nom, idx in sorted(hors_bornes):
print(f" - {nom} : index {idx} — le 2e octet d'une IPv4 ne peut pas le porter",
file=sys.stderr)
return 2
if coll:
print("erreur: collision d'index entre instances FEDEREES "
"(memes VLAN/VMID) :", file=sys.stderr)
for idx, noms in sorted(coll.items()):
print(f" - index {idx} partage par : {', '.join(noms)}", file=sys.stderr)
return 2
print(f"Federation coherente : {sum(1 for i in instances if i['federe'])} "
f"instance(s) federee(s), aucun index en collision.")
return 0
if not instances:
print("Aucune instance decouverte (depots freres avec plan/nomenclature.yml).")
return 0
print(f"{'':1} {'INSTANCE':<22} {'INDEX':>5} {'VLAN':<11} {'FEDERE':<7} {'PROD':<5}")
for i in instances:
marque = "*" if i["actif"] else " "
vlan = f"{i['vlans'][0]}-{i['vlans'][1]}" if i["vlans"] else "—"
fed = "oui" if i["federe"] else "LOCAL"
prod = "oui" if i["production"] else ("non" if i["production"] is False else "?")
print(f"{marque} {i['nom']:<22} {i['index']:>5} {vlan:<11} {fed:<7} {prod:<5}")
print("\n* = instance active (symlink 'instance'). "
"Basculer : make instance-utiliser NOM=<depot>")
if coll:
print("\n⚠ COLLISION d'index entre instances federees :", file=sys.stderr)
for idx, noms in sorted(coll.items()):
print(f" index {idx} : {', '.join(noms)} — memes VLAN/VMID sur le trunk !",
file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())