sdn : un applicateur qui cree, met a jour et RETIRE
Deuxieme applicateur du depot, meme contrat que celui de la frontiere. Deux cibles : les objets de cluster par l'API Proxmox, la sortie du VRF par SSH — `/etc/frr/frr.conf.local` n'est expose par aucune API. Perimetre strict : seules les zones du devis et les anciens nommages listes sont touches ; une zone inconnue est signalee et laissee intacte. Un VNet encore branche a une VM est refuse, avec le nom des machines. L'ordre suit les dependances : sous-reseaux, VNets, zones. `devis_sdn.strophe_frr()` est la source unique : le devis l'affiche, l'applicateur la compare au fichier distant. Defaut trouve a la premiere execution : la lecture sans `sudo` echouait, un `|| true` masquait l'echec, et un fichier present etait declare absent puis reecrit. Trois etats distingues desormais : absent, illisible, different. Rejeu a vide, six VRF avec leur defaut, sortie tenant fonctionnelle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
917b22ccc8
commit
3ed25ff703
4 changed files with 405 additions and 5 deletions
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -2,6 +2,38 @@
|
||||||
|
|
||||||
## 2026-08-06 — le chemin nord-sud devient dérivable
|
## 2026-08-06 — le chemin nord-sud devient dérivable
|
||||||
|
|
||||||
|
### Un applicateur pour le SDN, et pour la sortie des VRF
|
||||||
|
|
||||||
|
`scripts/appliquer_sdn.py` — deuxième applicateur du dépôt, même contrat que celui de la
|
||||||
|
frontière : ce qui manque est créé, **ce que le devis ne demande plus est retiré**.
|
||||||
|
|
||||||
|
**Deux cibles, parce qu'elles n'ont pas la même prise.** Les objets de cluster (zone, VNets,
|
||||||
|
sous-réseaux) passent par l'API Proxmox ; la sortie du VRF est un *fichier* sur chaque nœud de
|
||||||
|
sortie, qu'aucune API n'expose — donc SSH. `make sdn-plan` lit, `make sdn-appliquer
|
||||||
|
CONFIRMER=true` agit.
|
||||||
|
|
||||||
|
**Périmètre strict.** Seules les zones nommées par le devis et celles de la liste explicite des
|
||||||
|
anciens nommages sont touchées. Une zone inconnue est signalée et **laissée intacte** : ce
|
||||||
|
dépôt n'est pas seul au monde sur ce cluster.
|
||||||
|
|
||||||
|
**Deux garde-fous sur le retrait.** Un VNet encore branché à une VM est refusé, avec le nom des
|
||||||
|
machines — on ne débranche personne par inadvertance. Et l'ordre suit les dépendances :
|
||||||
|
sous-réseaux, puis VNets, puis zones à la suppression ; l'inverse à la création. Proxmox refuse
|
||||||
|
tout autre ordre, et l'avait déjà appris à ses dépens.
|
||||||
|
|
||||||
|
**Une source unique pour la strophe.** `devis_sdn.strophe_frr()` sert à la fois au devis qui
|
||||||
|
l'affiche et à l'applicateur qui la compare au fichier distant. Deux rendus séparés auraient
|
||||||
|
fini par diverger — c'est le mode de panne que ce dépôt passe ses journées à fermer.
|
||||||
|
|
||||||
|
**Un défaut trouvé par la première exécution.** L'applicateur lisait `frr.conf.local` sans
|
||||||
|
`sudo` ; la lecture échouait, un `|| true` masquait l'échec, et un fichier **présent** était
|
||||||
|
déclaré absent — donc réécrit sans raison. Il distingue désormais « absent », « illisible » et
|
||||||
|
« différent », trois états qui appellent trois réponses.
|
||||||
|
|
||||||
|
Le rejeu ne trouve plus rien à faire, les six VRF portent leur défaut, et la sortie tenant
|
||||||
|
répond toujours.
|
||||||
|
|
||||||
|
|
||||||
### Trois nœuds de sortie, et le primaire enfin émis
|
### Trois nœuds de sortie, et le primaire enfin émis
|
||||||
|
|
||||||
`t17` passe à `asgard,gandalf,vishnu`, primaire `asgard` — `t11` l'était déjà. La strophe FRR
|
`t17` passe à `asgard,gandalf,vishnu`, primaire `asgard` — `t11` l'était déjà. La strophe FRR
|
||||||
|
|
|
||||||
12
Makefile
12
Makefile
|
|
@ -348,6 +348,18 @@ devis-reseau: ansible-runtime ## Devis switch (VLANs/SVIs/ACLs) du reseau conver
|
||||||
devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe)
|
devis-opnsense: ansible-runtime ## Devis OPNsense (frontiere nord/sud), derive du registre des flux (pair: externe)
|
||||||
python3 scripts/devis_opnsense.py $(if $(JSON),--json,)
|
python3 scripts/devis_opnsense.py $(if $(JSON),--json,)
|
||||||
|
|
||||||
|
.PHONY: sdn-plan sdn-appliquer
|
||||||
|
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
|
||||||
|
python3 scripts/appliquer_sdn.py
|
||||||
|
|
||||||
|
sdn-appliquer: ansible-runtime ## Reconcilie le SDN : cree ce qui manque, RETIRE ce qui est perime. CONFIRMER=true
|
||||||
|
@if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||||
|
printf '%s\n' "Refus: cette cible ECRIT sur le cluster (zones, VNets, sous-reseaux) et sur" \
|
||||||
|
"les noeuds de sortie (/etc/frr/frr.conf.local), et RETIRE ce qui est perime." \
|
||||||
|
"Relire d'abord 'make sdn-plan', puis: make sdn-appliquer CONFIRMER=true"; \
|
||||||
|
exit 2; fi
|
||||||
|
CONFIRMER=true python3 scripts/appliquer_sdn.py
|
||||||
|
|
||||||
.PHONY: frontiere-plan frontiere-appliquer
|
.PHONY: frontiere-plan frontiere-appliquer
|
||||||
frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture)
|
frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture)
|
||||||
python3 scripts/appliquer_opnsense.py
|
python3 scripts/appliquer_opnsense.py
|
||||||
|
|
|
||||||
329
scripts/appliquer_sdn.py
Normal file
329
scripts/appliquer_sdn.py
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Reconcilie le SDN EVPN et la sortie des VRF avec le devis (`devis_sdn.py`).
|
||||||
|
|
||||||
|
DEUX SENS, comme l'applicateur de la frontiere : ce que le devis demande et qui
|
||||||
|
manque est cree ; ce qu'il ne demande plus est RETIRE. Un devis qui change sans
|
||||||
|
retrait laisse des objets orphelins, et la lecture du cluster cesse de dire la
|
||||||
|
verite.
|
||||||
|
|
||||||
|
DEUX CIBLES, parce qu'elles n'ont pas la meme prise :
|
||||||
|
- les objets de cluster (zone, VNets, sous-reseaux) : API Proxmox ;
|
||||||
|
- la sortie du VRF (`/etc/frr/frr.conf.local`) : un FICHIER sur chaque noeud de
|
||||||
|
sortie, qu'aucune API n'expose. SSH, donc.
|
||||||
|
|
||||||
|
PERIMETRE STRICT : seules les zones nommees par le devis, et celles de la liste
|
||||||
|
explicite des anciens nommages, sont touchees. Une zone inconnue est signalee et
|
||||||
|
LAISSEE INTACTE — ce depot n'est pas seul au monde sur ce cluster.
|
||||||
|
|
||||||
|
NON DESTRUCTIF PAR DEFAUT : sans `CONFIRMER=true`, aucune ecriture (regle 4).
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python3 scripts/appliquer_sdn.py # plan seul
|
||||||
|
CONFIRMER=true python3 scripts/appliquer_sdn.py # applique
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
RACINE = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(RACINE / "scripts"))
|
||||||
|
|
||||||
|
import devis_sdn as devis_mod # noqa: E402
|
||||||
|
|
||||||
|
CHEMIN_FRR = "/etc/frr/frr.conf.local"
|
||||||
|
|
||||||
|
|
||||||
|
def _hebergeur() -> Path:
|
||||||
|
lien = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml"))
|
||||||
|
if not lien.exists():
|
||||||
|
raise SystemExit("Aucun underlay ne designe d'hebergeur : pas de cluster a piloter.")
|
||||||
|
return lien.resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _voute(base: Path) -> dict:
|
||||||
|
for nom in ("principal", "production", "lab"):
|
||||||
|
p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml"
|
||||||
|
if p.is_file():
|
||||||
|
r = subprocess.run(["ansible-vault", "view", str(p)], capture_output=True, text=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit("Voute illisible : renseigner ANSIBLE_VAULT_PASSWORD_FILE.\n"
|
||||||
|
+ r.stderr.strip()[:300])
|
||||||
|
return yaml.safe_load(r.stdout) or {}
|
||||||
|
raise SystemExit(f"Aucune voute sous {base}/inventories/*/group_vars/all/.")
|
||||||
|
|
||||||
|
|
||||||
|
class Cluster:
|
||||||
|
"""Le minimum d'API Proxmox pour reconcilier le SDN."""
|
||||||
|
|
||||||
|
def __init__(self, hote: str, port, user: str, tid: str, secret: str):
|
||||||
|
self.base = f"https://{hote}:{port}/api2/json"
|
||||||
|
# Le jeton complet est `utilisateur@royaume!nom` : la voute ne porte que le NOM,
|
||||||
|
# l'utilisateur vit dans proxmox-hebergeur.yml. Les recomposer ici evite de
|
||||||
|
# stocker deux fois la meme identite — et le 401 muet quand elles divergent.
|
||||||
|
self.tok = f"PVEAPIToken={user}!{str(tid).split('!')[-1]}={secret}"
|
||||||
|
self.ctx = ssl._create_unverified_context()
|
||||||
|
|
||||||
|
def __call__(self, chemin: str, methode: str = "GET", corps: dict | None = None):
|
||||||
|
data = urllib.parse.urlencode(corps).encode() if corps else None
|
||||||
|
req = urllib.request.Request(self.base + chemin, data=data, method=methode,
|
||||||
|
headers={"Authorization": self.tok})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, context=self.ctx, timeout=45) as rep:
|
||||||
|
return json.loads(rep.read()).get("data")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"_erreur": f"{e.code} {e.read().decode()[:200]}"}
|
||||||
|
except OSError as e:
|
||||||
|
return {"_erreur": str(e)[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh(hote: str, commande: str, entree: str | None = None) -> tuple[int, str]:
|
||||||
|
"""SSH vers un noeud de sortie. La cle vient de l'agent, ou de SETOPS_SSH_KEY."""
|
||||||
|
cmd = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no",
|
||||||
|
"-o", "ConnectTimeout=10"]
|
||||||
|
cle = os.environ.get("SETOPS_SSH_KEY")
|
||||||
|
if cle:
|
||||||
|
cmd += ["-o", "IdentitiesOnly=yes", "-i", os.path.expanduser(cle)]
|
||||||
|
cmd += [f"{os.environ.get('SETOPS_SSH_USER', 'ansible')}@{hote}", commande]
|
||||||
|
r = subprocess.run(cmd, input=entree, capture_output=True, text=True, timeout=90)
|
||||||
|
return r.returncode, (r.stdout or "") + (r.stderr or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _vm_par_vnet(api: Cluster) -> dict[str, list[str]]:
|
||||||
|
"""Quelles VM sont branchees sur quel VNet — pour ne jamais debrancher personne."""
|
||||||
|
dedans: dict[str, list[str]] = {}
|
||||||
|
for r in (api("/cluster/resources?type=vm") or []):
|
||||||
|
if not isinstance(r, dict) or not r.get("vmid"):
|
||||||
|
continue
|
||||||
|
cfg = api(f"/nodes/{r['node']}/qemu/{r['vmid']}/config")
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
for k, v in cfg.items():
|
||||||
|
if k.startswith("net") and "bridge=" in str(v):
|
||||||
|
pont = str(v).split("bridge=")[1].split(",")[0]
|
||||||
|
dedans.setdefault(pont, []).append(f"{r['vmid']} ({r.get('name', '?')})")
|
||||||
|
return dedans
|
||||||
|
|
||||||
|
|
||||||
|
def plan(api: Cluster, devis: dict) -> dict:
|
||||||
|
zones_voulues = {b["zone"]: b for b in devis["zones"]}
|
||||||
|
connues = set(zones_voulues) | set(devis.get("anciennes") or [])
|
||||||
|
|
||||||
|
zones_posees = {z["zone"]: z for z in (api("/cluster/sdn/zones") or [])
|
||||||
|
if isinstance(z, dict)}
|
||||||
|
vnets_poses = {v["vnet"]: v for v in (api("/cluster/sdn/vnets") or [])
|
||||||
|
if isinstance(v, dict)}
|
||||||
|
|
||||||
|
vnets_voulus = {v["vnet"]: (b["zone"], v)
|
||||||
|
for b in devis["zones"] for v in b["vnets"]}
|
||||||
|
|
||||||
|
# Sous-reseaux : l'identifiant Proxmox est `<zone>-<reseau>-<masque>`.
|
||||||
|
sr_poses = {}
|
||||||
|
for nom, v in vnets_poses.items():
|
||||||
|
if v.get("zone") not in connues:
|
||||||
|
continue
|
||||||
|
for s in (api(f"/cluster/sdn/vnets/{nom}/subnets") or []):
|
||||||
|
if isinstance(s, dict):
|
||||||
|
sr_poses[(nom, s.get("cidr"))] = s
|
||||||
|
sr_voulus = {(v["vnet"], v["sous_reseau"]): (b["zone"], v)
|
||||||
|
for b in devis["zones"] for v in b["vnets"]}
|
||||||
|
|
||||||
|
def zone_differe(nom, b):
|
||||||
|
z = zones_posees[nom]
|
||||||
|
attendu = {
|
||||||
|
"controller": devis["controleur"],
|
||||||
|
"exitnodes": ",".join(sorted(devis["noeuds_de_sortie"])),
|
||||||
|
"exitnodes-primary": devis.get("sortie_primaire") or "",
|
||||||
|
"mtu": str(b["mtu"]),
|
||||||
|
"vrf-vxlan": str(b["vrf_vxlan"]),
|
||||||
|
}
|
||||||
|
reel = {
|
||||||
|
"controller": str(z.get("controller") or ""),
|
||||||
|
"exitnodes": ",".join(sorted(str(z.get("exitnodes") or "").split(","))).strip(","),
|
||||||
|
"exitnodes-primary": str(z.get("exitnodes-primary") or ""),
|
||||||
|
"mtu": str(z.get("mtu") or ""),
|
||||||
|
"vrf-vxlan": str(z.get("vrf-vxlan") or ""),
|
||||||
|
}
|
||||||
|
return {k: (reel[k], attendu[k]) for k in attendu if reel[k] != attendu[k]}
|
||||||
|
|
||||||
|
# La strophe FRR, comparee sur chaque noeud de sortie.
|
||||||
|
voulue = devis_mod.strophe_frr(devis)
|
||||||
|
frr = {}
|
||||||
|
for n in devis.get("noeuds_de_sortie") or []:
|
||||||
|
# `sudo` : le fichier appartient a root. Sans lui, la lecture echoue et un
|
||||||
|
# fichier PRESENT serait declare absent — puis reecrit sans raison.
|
||||||
|
# `test -e` d'abord, pour distinguer « absent » de « illisible ».
|
||||||
|
rc, _ = _ssh(n, "true")
|
||||||
|
if rc != 0:
|
||||||
|
frr[n] = {"present": None, "joignable": False, "conforme": False}
|
||||||
|
continue
|
||||||
|
rc_e, _ = _ssh(n, f"sudo test -e {CHEMIN_FRR}")
|
||||||
|
contenu = ""
|
||||||
|
if rc_e == 0:
|
||||||
|
rc_c, contenu = _ssh(n, f"sudo cat {CHEMIN_FRR}")
|
||||||
|
if rc_c != 0:
|
||||||
|
frr[n] = {"present": None, "joignable": False, "conforme": False,
|
||||||
|
"illisible": True}
|
||||||
|
continue
|
||||||
|
frr[n] = {"present": contenu, "joignable": True, "conforme": contenu == voulue}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"zones_creer": {n: b for n, b in zones_voulues.items() if n not in zones_posees},
|
||||||
|
"zones_majer": {n: zone_differe(n, b) for n, b in zones_voulues.items()
|
||||||
|
if n in zones_posees and zone_differe(n, b)},
|
||||||
|
"zones_retirer": {n: z for n, z in zones_posees.items()
|
||||||
|
if n in (devis.get("anciennes") or []) and n not in zones_voulues},
|
||||||
|
"zones_etrangeres": sorted(n for n in zones_posees if n not in connues),
|
||||||
|
"vnets_creer": {n: v for n, v in vnets_voulus.items() if n not in vnets_poses},
|
||||||
|
"vnets_retirer": {n: v for n, v in vnets_poses.items()
|
||||||
|
if v.get("zone") in connues and n not in vnets_voulus},
|
||||||
|
"sr_creer": {k: v for k, v in sr_voulus.items() if k not in sr_poses},
|
||||||
|
"sr_retirer": {k: v for k, v in sr_poses.items() if k not in sr_voulus},
|
||||||
|
"frr": frr,
|
||||||
|
"frr_voulue": voulue,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def afficher(p: dict) -> bool:
|
||||||
|
for n in sorted(p["zones_creer"]):
|
||||||
|
print(f" + zone {n}")
|
||||||
|
for n, d in sorted(p["zones_majer"].items()):
|
||||||
|
for k, (avant, apres) in sorted(d.items()):
|
||||||
|
print(f" ~ zone {n:<8} {k:<18} {avant or '(vide)'} -> {apres}")
|
||||||
|
for n, (z, v) in sorted(p["vnets_creer"].items()):
|
||||||
|
print(f" + VNet {n:<10} zone={z} tag={v['tag']}")
|
||||||
|
for (vn, cidr), (z, v) in sorted(p["sr_creer"].items()):
|
||||||
|
print(f" + sous-reseau {cidr:<18} vnet={vn} passerelle={v['passerelle']}")
|
||||||
|
for k in sorted(p["sr_retirer"]):
|
||||||
|
print(f" - sous-reseau PERIME {k[1]} (vnet {k[0]})")
|
||||||
|
for n in sorted(p["vnets_retirer"]):
|
||||||
|
print(f" - VNet PERIME {n}")
|
||||||
|
for n in sorted(p["zones_retirer"]):
|
||||||
|
print(f" - zone PERIMEE {n}")
|
||||||
|
for n, e in sorted(p["frr"].items()):
|
||||||
|
if e.get("illisible"):
|
||||||
|
print(f" ! sortie du VRF {n} : {CHEMIN_FRR} ILLISIBLE (sudo ?)")
|
||||||
|
elif not e["joignable"]:
|
||||||
|
print(f" ! sortie du VRF {n} : INJOIGNABLE en SSH")
|
||||||
|
elif not e["conforme"]:
|
||||||
|
quoi = "absente" if not e["present"] else "differente"
|
||||||
|
print(f" ~ sortie du VRF {n} : strophe {quoi}")
|
||||||
|
if p["zones_etrangeres"]:
|
||||||
|
print(f"\n zone(s) hors devis, LAISSEE(S) INTACTE(S) : {', '.join(p['zones_etrangeres'])}")
|
||||||
|
creer = len(p["zones_creer"]) + len(p["vnets_creer"]) + len(p["sr_creer"])
|
||||||
|
retirer = len(p["zones_retirer"]) + len(p["vnets_retirer"]) + len(p["sr_retirer"])
|
||||||
|
frr_a_faire = sum(1 for e in p["frr"].values() if e["joignable"] and not e["conforme"])
|
||||||
|
print(f"\n a creer : {creer} | a retirer : {retirer} | a mettre a jour : "
|
||||||
|
f"{len(p['zones_majer'])} | noeuds a corriger : {frr_a_faire}")
|
||||||
|
return bool(creer or retirer or p["zones_majer"] or frr_a_faire)
|
||||||
|
|
||||||
|
|
||||||
|
def appliquer(api: Cluster, p: dict, devis: dict) -> int:
|
||||||
|
echecs = 0
|
||||||
|
|
||||||
|
def _fait(rep, quoi):
|
||||||
|
nonlocal echecs
|
||||||
|
if isinstance(rep, dict) and rep.get("_erreur"):
|
||||||
|
echecs += 1
|
||||||
|
print(f" ! ECHEC {quoi} : {rep['_erreur'][:150]}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Un VNet encore branche a une VM ne se retire pas : on debrancherait la machine.
|
||||||
|
if p["vnets_retirer"]:
|
||||||
|
occupe = _vm_par_vnet(api)
|
||||||
|
for n in list(p["vnets_retirer"]):
|
||||||
|
if occupe.get(n):
|
||||||
|
print(f" ! REFUS de retirer le VNet {n} : VM branchee(s) — "
|
||||||
|
f"{', '.join(occupe[n])}")
|
||||||
|
del p["vnets_retirer"][n]
|
||||||
|
echecs += 1
|
||||||
|
|
||||||
|
# 1. Creations, du contenant au contenu.
|
||||||
|
for n, b in sorted(p["zones_creer"].items()):
|
||||||
|
corps = {"zone": n, "type": "evpn", "controller": devis["controleur"],
|
||||||
|
"vrf-vxlan": b["vrf_vxlan"], "mtu": b["mtu"], "ipam": "pve",
|
||||||
|
"exitnodes": ",".join(devis["noeuds_de_sortie"])}
|
||||||
|
if devis.get("sortie_primaire"):
|
||||||
|
corps["exitnodes-primary"] = devis["sortie_primaire"]
|
||||||
|
_fait(api("/cluster/sdn/zones", "POST", corps), f"zone {n}")
|
||||||
|
for n, d in sorted(p["zones_majer"].items()):
|
||||||
|
_fait(api(f"/cluster/sdn/zones/{n}", "PUT",
|
||||||
|
{k: apres for k, (_, apres) in d.items() if apres}), f"maj zone {n}")
|
||||||
|
for n, (z, v) in sorted(p["vnets_creer"].items()):
|
||||||
|
_fait(api("/cluster/sdn/vnets", "POST", {"vnet": n, "zone": z, "tag": v["tag"]}),
|
||||||
|
f"vnet {n}")
|
||||||
|
for (vn, cidr), (z, v) in sorted(p["sr_creer"].items()):
|
||||||
|
_fait(api(f"/cluster/sdn/vnets/{vn}/subnets", "POST",
|
||||||
|
{"subnet": cidr, "type": "subnet", "gateway": v["passerelle"], "snat": 0}),
|
||||||
|
f"sous-reseau {cidr}")
|
||||||
|
|
||||||
|
# 2. Retraits, du contenu au contenant : Proxmox refuse l'inverse.
|
||||||
|
for (vn, cidr), s in sorted(p["sr_retirer"].items()):
|
||||||
|
_fait(api(f"/cluster/sdn/vnets/{vn}/subnets/{s['id']}", "DELETE"),
|
||||||
|
f"retrait sous-reseau {cidr}")
|
||||||
|
for n in sorted(p["vnets_retirer"]):
|
||||||
|
_fait(api(f"/cluster/sdn/vnets/{n}", "DELETE"), f"retrait vnet {n}")
|
||||||
|
for n in sorted(p["zones_retirer"]):
|
||||||
|
_fait(api(f"/cluster/sdn/zones/{n}", "DELETE"), f"retrait zone {n}")
|
||||||
|
|
||||||
|
# 3. La sortie du VRF, fichier par noeud. `cat >` et non `>>` : le fichier est
|
||||||
|
# GENERE, donc remplace — un ajout repete l'empilerait a chaque passage.
|
||||||
|
for n, e in sorted(p["frr"].items()):
|
||||||
|
if not e["joignable"] or e["conforme"]:
|
||||||
|
continue
|
||||||
|
rc, out = _ssh(n, f"sudo tee {CHEMIN_FRR} >/dev/null && sudo systemctl reload frr",
|
||||||
|
entree=p["frr_voulue"])
|
||||||
|
if rc != 0:
|
||||||
|
echecs += 1
|
||||||
|
print(f" ! ECHEC strophe sur {n} : {out.strip()[:150]}")
|
||||||
|
else:
|
||||||
|
print(f" ~ strophe posee sur {n}, FRR recharge")
|
||||||
|
|
||||||
|
if echecs:
|
||||||
|
print(f"\n {echecs} echec(s) — le SDN n'est PAS applique, la config reste en attente.")
|
||||||
|
return 1
|
||||||
|
rep = api("/cluster/sdn", "PUT")
|
||||||
|
print(" application du SDN :", "ok" if not (isinstance(rep, dict) and rep.get("_erreur"))
|
||||||
|
else rep["_erreur"][:120])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
base = _hebergeur()
|
||||||
|
heb = yaml.safe_load((base / "proxmox-hebergeur.yml").read_text(encoding="utf-8")) or {}
|
||||||
|
v = _voute(base)
|
||||||
|
api = Cluster(heb.get("proxmox_api_host"), heb.get("proxmox_api_port", 8006),
|
||||||
|
heb.get("proxmox_api_user"), v["proxmox_api_token_id"],
|
||||||
|
v["proxmox_api_token_secret"])
|
||||||
|
|
||||||
|
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_sdn.py"), "--json"],
|
||||||
|
cwd=RACINE, capture_output=True, text=True,
|
||||||
|
env={**os.environ, "SETOPS_INSTANCE": str(base)})
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit("Le devis ne se genere pas :\n" + r.stderr.strip()[:400])
|
||||||
|
devis = json.loads(r.stdout)
|
||||||
|
|
||||||
|
print(f"Cluster {heb.get('proxmox_api_host')} — {len(devis['zones'])} zone(s) au devis\n")
|
||||||
|
p = plan(api, devis)
|
||||||
|
if not afficher(p):
|
||||||
|
print("\n Le cluster dit deja ce que le devis dit. Rien a faire.")
|
||||||
|
return 0
|
||||||
|
if os.environ.get("CONFIRMER") != "true":
|
||||||
|
print("\n PLAN SEUL — aucune ecriture. Rejouer avec CONFIRMER=true pour appliquer.")
|
||||||
|
return 0
|
||||||
|
print()
|
||||||
|
return appliquer(api, p, devis)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -161,6 +161,34 @@ def construire(tenants: list[tuple[str, str, dict]]) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
EN_TETE_FRR = (
|
||||||
|
"! Set-OPS — sortie des tenants par la frontiere. GENERE : `make devis-sdn`.\n"
|
||||||
|
"! NE PAS EDITER A LA MAIN — `make sdn-appliquer` reecrit ce fichier.\n"
|
||||||
|
"!\n"
|
||||||
|
"! `nexthop-vrf default` emprunte UNE adresse a la table principale au lieu de\n"
|
||||||
|
"! l'importer : la route par defaut des hyperviseurs ne gouverne pas la sortie\n"
|
||||||
|
"! des tenants. `import vrf default` ferait contourner la frontiere et fuiterait\n"
|
||||||
|
"! le transport VXLAN, la gestion et les VLAN herites dans le VRF.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def strophe_frr(devis: dict) -> str:
|
||||||
|
"""Contenu attendu de /etc/frr/frr.conf.local, ou '' s'il n'y a rien a poser.
|
||||||
|
|
||||||
|
SOURCE UNIQUE : le devis l'affiche, l'applicateur la compare au fichier distant.
|
||||||
|
Deux rendus separes finiraient par diverger — c'est le mode de panne que ce
|
||||||
|
depot passe son temps a fermer.
|
||||||
|
"""
|
||||||
|
if not devis.get("frontiere") or not devis.get("noeuds_de_sortie"):
|
||||||
|
return ""
|
||||||
|
lignes = [EN_TETE_FRR.rstrip("\n")]
|
||||||
|
for b in devis["zones"]:
|
||||||
|
lignes += [f"vrf vrf_{b['zone']}",
|
||||||
|
f" ip route 0.0.0.0/0 {devis['frontiere']} nexthop-vrf default",
|
||||||
|
"exit-vrf"]
|
||||||
|
return "\n".join(lignes) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def rendre(devis: dict) -> str:
|
def rendre(devis: dict) -> str:
|
||||||
sortie = devis["noeuds_de_sortie"]
|
sortie = devis["noeuds_de_sortie"]
|
||||||
out = [
|
out = [
|
||||||
|
|
@ -263,11 +291,10 @@ def rendre(devis: dict) -> str:
|
||||||
elif not sortie:
|
elif not sortie:
|
||||||
out += ["# /!\\ Aucun noeud de sortie : strophe OMISE (elle n'aurait aucun porteur)."]
|
out += ["# /!\\ Aucun noeud de sortie : strophe OMISE (elle n'aurait aucun porteur)."]
|
||||||
else:
|
else:
|
||||||
out += [f"# A poser sur : {', '.join(sortie)}", "cat >> /etc/frr/frr.conf.local <<'EOF'"]
|
out += [f"# A poser sur : {', '.join(sortie)}",
|
||||||
for b in devis["zones"]:
|
"# `make sdn-appliquer CONFIRMER=true` le fait, et le RETIRE s'il ne l'est plus.",
|
||||||
out += [f"vrf vrf_{b['zone']}",
|
"cat > /etc/frr/frr.conf.local <<'EOF'"]
|
||||||
f" ip route 0.0.0.0/0 {devis['frontiere']} nexthop-vrf default",
|
out += strophe_frr(devis).rstrip("\n").split("\n")
|
||||||
"exit-vrf"]
|
|
||||||
out += ["EOF", "systemctl reload frr"]
|
out += ["EOF", "systemctl reload frr"]
|
||||||
out += [
|
out += [
|
||||||
"",
|
"",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue