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>
2026-08-06 19:39:33 -04:00
|
|
|
#!/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
|
|
|
|
|
|
|
|
|
|
|
voutes : le monde physique a la sienne, les tenants n'en portent plus les cles
Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ».
CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans
la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne
pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des
neuf copies, appliquee aux secrets.
CE QUI EST POSE :
- `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces
(jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants.
- `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site
non encore migre continue de fonctionner sur son ancienne voute.
- `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster.
La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres.
- Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme
derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur.
- `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18).
EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster
repond, le devis de placement est conforme pour les deux ecosystemes, le devis de
frontiere se genere (86 objets), le SDN est convergent.
UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la
chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est
desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:13:48 -04:00
|
|
|
def _lire_voute(p: Path) -> dict:
|
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def voute_underlay(base: Path) -> dict:
|
|
|
|
|
"""Les secrets du MONDE PHYSIQUE : `underlay.vault.yml`, chez l'hebergeur.
|
|
|
|
|
|
|
|
|
|
LA VOUTE DE L'UNDERLAY EST DISTINCTE DE CELLE DES TENANTS (2026-08-22). Le jeton d'API
|
|
|
|
|
du cluster et la cle de la frontiere appartiennent a l'hebergeur, pas a l'organisation
|
|
|
|
|
hebergee. Recopies dans la voute de chaque tenant, ils ne pouvaient plus etre revoques
|
|
|
|
|
isolement : retirer l'acces d'un locataire revenait a le retirer a tous.
|
|
|
|
|
|
|
|
|
|
C'est la faute des neuf copies de la resolution d'instance, appliquee aux secrets. On
|
|
|
|
|
ferme la meme porte, du meme geste : une source, pas N copies.
|
|
|
|
|
|
|
|
|
|
Rend {} si le fichier n'existe pas — l'appelant retombe alors sur la voute du tenant,
|
|
|
|
|
le temps que les sites migrent.
|
|
|
|
|
"""
|
|
|
|
|
p = base / "underlay.vault.yml"
|
|
|
|
|
return _lire_voute(p) if p.is_file() else {}
|
|
|
|
|
|
|
|
|
|
|
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>
2026-08-06 19:39:33 -04:00
|
|
|
def voute(base: Path) -> dict:
|
voutes : le monde physique a la sienne, les tenants n'en portent plus les cles
Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ».
CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans
la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne
pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des
neuf copies, appliquee aux secrets.
CE QUI EST POSE :
- `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces
(jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants.
- `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site
non encore migre continue de fonctionner sur son ancienne voute.
- `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster.
La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres.
- Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme
derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur.
- `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18).
EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster
repond, le devis de placement est conforme pour les deux ecosystemes, le devis de
frontiere se genere (86 objets), le SDN est convergent.
UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la
chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est
desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:13:48 -04:00
|
|
|
"""Secrets, lus par ansible-vault. Rien n'est journalise ni ecrit sur disque.
|
|
|
|
|
|
|
|
|
|
L'ordre compte : la voute de l'UNDERLAY d'abord (monde physique), celle du tenant
|
|
|
|
|
ensuite. Un site migre est donc lu correctement, un site qui ne l'est pas encore
|
|
|
|
|
continue de fonctionner — et le jour ou les deux portent la cle, c'est celle de
|
|
|
|
|
l'hebergeur qui fait foi, ce qui est le sens de la frontiere.
|
|
|
|
|
"""
|
|
|
|
|
fusion: dict = {}
|
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>
2026-08-06 19:39:33 -04:00
|
|
|
for nom in ("principal", "production", "lab"):
|
|
|
|
|
p = base / "inventories" / nom / "group_vars" / "all" / "vault.yml"
|
|
|
|
|
if p.is_file():
|
voutes : le monde physique a la sienne, les tenants n'en portent plus les cles
Demande de l'exploitant : « l'underlay et son tenant doivent avoir chacun sa voute ».
CE QUI ETAIT FAUX. Le jeton d'API du cluster et la cle d'API de la frontiere vivaient dans
la voute de CHAQUE tenant. Patient 0 a du les recopier pour exister. Consequence : on ne
pouvait plus revoquer l'acces d'un locataire sans le revoquer pour tous — la faute des
neuf copies, appliquee aux secrets.
CE QUI EST POSE :
- `underlay.vault.yml`, chez l'hebergeur, a cote d'underlay.yml. Quatre secrets deplaces
(jeton Proxmox, cle et secret d'API OPNsense), retires des deux voutes de tenants.
- `proxmox_api.voute()` lit l'underlay APRES le tenant, donc l'hebergeur fait foi ; un site
non encore migre continue de fonctionner sur son ancienne voute.
- `appliquer_opnsense._voute()` n'a plus sa propre lecture : elle appelle celle du cluster.
La reecrire aurait fait une dixieme copie le jour ou l'on refermait les neuf autres.
- Le playbook de clonage charge la voute de l'underlay APRES celle du tenant, par la meme
derivation que proxmox-hebergeur.yml : le symlink designe deja l'hebergeur.
- `voute.py` sait que ces secrets ne sont plus attendus chez un tenant (P18).
EPROUVE SUR LE REEL, apres retrait des cles chez les deux tenants : l'API du cluster
repond, le devis de placement est conforme pour les deux ecosystemes, le devis de
frontiere se genere (86 objets), le SDN est convergent.
UNE PRECAUTION APPRISE EN CHEMIN : le premier essai a ecrit la voute EN CLAIR avant de la
chiffrer, et le chiffrement a echoue — il a fallu detruire le fichier. La sequence est
desormais l'inverse : chiffrer dans un dossier de travail, ne deposer que le resultat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:13:48 -04:00
|
|
|
fusion = _lire_voute(p)
|
|
|
|
|
break
|
|
|
|
|
physique = voute_underlay(base)
|
|
|
|
|
if not fusion and not physique:
|
|
|
|
|
raise SystemExit(f"Aucune voute sous {base} (ni underlay.vault.yml, "
|
|
|
|
|
f"ni inventories/*/group_vars/all/vault.yml).")
|
|
|
|
|
fusion.update(physique)
|
|
|
|
|
return fusion
|
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>
2026-08-06 19:39:33 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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 "")
|