#!/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())