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>
235 lines
9.8 KiB
Python
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())
|