proxmox-fw : un applicateur, et un refus assume
Reconcilie les trois couches du devis : 26 IPSets, 36 groupes, affectations aux VM. Cree, met a jour, retire — meme contrat que les applicateurs de la frontiere et du SDN. Il n'active JAMAIS le pare-feu du datacenter : ce reglage vaut pour toutes les VM du cluster, y compris les 37 heritees sans regle, et le basculer couperait le parc. L'ecart est signale a chaque execution ; la decision reste humaine. Les 28 VM du devis n'existent pas encore : listees comme differees, pas comme erreurs. Les objets poses sont donc inertes, ce qui rend l'application sure. `scripts/proxmox_api.py` extrait ce que les deux applicateurs Proxmox partagent, en particulier la recomposition du jeton dont la voute ne porte que le nom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3ed25ff703
commit
b3447d5a96
5 changed files with 387 additions and 56 deletions
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -2,6 +2,36 @@
|
|||
|
||||
## 2026-08-06 — le chemin nord-sud devient dérivable
|
||||
|
||||
### Un applicateur pour le pare-feu est-ouest — et un refus assumé
|
||||
|
||||
`scripts/appliquer_proxmox_fw.py` réconcilie les trois couches du devis : 26 IPSets,
|
||||
36 groupes de sécurité, et les affectations aux VM. Créé, mis à jour, retiré — même contrat
|
||||
que les deux autres.
|
||||
|
||||
**Ce qu'il ne fera jamais : activer le pare-feu du datacenter.** Ce réglage
|
||||
(`enable=1` + `policy_in=DROP`) vaut pour **toutes** les VM du cluster, y compris les 37
|
||||
machines héritées qui n'ont aucune règle. Le basculer couperait le parc d'un coup. L'écart est
|
||||
signalé à chaque exécution, en toutes lettres ; la décision reste humaine.
|
||||
|
||||
C'est la première fois qu'un applicateur de ce dépôt **refuse par conception** de faire une
|
||||
partie de son devis. Le refus vaut mieux qu'une option qu'on finirait par cocher sans y penser.
|
||||
|
||||
**Les 28 VM du devis n'existent pas encore.** Elles sont listées comme différées, pas comme
|
||||
erreurs : leur affectation se posera au prochain passage, une fois clonées.
|
||||
|
||||
**Les objets posés sont inertes**, précisément parce que le prérequis n'est pas rempli. C'est
|
||||
ce qui rend l'application sûre aujourd'hui : la politique est en place et vérifiable, sans rien
|
||||
filtrer tant qu'on ne l'a pas décidé.
|
||||
|
||||
**`scripts/proxmox_api.py`** extrait ce que les deux applicateurs Proxmox partagent — surtout
|
||||
la recomposition du jeton `utilisateur@royaume!nom`, dont la voûte ne porte que le nom. La
|
||||
dupliquer, c'était préparer un 401 muet le jour où l'un des deux morceaux changerait.
|
||||
|
||||
Une fausse alerte en passant : les noms tronqués à 18 caractères semblaient collisionner entre
|
||||
IPSets et groupes. Ce sont deux espaces de noms distincts chez Proxmox, et les règles
|
||||
référencent un IPSet par un `+`. Aucune collision — et le devis portait déjà sa garde.
|
||||
|
||||
|
||||
### 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
|
||||
|
|
|
|||
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: proxmox-fw-plan proxmox-fw-appliquer
|
||||
proxmox-fw-plan: ansible-runtime ## Ecart entre le pare-feu est-ouest Proxmox et son devis (aucune ecriture)
|
||||
python3 scripts/appliquer_proxmox_fw.py
|
||||
|
||||
proxmox-fw-appliquer: ansible-runtime ## Reconcilie le pare-feu est-ouest : IPSets, groupes, affectations. CONFIRMER=true
|
||||
@if [[ "$(CONFIRMER)" != "true" ]]; then \
|
||||
printf '%s\n' "Refus: cette cible ECRIT sur le pare-feu du cluster (IPSets, groupes, VM)" \
|
||||
"et RETIRE ce qui est perime. Elle n'active JAMAIS le pare-feu du datacenter." \
|
||||
"Relire d'abord 'make proxmox-fw-plan', puis: make proxmox-fw-appliquer CONFIRMER=true"; \
|
||||
exit 2; fi
|
||||
CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py
|
||||
|
||||
.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
|
||||
|
|
|
|||
253
scripts/appliquer_proxmox_fw.py
Normal file
253
scripts/appliquer_proxmox_fw.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Reconcilie le pare-feu Proxmox (est-ouest intra-tenant) avec son devis.
|
||||
|
||||
Trois couches, toutes au niveau du CLUSTER sauf la derniere :
|
||||
- IPSets : qui porte quel role
|
||||
- Groupes : les flux entrants d'un role
|
||||
- Affectations : quels groupes sur quelle VM
|
||||
|
||||
DEUX SENS : ce que le devis demande et qui manque est cree, ce qu'il ne demande
|
||||
plus est RETIRE. Un groupe orphelin ne bloque rien, mais il decrit une politique
|
||||
qui n'est plus la notre.
|
||||
|
||||
CE QU'IL NE FAIT JAMAIS : activer le pare-feu du datacenter, ni poser sa politique
|
||||
d'entree a DROP. Ce reglage vaut pour TOUTES les VM du cluster, y compris celles
|
||||
qui n'ont aucune regle — le basculer couperait le parc herite d'un coup. L'ecart
|
||||
est signale, la decision reste humaine.
|
||||
|
||||
PERIMETRE STRICT : seuls les objets prefixes par l'etiquette d'un tenant du devis
|
||||
(`t11-`, `t17-`) sont touches. Le reste du cluster n'existe pas pour ce script.
|
||||
|
||||
NON DESTRUCTIF PAR DEFAUT : sans `CONFIRMER=true`, aucune ecriture (regle 4).
|
||||
|
||||
Usage :
|
||||
python3 scripts/appliquer_proxmox_fw.py # plan seul
|
||||
CONFIRMER=true python3 scripts/appliquer_proxmox_fw.py # applique
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
from proxmox_api import Cluster # noqa: E402
|
||||
|
||||
|
||||
def _prefixe(bloc: dict) -> str:
|
||||
"""`t11-` — deduit du premier IPSet, qui les porte tous. Jamais saisi."""
|
||||
for nom in bloc.get("ipsets") or {}:
|
||||
return nom.split("-")[0] + "-"
|
||||
for g in bloc.get("groupes") or []:
|
||||
return str(g["nom"]).split("-")[0] + "-"
|
||||
return ""
|
||||
|
||||
|
||||
def _regle_cle(r: dict) -> tuple:
|
||||
"""Identite d'une regle de groupe, insensible a la position et au commentaire."""
|
||||
return (str(r.get("type") or r.get("sens") or "").lower(),
|
||||
str(r.get("action") or "").upper(),
|
||||
str(r.get("source") or ""),
|
||||
str(r.get("proto") or ""),
|
||||
str(r.get("dport") or ""))
|
||||
|
||||
|
||||
def plan(api: Cluster, devis: dict) -> dict:
|
||||
prefixes = tuple(_prefixe(b) for b in devis["blocs"] if _prefixe(b))
|
||||
|
||||
ipsets_voulus, groupes_voulus, affect_voulues = {}, {}, {}
|
||||
for b in devis["blocs"]:
|
||||
for nom, d in (b.get("ipsets") or {}).items():
|
||||
ipsets_voulus[nom] = {"membres": sorted(set(d["membres"])), "role": d.get("role", "")}
|
||||
for g in b.get("groupes") or []:
|
||||
groupes_voulus[g["nom"]] = g
|
||||
for a in b.get("affectations") or []:
|
||||
affect_voulues[int(a["vmid"])] = {"hote": a["hote"], "groupes": sorted(a["groupes"])}
|
||||
|
||||
# --- etat du cluster, restreint a nos prefixes -----------------------------
|
||||
ipsets_poses = {}
|
||||
for s in (api("/cluster/firewall/ipset") or []):
|
||||
if isinstance(s, dict) and str(s.get("name", "")).startswith(prefixes):
|
||||
membres = [str(m.get("cidr")) for m in (api(f"/cluster/firewall/ipset/{s['name']}") or [])
|
||||
if isinstance(m, dict)]
|
||||
ipsets_poses[s["name"]] = sorted(set(membres))
|
||||
|
||||
groupes_poses = {}
|
||||
for g in (api("/cluster/firewall/groups") or []):
|
||||
if isinstance(g, dict) and str(g.get("group", "")).startswith(prefixes):
|
||||
regles = [r for r in (api(f"/cluster/firewall/groups/{g['group']}") or [])
|
||||
if isinstance(r, dict)]
|
||||
groupes_poses[g["group"]] = regles
|
||||
|
||||
vm_du_cluster = {int(r["vmid"]): r["node"] for r in (api("/cluster/resources?type=vm") or [])
|
||||
if isinstance(r, dict) and r.get("vmid")}
|
||||
|
||||
affect_posees, vm_absentes = {}, []
|
||||
for vmid in affect_voulues:
|
||||
noeud = vm_du_cluster.get(vmid)
|
||||
if not noeud:
|
||||
vm_absentes.append(vmid)
|
||||
continue
|
||||
regles = api(f"/nodes/{noeud}/qemu/{vmid}/firewall/rules") or []
|
||||
affect_posees[vmid] = {
|
||||
"noeud": noeud,
|
||||
"groupes": sorted(str(r.get("action")) for r in regles
|
||||
if isinstance(r, dict) and r.get("type") == "group"),
|
||||
"actif": str((api(f"/nodes/{noeud}/qemu/{vmid}/firewall/options") or {}).get("enable", "")),
|
||||
}
|
||||
|
||||
opts = api("/cluster/firewall/options") or {}
|
||||
return {
|
||||
"ipsets_creer": {n: v for n, v in ipsets_voulus.items() if n not in ipsets_poses},
|
||||
"ipsets_majer": {n: (ipsets_poses[n], v["membres"]) for n, v in ipsets_voulus.items()
|
||||
if n in ipsets_poses and ipsets_poses[n] != v["membres"]},
|
||||
"ipsets_retirer": sorted(n for n in ipsets_poses if n not in ipsets_voulus),
|
||||
"groupes_creer": {n: g for n, g in groupes_voulus.items() if n not in groupes_poses},
|
||||
"groupes_majer": {n: g for n, g in groupes_voulus.items()
|
||||
if n in groupes_poses
|
||||
and {_regle_cle(r) for r in g["regles"]}
|
||||
!= {_regle_cle(r) for r in groupes_poses[n]}},
|
||||
"groupes_retirer": sorted(n for n in groupes_poses if n not in groupes_voulus),
|
||||
"affect_majer": {v: a for v, a in affect_voulues.items()
|
||||
if v in affect_posees
|
||||
and (affect_posees[v]["groupes"] != a["groupes"]
|
||||
or affect_posees[v]["actif"] != "1")},
|
||||
"affect_posees": affect_posees,
|
||||
"vm_absentes": sorted(vm_absentes),
|
||||
"datacenter": {"actif": str(opts.get("enable", "")), "politique": str(opts.get("policy_in", ""))},
|
||||
"_voulus": {"ipsets": ipsets_voulus, "groupes": groupes_voulus, "affect": affect_voulues},
|
||||
}
|
||||
|
||||
|
||||
def afficher(p: dict) -> bool:
|
||||
for n in sorted(p["ipsets_creer"]):
|
||||
print(f" + IPSet {n:<22} {len(p['ipsets_creer'][n]['membres'])} membre(s)")
|
||||
for n, (avant, apres) in sorted(p["ipsets_majer"].items()):
|
||||
print(f" ~ IPSet {n:<22} {len(avant)} -> {len(apres)} membre(s)")
|
||||
for n in p["ipsets_retirer"]:
|
||||
print(f" - IPSet PERIME {n}")
|
||||
for n, g in sorted(p["groupes_creer"].items()):
|
||||
print(f" + groupe {n:<22} {len(g['regles'])} regle(s) # {g.get('role','')}")
|
||||
for n in sorted(p["groupes_majer"]):
|
||||
print(f" ~ groupe {n:<22} regles differentes")
|
||||
for n in p["groupes_retirer"]:
|
||||
print(f" - groupe PERIME {n}")
|
||||
for v, a in sorted(p["affect_majer"].items()):
|
||||
print(f" ~ VM {v:<12} {a['hote']:<16} -> {', '.join(a['groupes'])}")
|
||||
if p["vm_absentes"]:
|
||||
print(f"\n {len(p['vm_absentes'])} VM du devis absente(s) du cluster — affectation "
|
||||
f"differee, elle se posera au clonage :")
|
||||
print(f" {', '.join(str(v) for v in p['vm_absentes'][:8])}"
|
||||
+ (" …" if len(p["vm_absentes"]) > 8 else ""))
|
||||
dc = p["datacenter"]
|
||||
if dc["actif"] != "1" or dc["politique"].upper() != "DROP":
|
||||
print(f"\n /!\\ PREREQUIS NON REMPLI — pare-feu du datacenter : actif={dc['actif'] or '0'}, "
|
||||
f"politique d'entree={dc['politique'] or '(defaut: ACCEPT)'}.")
|
||||
print(" Sans `enable=1` ET `policy_in=DROP`, les regles ci-dessus s'ajoutent a un")
|
||||
print(" tout-permis et n'interdisent RIEN.")
|
||||
print(" Ce script ne le bascule PAS : le reglage vaut pour TOUTES les VM du")
|
||||
print(" cluster, y compris le parc herite qui n'a aucune regle. A decider a la main.")
|
||||
creer = len(p["ipsets_creer"]) + len(p["groupes_creer"])
|
||||
majer = len(p["ipsets_majer"]) + len(p["groupes_majer"]) + len(p["affect_majer"])
|
||||
retirer = len(p["ipsets_retirer"]) + len(p["groupes_retirer"])
|
||||
print(f"\n a creer : {creer} | a mettre a jour : {majer} | a retirer : {retirer}")
|
||||
return bool(creer or majer or retirer)
|
||||
|
||||
|
||||
def appliquer(api: Cluster, p: dict) -> int:
|
||||
echecs = 0
|
||||
|
||||
def _fait(rep, quoi):
|
||||
nonlocal echecs
|
||||
if (e := api.rate(rep)):
|
||||
echecs += 1
|
||||
print(f" ! ECHEC {quoi} : {e[:150]}")
|
||||
return False
|
||||
return True
|
||||
|
||||
# 1. IPSets AVANT les groupes : une regle qui reference un IPSet absent est refusee.
|
||||
for n, v in sorted(p["ipsets_creer"].items()):
|
||||
if _fait(api("/cluster/firewall/ipset", "POST",
|
||||
{"name": n, "comment": v["role"][:100]}), f"ipset {n}"):
|
||||
for m in v["membres"]:
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}")
|
||||
for n, (avant, apres) in sorted(p["ipsets_majer"].items()):
|
||||
for m in sorted(set(apres) - set(avant)):
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}", "POST", {"cidr": m}), f"{n} += {m}")
|
||||
for m in sorted(set(avant) - set(apres)):
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}/{m}", "DELETE"), f"{n} -= {m}")
|
||||
|
||||
# 2. Groupes. Une mise a jour REMPLACE les regles : elles sont a nous, et les
|
||||
# comparer une a une pour un edit partiel couterait plus que de les reposer.
|
||||
for n in sorted(p["groupes_majer"]):
|
||||
for r in reversed(api(f"/cluster/firewall/groups/{n}") or []):
|
||||
_fait(api(f"/cluster/firewall/groups/{n}/{r['pos']}", "DELETE"), f"purge {n}")
|
||||
for n, g in sorted({**p["groupes_creer"], **p["groupes_majer"]}.items()):
|
||||
if n in p["groupes_creer"]:
|
||||
_fait(api("/cluster/firewall/groups", "POST",
|
||||
{"group": n, "comment": str(g.get("role", ""))[:100]}), f"groupe {n}")
|
||||
for r in g["regles"]:
|
||||
corps = {"type": str(r.get("sens", "in")).lower(), "action": r["action"],
|
||||
"enable": 1, "comment": str(r.get("via") or g.get("role", ""))[:100]}
|
||||
for cle, champ in (("source", "source"), ("proto", "proto"), ("dport", "dport")):
|
||||
if r.get(cle):
|
||||
corps[champ] = r[cle]
|
||||
_fait(api(f"/cluster/firewall/groups/{n}", "POST", corps), f"regle de {n}")
|
||||
|
||||
# 3. Affectations aux VM, et activation de leur pare-feu — sans quoi les groupes
|
||||
# seraient poses sur une machine qui ne filtre pas.
|
||||
for v, a in sorted(p["affect_majer"].items()):
|
||||
noeud = p["affect_posees"][v]["noeud"]
|
||||
for r in reversed(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules") or []):
|
||||
if isinstance(r, dict) and r.get("type") == "group":
|
||||
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules/{r['pos']}", "DELETE"),
|
||||
f"purge VM {v}")
|
||||
for g in a["groupes"]:
|
||||
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/rules", "POST",
|
||||
{"type": "group", "action": g, "enable": 1}), f"VM {v} += {g}")
|
||||
_fait(api(f"/nodes/{noeud}/qemu/{v}/firewall/options", "PUT", {"enable": 1}),
|
||||
f"pare-feu de la VM {v}")
|
||||
|
||||
# 4. Retraits : groupes avant IPSets, un IPSet reference ne se supprime pas.
|
||||
for n in p["groupes_retirer"]:
|
||||
for r in reversed(api(f"/cluster/firewall/groups/{n}") or []):
|
||||
_fait(api(f"/cluster/firewall/groups/{n}/{r['pos']}", "DELETE"), f"purge {n}")
|
||||
_fait(api(f"/cluster/firewall/groups/{n}", "DELETE"), f"retrait groupe {n}")
|
||||
for n in p["ipsets_retirer"]:
|
||||
_fait(api(f"/cluster/firewall/ipset/{n}", "DELETE", {"force": 1}), f"retrait ipset {n}")
|
||||
|
||||
if echecs:
|
||||
print(f"\n {echecs} echec(s) — la reconciliation est INCOMPLETE.")
|
||||
return 1
|
||||
print("\n Pare-feu est-ouest reconcilie.")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
api, base = Cluster.depuis_hebergeur()
|
||||
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_proxmox_fw.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 {api.hote} — {len(devis['blocs'])} tenant(s) au devis\n")
|
||||
p = plan(api, devis)
|
||||
quelque_chose = afficher(p)
|
||||
if not quelque_chose:
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -39,63 +39,15 @@ RACINE = Path(__file__).resolve().parent.parent
|
|||
sys.path.insert(0, str(RACINE / "scripts"))
|
||||
|
||||
import devis_sdn as devis_mod # noqa: E402
|
||||
from proxmox_api import Cluster, ssh as _ssh # 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]]:
|
||||
|
|
@ -299,12 +251,7 @@ def appliquer(api: Cluster, p: dict, devis: dict) -> int:
|
|||
|
||||
|
||||
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"])
|
||||
api, base = Cluster.depuis_hebergeur()
|
||||
|
||||
r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_sdn.py"), "--json"],
|
||||
cwd=RACINE, capture_output=True, text=True,
|
||||
|
|
@ -313,7 +260,7 @@ def main() -> int:
|
|||
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")
|
||||
print(f"Cluster {api.hote} — {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.")
|
||||
|
|
|
|||
89
scripts/proxmox_api.py
Normal file
89
scripts/proxmox_api.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Acces au cluster Proxmox de l'hebergeur — partage par les applicateurs.
|
||||
|
||||
Extrait pour n'avoir QU'UN endroit ou vit la recomposition du jeton : la voute ne
|
||||
porte que le NOM du jeton, l'utilisateur vit dans `proxmox-hebergeur.yml`. Les
|
||||
recomposer a deux endroits, c'est se preparer un 401 muet le jour ou l'un des deux
|
||||
change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def hebergeur() -> Path:
|
||||
"""Depot de l'hebergeur, designe par le symlink `underlay.yml` (D-17)."""
|
||||
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:
|
||||
"""Secrets, lus par ansible-vault. Rien n'est journalise ni ecrit sur disque."""
|
||||
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. Retourne `{'_erreur': ...}` plutot que de lever."""
|
||||
|
||||
def __init__(self, hote: str, port, user: str, tid: str, secret: str):
|
||||
self.base = f"https://{hote}:{port}/api2/json"
|
||||
self.hote = hote
|
||||
self.tok = f"PVEAPIToken={user}!{str(tid).split('!')[-1]}={secret}"
|
||||
self.ctx = ssl._create_unverified_context()
|
||||
|
||||
@classmethod
|
||||
def depuis_hebergeur(cls) -> tuple["Cluster", Path]:
|
||||
base = hebergeur()
|
||||
heb = yaml.safe_load((base / "proxmox-hebergeur.yml").read_text(encoding="utf-8")) or {}
|
||||
v = voute(base)
|
||||
return cls(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"]), base
|
||||
|
||||
def __call__(self, chemin: str, methode: str = "GET", corps: dict | None = None):
|
||||
data = urllib.parse.urlencode(corps, doseq=True).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 rate(self, rep) -> str | None:
|
||||
return rep["_erreur"] if isinstance(rep, dict) and rep.get("_erreur") else None
|
||||
|
||||
|
||||
def ssh(hote: str, commande: str, entree: str | None = None) -> tuple[int, str]:
|
||||
"""SSH vers un noeud. 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 "")
|
||||
Loading…
Reference in a new issue