Set-OPS-Public/scripts/model_creer.py

236 lines
9.8 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Crée un MODÈLE (template réutilisable) dans le dépôt privé des modèles.
Deux modes :
- base : copie un modèle déjà générique vers un nouveau (copier + éditer).
- instance : PROMEUT une instance éprouvée en modèle la généralise (identité
placeholders, domaines exemple.*, index gabarit) et RETIRE tout
secret (voûte, jeton Proxmox) et l'inventaire généré.
Le modèle atterrit dans le dépôt PRIVÉ (`Set-OPS-Modeles/` par défaut, ou --dest),
jamais dans `exemples/modeles/` (public). Il doit passer `modeles.py verifier`.
Usage :
python3 scripts/model_creer.py --mode base --base identite --nom maison-obnl
python3 scripts/model_creer.py --mode instance --source OPS-Chezlepro --nom cabinet
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
FRERES = RACINE.parent
sys.path.insert(0, str(RACINE / "scripts"))
import modeles as _modeles # noqa: E402
# Fichiers de secret / généré : JAMAIS copiés dans un modèle.
SECRETS_OU_GENERE = {"vault.yml", "proxmox.vault.yml", "hosts.yml", "hosts.genere.yml"}
# Clés Proxmox spécifiques au site, vidées (D1 : génériquer, garder la structure).
PROXMOX_A_VIDER = {
"proxmox_api_host", "proxmox_clone_noeud", "proxmox_clone_stockage",
"proxmox_clone_vmid_modele",
}
def _dest_racine(dest: str | None) -> Path:
if dest:
return Path(dest) if Path(dest).is_absolute() else (RACINE / dest).resolve()
return FRERES / "Set-OPS-Modeles"
def _inv_dir(base: Path) -> Path | None:
for nom in ("principal", "production", "lab"):
if (base / "inventories" / nom).is_dir():
return base / "inventories" / nom
return None
def _copier_squelette(src: Path, dst: Path) -> str:
"""Copie CIBLÉE de plan/ + inventories/ (hors secrets et généré), en résolvant les
symlinks (copyfile lit le contenu). Ne suit AUCUN autre dossier robuste aux dépôts
imbriqués et aux boucles de symlinks. Renvoie le nom du dossier d'inventaire.
"""
inv = _inv_dir(src)
if not inv or not (src / "plan").is_dir():
raise ValueError(f"Source sans plan/ + inventories/ exploitables : {src.name}.")
inv_nom = inv.name
(dst / "plan").mkdir(parents=True)
for f in sorted((src / "plan").glob("*.yml")):
shutil.copyfile(f, dst / "plan" / f.name)
if (src / "README.md").is_file():
shutil.copyfile(src / "README.md", dst / "README.md")
gv = dst / "inventories" / inv_nom / "group_vars"
gv.mkdir(parents=True)
# group_vars/*.yml de premier niveau (inclut all.yml plat si présent), hors secrets
for f in sorted((inv / "group_vars").glob("*.yml")):
if f.name not in SECRETS_OU_GENERE:
shutil.copyfile(f, gv / f.name)
# group_vars/all/*.yml (structure en dossier), hors voûte chiffrée (garde .example)
all_src = inv / "group_vars" / "all"
if all_src.is_dir():
(gv / "all").mkdir(exist_ok=True)
for f in sorted(all_src.glob("*")):
if (f.is_file() or f.is_symlink()) and f.name not in SECRETS_OU_GENERE:
shutil.copyfile(f, gv / "all" / f.name)
return inv_nom
def _fichiers_texte(racine: Path):
for p in racine.rglob("*"):
if p.is_file() and p.suffix in (".yml", ".yaml", ".md", ".example", ".cfg", ".ini", ""):
yield p
def _remplacer_partout(racine: Path, paires: list[tuple[str, str]]) -> None:
"""Remplace des chaînes dans tous les fichiers texte (généralisation identité/domaine)."""
paires = sorted({p for p in paires if p[0]}, key=lambda t: -len(t[0]))
for f in _fichiers_texte(racine):
try:
t = f.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
n = t
for src, dst in paires:
n = n.replace(src, dst)
if n != t:
f.write_text(n, encoding="utf-8")
def _sub_champ(fichier: Path, motif: str, remplacement: str) -> None:
if not fichier.is_file():
return
t = fichier.read_text(encoding="utf-8")
fichier.write_text(re.sub(motif, remplacement, t, flags=re.M), encoding="utf-8")
def _generaliser(modele: Path, source: Path) -> None:
"""Applique la liste de généralisation validée sur le modèle fraîchement copié."""
inv = _inv_dir(source)
inv_nom = inv.name if inv else "principal"
all_dir = modele / "inventories" / inv_nom / "group_vars" / "all"
gv_dir = modele / "inventories" / inv_nom / "group_vars"
# --- collecter l'identité RÉELLE de la source pour la remplacer partout ---
intr = yaml.safe_load((all_dir / "10-intrants.yml").read_text(encoding="utf-8")) or {} \
if (all_dir / "10-intrants.yml").is_file() else {}
doms = yaml.safe_load((modele / "plan" / "domaines.yml").read_text(encoding="utf-8")) or {} \
if (modele / "plan" / "domaines.yml").is_file() else {}
di = str(intr.get("domaine_interne", "")).strip()
org = str(intr.get("organisation", "")).strip()
realm = str(intr.get("identite_realm", "")).strip()
publics = [k for k in (doms.get("domaines_publics") or {}) if not str(k).endswith(".internal")]
paires: list[tuple[str, str]] = []
if di:
paires.append((di, "exemple.internal"))
for p in publics:
paires.append((p, "exemple.ca"))
if org:
paires.append((org, "Exemple"))
if org.lower() != org:
paires.append((org.lower(), "exemple"))
if realm and realm != "exemple":
paires.append((realm, "exemple"))
_remplacer_partout(modele, paires)
# --- placeholders non textuels (champs précis) ---
intr_f = all_dir / "10-intrants.yml"
_sub_champ(intr_f, r"(?m)^nftables_admin_ssh:.*$", "nftables_admin_ssh: []")
# (domaine/organisation/realm déjà génériqués par le remplacement global)
inst_f = all_dir / "00-instance.yml"
_sub_champ(inst_f, r"(?m)^setops_production:.*$", "setops_production: false")
nom_f = modele / "plan" / "nomenclature.yml"
_sub_champ(nom_f, r"(?m)^index:.*$", "index: 1")
_sub_champ(nom_f, r"(?m)^federe:.*\n", "") # un modèle est neutre côté fédération
bkp_f = gv_dir / "serveur_backup.yml"
_sub_champ(bkp_f, r'(?m)^serveur_backup_pubkey:.*$', 'serveur_backup_pubkey: ""')
# --- D1 : génériquer proxmox.yml (vider le spécifique au site, garder les clés) ---
px_f = gv_dir / "proxmox.yml"
if px_f.is_file():
for cle in PROXMOX_A_VIDER:
_sub_champ(px_f, rf'(?m)^{cle}:.*$', f'{cle}: ""')
_sub_champ(px_f, r'(?m)^proxmox_clone_source_nom:.*$',
'proxmox_clone_source_nom: "modele-debian13"')
def creer(mode: str, nom: str, base: str | None, source: str | None, dest: str | None) -> Path:
if not nom or "/" in nom or nom.startswith("."):
raise ValueError(f"Nom de modèle invalide : {nom!r}.")
dest_racine = _dest_racine(dest)
if not dest_racine.is_dir():
raise ValueError(f"Dépôt de modèles introuvable : {dest_racine} (créer d'abord le dépôt privé, ou --dest).")
cible = dest_racine / nom
if cible.exists():
raise ValueError(f"Existe déjà : {cible} — refus (ne rien écraser).")
if mode == "base":
dispo = {m.name: m for m in _modeles.decouvrir()}
if base not in dispo:
raise ValueError(f"Modèle de base inconnu : {base!r}. Disponibles : {', '.join(sorted(dispo)) or 'aucun'}.")
_copier_squelette(dispo[base], cible)
elif mode == "instance":
src = FRERES / (source or "")
if not (src / "plan").is_dir():
raise ValueError(f"Instance source introuvable (dossier frère avec plan/) : {source!r}.")
_copier_squelette(src, cible)
_generaliser(cible, src)
else:
raise ValueError(f"Mode inconnu : {mode!r} (attendu : base | instance).")
# --- SÛRETÉ : aucun secret ne doit subsister ---
for interdit in cible.rglob("vault.yml"):
shutil.rmtree(cible)
raise ValueError(f"Refus : un secret ({interdit.name}) a été trouvé dans le modèle — annulé.")
for interdit in cible.rglob("proxmox.vault.yml"):
shutil.rmtree(cible)
raise ValueError(f"Refus : un secret ({interdit.name}) a été trouvé dans le modèle — annulé.")
# --- validation du modèle produit ---
erreurs = _modeles.verifier_un(cible)
if erreurs:
shutil.rmtree(cible)
raise ValueError("Le modèle produit ne valide pas (annulé) : " + " ; ".join(erreurs))
return cible
def main() -> int:
p = argparse.ArgumentParser(description="Crée un modèle Set-OPS (dépôt privé).")
p.add_argument("--mode", choices=("base", "instance"), required=True)
p.add_argument("--nom", required=True, help="Nom du nouveau modèle.")
p.add_argument("--base", help="Mode base : modèle de départ (déjà générique).")
p.add_argument("--source", help="Mode instance : dossier d'instance à promouvoir (ex. OPS-Chezlepro).")
p.add_argument("--dest", help="Dépôt de modèles cible (défaut : ../Set-OPS-Modeles).")
args = p.parse_args()
try:
cible = creer(args.mode, args.nom, args.base, args.source, args.dest)
except (ValueError, OSError) as exc:
print(f"erreur: {exc}", file=sys.stderr)
return 2
try:
rel = cible.relative_to(FRERES)
except ValueError:
rel = cible
print(f"Modèle créé et validé : {rel}")
print("Prochaines étapes : éditer le plan si besoin (GUI ou plan/*.yml), commiter dans le dépôt PRIVÉ.")
if args.mode == "instance":
print("Généralisé : identité → exemple.*, index → 1, production → false, voûte retirée (gabarit conservé).")
return 0
if __name__ == "__main__":
raise SystemExit(main())