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
|
||||
|
||||
### 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
|
||||
|
||||
`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)
|
||||
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
|
||||
frontiere-plan: ansible-runtime ## Ecart entre la frontiere OPNsense et son devis (aucune ecriture)
|
||||
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:
|
||||
sortie = devis["noeuds_de_sortie"]
|
||||
out = [
|
||||
|
|
@ -263,11 +291,10 @@ def rendre(devis: dict) -> str:
|
|||
elif not sortie:
|
||||
out += ["# /!\\ Aucun noeud de sortie : strophe OMISE (elle n'aurait aucun porteur)."]
|
||||
else:
|
||||
out += [f"# A poser sur : {', '.join(sortie)}", "cat >> /etc/frr/frr.conf.local <<'EOF'"]
|
||||
for b in devis["zones"]:
|
||||
out += [f"vrf vrf_{b['zone']}",
|
||||
f" ip route 0.0.0.0/0 {devis['frontiere']} nexthop-vrf default",
|
||||
"exit-vrf"]
|
||||
out += [f"# A poser sur : {', '.join(sortie)}",
|
||||
"# `make sdn-appliquer CONFIRMER=true` le fait, et le RETIRE s'il ne l'est plus.",
|
||||
"cat > /etc/frr/frr.conf.local <<'EOF'"]
|
||||
out += strophe_frr(devis).rstrip("\n").split("\n")
|
||||
out += ["EOF", "systemctl reload frr"]
|
||||
out += [
|
||||
"",
|
||||
|
|
|
|||
Loading…
Reference in a new issue