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:
parent
d8474d1c9f
commit
4180913c25
3 changed files with 266 additions and 0 deletions
24
CHANGELOG.md
24
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=<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)
|
||||
|
|
|
|||
7
Makefile
7
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
|
||||
|
||||
|
|
|
|||
235
scripts/model_creer.py
Normal file
235
scripts/model_creer.py
Normal 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())
|
||||
Loading…
Reference in a new issue