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:
Daniel Allaire 2026-08-06 19:39:33 -04:00
parent 3ed25ff703
commit b3447d5a96
5 changed files with 387 additions and 56 deletions

View file

@ -2,6 +2,36 @@
## 2026-08-06 — le chemin nord-sud devient dérivable ## 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 ### 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 `scripts/appliquer_sdn.py` — deuxième applicateur du dépôt, même contrat que celui de la

View file

@ -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: 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 .PHONY: sdn-plan sdn-appliquer
sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture) sdn-plan: ansible-runtime ## Ecart entre le SDN EVPN (+ sortie des VRF) et son devis (aucune ecriture)
python3 scripts/appliquer_sdn.py python3 scripts/appliquer_sdn.py

View 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())

View file

@ -39,63 +39,15 @@ RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts")) sys.path.insert(0, str(RACINE / "scripts"))
import devis_sdn as devis_mod # noqa: E402 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" 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]]: 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: def main() -> int:
base = _hebergeur() api, base = Cluster.depuis_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"], r = subprocess.run([sys.executable, str(RACINE / "scripts" / "devis_sdn.py"), "--json"],
cwd=RACINE, capture_output=True, text=True, 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]) raise SystemExit("Le devis ne se genere pas :\n" + r.stderr.strip()[:400])
devis = json.loads(r.stdout) 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) p = plan(api, devis)
if not afficher(p): if not afficher(p):
print("\n Le cluster dit deja ce que le devis dit. Rien a faire.") print("\n Le cluster dit deja ce que le devis dit. Rien a faire.")

89
scripts/proxmox_api.py Normal file
View 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 "")