Some checks are pending
verifier / verifier (push) Waiting to run
`path.open("w")` TRONQUE avant d ecrire : entre les deux, le fichier est vide.
Une exception dans yaml.safe_dump, un disque plein, un Ctrl-C, et
instance/plan/serveurs.yml reste mutile.
L asymetrie fait la gravite : hosts.yml se regenere d un
make instancier-appliquer, le PLAN ne se regenere de rien. C est la source
unique de verite. Git est le filet, mais encore faut-il savoir qu on est tombe.
TREIZE SITES, UNE SEULE FONCTION
Le defaut n etait pas dans le GUI seul : douze sites dans sept fichiers, dont
les miroirs CLI des MEMES registres. Corriger le GUI seul aurait recree la
divergence que P41 garde depuis les neuf resolutions d instance. La fonction
vit donc dans inventory_rules.py, que les sept importaient deja. Une source,
pas douze.
TROIS DETAILS QUI FONT LA DIFFERENCE ENTRE « CA MARCHE » ET « CA TIENT »
temporaire dans le MEME dossier os.replace n est atomique qu au sein d un
meme systeme de fichiers ; un /tmp sur une
autre partition casserait la garantie sans
rien dire
fsync AVANT le rename sinon le renommage peut atteindre le disque
avant le contenu : au retour d une coupure
brutale, un fichier neuf et VIDE — le defaut
qu on ferme, deplace d un cran
report des droits mkstemp cree en 0600, le plan est en 0664 et
doit rester lisible par le groupe sur les
runners
LE TEST PORTE SON PROPRE CONTROLE NEGATIF
scripts/tests/test_ecriture_atomique.py rejoue D ABORD l ancienne forme et
verifie qu elle DETRUIT. Sans ce controle, « le fichier est intact » ne
prouverait rien — il pourrait l etre parce que rien n a ete ecrit du tout. Une
garantie qu on n a jamais vue echouer n est pas une garantie, c est une
habitude.
Branche sur P02, dont le titre annoncait « inventory_host » alors qu il lance
maintenant trois tests. Corrige au passage.
LA VOUTE DU GUI : VERIFIEE, PAS DE DEFAUT
Le soupcon etait qu executer_flux pose ANSIBLE_VAULT_PASSWORD_FILE (un seul mot
de passe) alors que creer une VM ouvre DEUX voutes depuis « une voute, une cle ».
Eprouve contre deux voutes JETABLES a mots de passe distincts — jamais les
vraies. Les deux variables se CUMULENT : Ansible essaie tous les secrets, et un
PASSWORD_FILE errone n empeche rien. Confirme en sondant l environnement qu une
recette make recoit reellement : le mot de passe saisi ET les cinq cles
calculees par voutes.py.
Ce qui sauve ce chemin n est donc pas le mot de passe saisi, c est
l IDENTITY_LIST que make pose par-dessus. Chacun couvre ce que l autre ne
couvre pas — le PASSWORD_FILE sert le runner qui n a que sa cle, l IDENTITY_LIST
le poste qui les a toutes. Ecrit au-dessus du code, pour que personne ne
« simplifie » en retirant l un des deux.
make prouver : CONFORME, 59 OK, 0 echec, 1 saute.
make instancier : DIFF VIDE, quatre registres relus, droits 664 preserves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
214 lines
8.3 KiB
Python
214 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Assistant de configuration Proxmox pour Set-OPS."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from getpass import getpass
|
|
from pathlib import Path
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
import yaml
|
|
|
|
from inventory_rules import ecriture_atomique, instance_courante, inventaire_force
|
|
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
INSTANCE = instance_courante()
|
|
|
|
|
|
def _inventaire_dir(instance: Path, *noms: str) -> Path:
|
|
"""Répertoire de l'inventaire modèle (porte la config Proxmox / le clonage)."""
|
|
forced = inventaire_force(instance) # refuse une cible hors de `instance`
|
|
if forced:
|
|
return forced.parent
|
|
for nom in noms:
|
|
d = instance / "inventories" / nom
|
|
if (d / "hosts.yml").exists():
|
|
return d
|
|
return instance / "inventories" / noms[0]
|
|
|
|
|
|
_INV_DIR = _inventaire_dir(INSTANCE, "lab", "principal", "production")
|
|
FICHIER_PROXMOX = _INV_DIR / "group_vars/proxmox.yml"
|
|
# Le CLUSTER appartient a l'HEBERGEUR : son fichier vit dans le depot que le symlink
|
|
# `underlay.yml` designe deja (D-14/D-17). Recopiees chez chaque tenant, ces cles
|
|
# avaient deja diverge. Repli sur le fichier du tenant si aucun underlay n'est monte
|
|
# (depot autonome) — `make config` reste utilisable tel quel.
|
|
_UNDERLAY = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml"))
|
|
FICHIER_PROXMOX_HEBERGEUR = (_UNDERLAY.resolve().parent / "proxmox-hebergeur.yml"
|
|
if _UNDERLAY.exists() else FICHIER_PROXMOX)
|
|
# Cles ecrites chez l'HEBERGEUR ; tout le reste (modele, defauts de placement) au tenant.
|
|
CLES_HEBERGEUR = {"proxmox_api_host", "proxmox_api_user", "proxmox_api_port",
|
|
"proxmox_validate_certs"}
|
|
# Voûte UNIQUE de l'instance : tous les secrets (token Proxmox + vault_*) au même
|
|
# endroit, un seul mot de passe. Gabarit : exemples/vault.exemple.yml.
|
|
FICHIER_VAULT = _INV_DIR / "group_vars/all/vault.yml"
|
|
GABARIT_VAULT = RACINE / "exemples/vault.exemple.yml"
|
|
|
|
|
|
VALEURS_DEFAUT = {
|
|
"proxmox_api_host": "",
|
|
"proxmox_api_user": "",
|
|
"proxmox_api_port": "",
|
|
"proxmox_validate_certs": False,
|
|
"proxmox_clone_noeud": "",
|
|
"proxmox_clone_vmid_modele": 9000,
|
|
"proxmox_clone_source_nom": "modeleSetOPS",
|
|
"proxmox_clone_stockage": "",
|
|
"proxmox_clone_format": "",
|
|
"proxmox_clone_complet": True,
|
|
"proxmox_clone_timeout": 600,
|
|
"proxmox_clone_disque": "scsi0",
|
|
"proxmox_clone_interface": "net0",
|
|
"proxmox_clone_pont": "vmbr0",
|
|
"proxmox_clone_parefeu_interface": False,
|
|
"proxmox_clone_demarrer": True,
|
|
}
|
|
|
|
|
|
LIBELLES = {
|
|
"proxmox_api_host": "Hote API Proxmox",
|
|
"proxmox_api_user": "Utilisateur API Proxmox",
|
|
"proxmox_api_port": "Port API Proxmox",
|
|
"proxmox_validate_certs": "Valider les certificats TLS",
|
|
"proxmox_clone_noeud": "Noeud Proxmox par defaut",
|
|
"proxmox_clone_vmid_modele": "VMID du modele Debian 13",
|
|
"proxmox_clone_source_nom": "Nom logique du modele",
|
|
"proxmox_clone_stockage": "Stockage Proxmox par defaut",
|
|
"proxmox_clone_format": "Format disque par defaut",
|
|
"proxmox_clone_complet": "Clone complet",
|
|
"proxmox_clone_timeout": "Timeout operations Proxmox",
|
|
"proxmox_clone_disque": "Disque principal",
|
|
"proxmox_clone_interface": "Interface reseau",
|
|
"proxmox_clone_pont": "Pont Proxmox",
|
|
"proxmox_clone_parefeu_interface": "Pare-feu interface Proxmox",
|
|
"proxmox_clone_demarrer": "Demarrer le clone apres creation",
|
|
}
|
|
|
|
|
|
def charger_yaml(path: Path) -> dict:
|
|
if not path.exists():
|
|
return {}
|
|
with path.open("r", encoding="utf-8") as fichier:
|
|
data = yaml.safe_load(fichier) or {}
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"{path} ne contient pas une table YAML.")
|
|
return data
|
|
|
|
|
|
def ecrire_yaml(path: Path, data: dict) -> None:
|
|
with ecriture_atomique(path) as fichier:
|
|
fichier.write("---\n")
|
|
fichier.write("# Parametres non sensibles pour les operations Proxmox.\n")
|
|
fichier.write("# Les secrets vont dans la voûte unique group_vars/all/vault.yml.\n\n")
|
|
yaml.safe_dump(data, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
|
|
|
|
def demander(identifiant: str, courant: object) -> object:
|
|
libelle = LIBELLES[identifiant]
|
|
if isinstance(courant, bool):
|
|
defaut = "oui" if courant else "non"
|
|
reponse = input(f"{libelle} [{defaut}] : ").strip().lower()
|
|
if not reponse:
|
|
return courant
|
|
return reponse in {"o", "oui", "y", "yes", "true", "1"}
|
|
|
|
reponse = input(f"{libelle} [{courant}] : ").strip()
|
|
if not reponse:
|
|
return courant
|
|
|
|
if isinstance(courant, int):
|
|
return int(reponse)
|
|
return reponse
|
|
|
|
|
|
def configurer_proxmox() -> None:
|
|
# Deux fichiers, deux proprietaires : on fusionne pour AFFICHER l'etat courant,
|
|
# on separe pour ECRIRE. Sans quoi `make config` reecrirait chez le tenant les
|
|
# cles du cluster, et la recopie recommencerait a la premiere execution.
|
|
courant = (VALEURS_DEFAUT | charger_yaml(FICHIER_PROXMOX)
|
|
| charger_yaml(FICHIER_PROXMOX_HEBERGEUR))
|
|
nouveau = {}
|
|
|
|
print("Configuration Proxmox non sensible")
|
|
print("Entrer pour conserver la valeur entre crochets.\n")
|
|
print("Les valeurs Cloud-Init deja portees par le modele ne sont pas redemandees.\n")
|
|
|
|
for identifiant in VALEURS_DEFAUT:
|
|
nouveau[identifiant] = demander(identifiant, courant.get(identifiant, VALEURS_DEFAUT[identifiant]))
|
|
|
|
separe = FICHIER_PROXMOX_HEBERGEUR != FICHIER_PROXMOX
|
|
tenant = {k: v for k, v in nouveau.items() if not (separe and k in CLES_HEBERGEUR)}
|
|
ecrire_yaml(FICHIER_PROXMOX, tenant)
|
|
print(f"\nEcrit: {os.path.relpath(FICHIER_PROXMOX, RACINE)}")
|
|
if separe:
|
|
# Fusion : preserve les catalogues (noeuds/stockages/ponts) que ce script ne
|
|
# demande pas mais que le panneau ecrit dans le meme fichier.
|
|
heb = charger_yaml(FICHIER_PROXMOX_HEBERGEUR)
|
|
heb.update({k: nouveau[k] for k in CLES_HEBERGEUR if k in nouveau})
|
|
ecrire_yaml(FICHIER_PROXMOX_HEBERGEUR, heb)
|
|
print(f"Ecrit: {FICHIER_PROXMOX_HEBERGEUR} (HEBERGEUR — vaut pour tous ses tenants)")
|
|
|
|
|
|
def demander_oui_non(question: str, defaut: bool = False) -> bool:
|
|
suffixe = "O/n" if defaut else "o/N"
|
|
reponse = input(f"{question} [{suffixe}] : ").strip().lower()
|
|
if not reponse:
|
|
return defaut
|
|
return reponse in {"o", "oui", "y", "yes", "true", "1"}
|
|
|
|
|
|
def configurer_vault() -> None:
|
|
if not demander_oui_non("Configurer la voûte de secrets maintenant"):
|
|
return
|
|
|
|
if FICHIER_VAULT.exists():
|
|
print(f"Ouverture de la voûte existante: {os.path.relpath(FICHIER_VAULT, RACINE)}")
|
|
subprocess.run(["ansible-vault", "edit", str(FICHIER_VAULT)], check=True)
|
|
return
|
|
|
|
# Voûte absente : on la sème à partir du gabarit (toutes les clés vides),
|
|
# on y place le token Proxmox saisi, puis on chiffre. Les autres secrets se
|
|
# renseignent ensuite via `ansible-vault edit`.
|
|
token_id = input("Token ID Proxmox [set-ops] : ").strip() or "set-ops"
|
|
token_secret = getpass("Token secret Proxmox : ").strip()
|
|
if not token_secret:
|
|
print("Secret vide: voûte non créée.")
|
|
return
|
|
|
|
contenu = charger_yaml(GABARIT_VAULT)
|
|
contenu["proxmox_api_token_id"] = token_id
|
|
contenu["proxmox_api_token_secret"] = token_secret
|
|
|
|
FICHIER_VAULT.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
fd = os.open(FICHIER_VAULT, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fichier:
|
|
yaml.safe_dump(contenu, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
subprocess.run(["ansible-vault", "encrypt", str(FICHIER_VAULT)], check=True)
|
|
print(f"Voûte créée: {os.path.relpath(FICHIER_VAULT, RACINE)}")
|
|
print("Renseigne les autres secrets avec : ansible-vault edit "
|
|
f"{os.path.relpath(FICHIER_VAULT, RACINE)}")
|
|
except Exception:
|
|
if FICHIER_VAULT.exists():
|
|
FICHIER_VAULT.unlink()
|
|
raise
|
|
|
|
|
|
def main() -> int:
|
|
try:
|
|
configurer_proxmox()
|
|
configurer_vault()
|
|
except KeyboardInterrupt:
|
|
print("\nConfiguration interrompue.", file=sys.stderr)
|
|
return 130
|
|
except Exception as exc:
|
|
print(f"erreur: {exc}", file=sys.stderr)
|
|
return 2
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|