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
|
|
|
#!/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:.*$',
|
gabarit : modeleChezlepro -> modeleSetOPS, et une porte pour l'hebergeur
Le gabarit portait le nom du mauvais proprietaire. Les TROIS instances
(Chezlepro, Technolibre, lab) le declaraient et pointaient deja toutes sur le
MEME VMID 99998 — alors que le commentaire affirmait « chaque tenant a SON
golden template ». Faux depuis longtemps, et invisible en lisant un seul fichier.
Renomme sur le cluster et dans les trois instances. Le gabarit est un artefact du
MOTEUR, pas d'un tenant ; le nom d'un tenant sur le gabarit d'un autre etait un
piege qui n'attendait qu'un troisieme hebergeur. model_creer.py et
config_proxmox.py proposaient encore modele-debian13 : alignes.
Sans risque : le clonage se fait par VMID depuis le 2026-08-10, le nom ne sert
plus qu'a l'affichage.
NOUVELLE PORTE dans l'aiguillage : docs/preparer-un-site-hebergeur.md, pour
quelqu'un qui prete son materiel sans rien connaitre de Set-OPS. Ecrit a partir
du depot — VLAN et MTU d'underlay.yml, bloc par tenant de
inventory_rules.supernet_de(), privileges du jeton de config-proxmox.md,
dimensionnement (~460 Go, ~37 Go de RAM) mesure sur la flotte vivante.
Deux avertissements y figurent parce qu'ils ont deja coute cher ici : un gabarit
personnalise recopie son identite dans chaque clone, et un blocage contourne en
silence se paie en heures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:36:44 -04:00
|
|
|
'proxmox_clone_source_nom: "modeleSetOPS"')
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
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())
|