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>
This commit is contained in:
Daniel Allaire 2026-07-23 14:43:06 -04:00
parent d8474d1c9f
commit 4180913c25
3 changed files with 266 additions and 0 deletions

View file

@ -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=<modele> NOM=<x>`** — copie un modèle déjà générique (copier + éditer).
- **`MODE=instance SOURCE=OPS-<x> NOM=<y>`** — **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)

View file

@ -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

235
scripts/model_creer.py Normal file
View file

@ -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())