Set-OPS-Public/scripts/model_creer.py
Daniel Allaire 4180913c25 Créer un modèle (make model-creer) — base + promotion d'instance
Symétrique d'instance-creer, produit un modèle dans le dépôt PRIVÉ
(Set-OPS-Modeles/, jamais exemples/modeles/). scripts/model_creer.py, 2 modes :
- MODE=base : copie un modèle générique.
- MODE=instance : PROMEUT une instance en modèle (généralise identité ->
  exemple.*, index -> 1, production -> false, nftables_admin_ssh -> [], cle de
  sauvegarde videe, proxmox.yml generique [D1], inventaire principal [D2]).

Surete : aucun secret ne sort (vault.yml/proxmox.vault.yml jamais copies ; refus
si l'un subsiste ; .example conserve). Copie CIBLEE (plan/ + inventories/ seulement,
symlinks resolus) -> robuste aux depots imbriques et boucles de symlinks. Le modele
produit doit valider (modeles.py verifier), sinon annule.

Teste en isolement (depot prive jamais touche) : promotion du labo reussie +
validee (aucun chezlepro residuel, aucun secret, proxmox generique) ; copie socle
OK ; refus d'une instance INVALIDE (garde-fou : hote fantome detecte).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:43:06 -04:00

235 lines
9.8 KiB
Python

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