From 4180913c25b117544bc301f8e8adb50ae559e1e6 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 23 Jul 2026 14:43:06 -0400 Subject: [PATCH] =?UTF-8?q?Cr=C3=A9er=20un=20mod=C3=A8le=20(make=20model-c?= =?UTF-8?q?reer)=20=E2=80=94=20base=20+=20promotion=20d'instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 24 +++++ Makefile | 7 ++ scripts/model_creer.py | 235 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 scripts/model_creer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f18224a..675506b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # CHANGELOG — Set-OPS +## 2026-07-23 (suite 4) + +### Ajouté — créer un MODÈLE (`make model-creer`, dépôt privé) +Symétrique de `instance-creer`, mais produit un modèle réutilisable dans le dépôt PRIVÉ +(`Set-OPS-Modeles/`, jamais `exemples/modeles/`). `scripts/model_creer.py`, deux modes : + +- **`MODE=base BASE= NOM=`** — copie un modèle déjà générique (copier + éditer). +- **`MODE=instance SOURCE=OPS- NOM=`** — **promeut une instance éprouvée en modèle** : + généralise l'identité (`domaine → exemple.internal`, organisation → `Exemple`, realm → + `exemple`), fixe `index → 1`, `setops_production → false`, `nftables_admin_ssh → []`, vide + la clé publique de sauvegarde, générique `proxmox.yml` (host/nœud/stockage/VMID vidés, + golden template → `modele-debian13`). + +Sûreté : **aucun secret** ne sort (`vault.yml`/`proxmox.vault.yml` jamais copiés ; refus si +l'un subsiste ; le `.example` est conservé). Copie **ciblée** (plan/ + inventories/ seulement, +symlinks résolus) — robuste aux dépôts imbriqués et boucles de symlinks. Le modèle produit +**doit valider** (`modeles.py verifier`), sinon il est annulé. `make model-creer`. + +Validé : les deux modes testés en isolement (destination tmp, dépôt privé jamais touché) — +promotion du labo (cohérent) réussie + validée, aucun `chezlepro` résiduel, aucun secret, +`proxmox.yml` génériqué ; copie base (socle) OK ; refus d'une instance INVALIDE (le garde-fou +a détecté un hôte fantôme dans une instance en cours d'édition). `node --check`, `make verifier` +rc=0 CONFORME 21/21. + ## 2026-07-23 (suite 3) ### Ajouté — créer une instance depuis un modèle (CLI + GUI) diff --git a/Makefile b/Makefile index 9d914e7..2a90cb7 100644 --- a/Makefile +++ b/Makefile @@ -251,6 +251,13 @@ instance-creer: @python3 scripts/instance_creer.py --nom "$(NOM)" --modele "$(MODELE)" \ $(if $(INDEX),--index $(INDEX),) +# Crée un MODÈLE (dépôt privé). Deux modes : +# base : copier un modèle générique -> make model-creer MODE=base BASE=identite NOM=maison-obnl +# instance : promouvoir une instance -> make model-creer MODE=instance SOURCE=OPS-Chezlepro NOM=cabinet +model-creer: + @python3 scripts/model_creer.py --mode "$(MODE)" --nom "$(NOM)" \ + $(if $(BASE),--base $(BASE),) $(if $(SOURCE),--source $(SOURCE),) $(if $(DEST),--dest $(DEST),) + config: python3 scripts/config_proxmox.py diff --git a/scripts/model_creer.py b/scripts/model_creer.py new file mode 100644 index 0000000..a08ea4c --- /dev/null +++ b/scripts/model_creer.py @@ -0,0 +1,235 @@ +#!/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())