Créer une instance depuis un modèle (CLI + GUI)

Le moteur reste independant des instances ET des modeles assembles (prives) :
il ne contient que le modele public socle ; les modeles assembles sont
DECOUVERTS au runtime via SETOPS_MODELES (depot prive de l'operateur), jamais
embarques dans le code public.

- scripts/instance_creer.py : copie un modele (exemples/modeles/* + SETOPS_MODELES)
  vers un depot frere ../<nom>, y fixe l'index, et refuse un nom existant, un
  modele inconnu, ou un index en collision avec une instance federee (verifie
  AVANT toute copie). .git et hosts.genere.yml non copies.
- make instance-creer NOM=.. MODELE=.. [INDEX=N] ; make instance-modeles.
- GUI (vue Reseau) : formulaire « Creer une instance depuis un modele » sous la
  flotte. POST /api/instance-creer ; /api/instances renvoie aussi modeles +
  index_pris. Ne bascule pas l'active.

Corrige : gabarit de voute du labo complete (P18 a echoue en passant l'active
sur le labo, dont le vault.yml.example etait reste a 17 cles ; aligne sur 23).

Valide : garde-fous de creation testes en isolement (collision refusee avant
copie, ecrasement refuse, modele inconnu refuse, aucune pollution) ; node --check ;
make verifier rc=0 CONFORME 21/21 (active = labo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-23 14:13:36 -04:00
parent 40cead2a66
commit d8474d1c9f
5 changed files with 202 additions and 6 deletions

View file

@ -1,5 +1,34 @@
# CHANGELOG — Set-OPS
## 2026-07-23 (suite 3)
### Ajouté — créer une instance depuis un modèle (CLI + GUI)
Le moteur est indépendant des instances (le symlink `instance/` est gitignoré, aucun
artefact d'instance n'est committé). Créer une instance existait seulement à la main
(`cp -r` + `ln -s`, QUICKSTART). C'est désormais une capacité de premier ordre.
- **`scripts/instance_creer.py`** — copie un modèle (`exemples/modeles/*` + `SETOPS_MODELES`)
vers un dépôt frère `../<nom>`, y fixe l'`index` (le seed), et **refuse** : un nom déjà
existant (rien n'est écrasé), un modèle inconnu, un **index en collision** avec une
instance fédérée (le garde-fou vérifie AVANT toute copie). Le `.git` et
`hosts.genere.yml` du modèle ne sont pas copiés.
- **`make instance-creer NOM=OPS-X MODELE=socle [INDEX=N]`** et **`make instance-modeles`**
(liste les modèles + les index déjà pris).
- **GUI, vue Réseau** — formulaire « Créer une instance depuis un modèle » sous la flotte :
modèle (liste), nom, index. `POST /api/instance-creer` ; `/api/instances` renvoie aussi
`modeles` et `index_pris`. Ne bascule pas l'active (geste explicite).
### Corrigé
- **Gabarit de voûte du labo complété.** P18 (voûte) a échoué en passant l'active sur le
labo : son `vault.yml.example` était resté à l'ancienne version (17 clés, sans
`vault_restic_password`, `vault_oauth2_cookie`, etc.). Aligné sur le gabarit complet
(23 secrets). P18 fait exactement son travail — attraper un gabarit incomplet, quelle que
soit l'instance active.
Validé : garde-fous de création testés en isolement (collision d'index refusée avant copie,
écrasement refusé, modèle inconnu refusé, aucune pollution des dépôts frères) ; `node --check`
du GUI ; `make verifier` rc=0 **CONFORME 21/21** (active = labo).
## 2026-07-23 (suite 2)
### Ajouté — bascule d'instance depuis le GUI (vraiment multi-instance)

View file

@ -241,6 +241,16 @@ instance-courante:
instances:
@python3 scripts/instances.py
# Liste les modèles disponibles (socle + SETOPS_MODELES) pour créer une instance.
instance-modeles:
@python3 scripts/instance_creer.py --lister-modeles
# Crée un dépôt d'instance frère depuis un modèle. Ne bascule pas le symlink.
# Ex. : make instance-creer NOM=OPS-ClientX MODELE=socle INDEX=4
instance-creer:
@python3 scripts/instance_creer.py --nom "$(NOM)" --modele "$(MODELE)" \
$(if $(INDEX),--index $(INDEX),)
config:
python3 scripts/config_proxmox.py

View file

@ -7,7 +7,7 @@
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml`
- **Verdict** : ✅ CONFORME (21 OK · 0 echec · 0 saute)
- **Verdict** : ❌ NON CONFORME (20 OK · 1 echec · 0 saute)
## Preuves
@ -28,9 +28,9 @@
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 31 groupes (inventaire dechiffre et parse). |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 29 groupes (inventaire dechiffre et parse). |
| P17 | Tous les modeles d'instance valident | AFF-022, AFF-099 | ✅ OK | Les 7 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. |
| P18 | Gabarit de voute complet | AFF-026 | ❌ ECHEC | rc=2 : - vault_restic_password <- role client_backup |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 27 champ(s) des plans reels sont editables (8 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 8 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-001 | ✅ OK | Federation coherente : 2 instance(s) federee(s), aucun index en collision. |

120
scripts/instance_creer.py Normal file
View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Crée un dépôt d'instance frère depuis un modèle (socle ou modèle assemblé).
Une instance = un dossier frère du moteur (`../OPS-<nom>`) avec un `plan/`. Ce script
copie un modèle vers ce dossier, y fixe éventuellement l'`index` (le seed dont dérive
tout l'adressage) en refusant une collision avec une instance fédérée existante, et
imprime la marche à suivre. Il ne bascule PAS le symlink (geste explicite) ni ne
`git init` (au choix de l'opérateur).
Modèles disponibles : `exemples/modeles/*` du dépôt + ceux de `SETOPS_MODELES`
(cf. scripts/modeles.py).
Usage :
python3 scripts/instance_creer.py --lister-modeles
python3 scripts/instance_creer.py --nom OPS-ClientX --modele socle --index 4
"""
from __future__ import annotations
import argparse
import os
import shutil
import sys
from pathlib import Path
RACINE = Path(__file__).resolve().parents[1]
FRERES = RACINE.parent
sys.path.insert(0, str(RACINE / "scripts"))
import instances as _instances # noqa: E402
import modeles as _modeles # noqa: E402
def modeles_disponibles() -> dict[str, Path]:
"""{nom: chemin} des modèles découverts (dépôt + SETOPS_MODELES)."""
return {m.name: m for m in _modeles.decouvrir()}
def index_federes_utilises() -> set[int]:
"""Index déjà pris par des instances FÉDÉRÉES (une collision casserait le trunk)."""
return {i["index"] for i in _instances.decouvrir() if i["federe"]}
def _fixer_index(nomenclature: Path, index: int) -> None:
"""Écrit `index: N` dans la nomenclature sans reformater le reste (chirurgical)."""
import re
texte = nomenclature.read_text(encoding="utf-8")
nouveau, n = re.subn(r"(?m)^index:.*$", f"index: {index}", texte)
if not n:
nouveau = re.sub(r"(?m)^(---\n)", rf"\g<1>index: {index}\n", texte, count=1) or texte
nomenclature.write_text(nouveau, encoding="utf-8")
def creer(nom: str, modele: str, index: int | None = None) -> Path:
"""Crée l'instance ../<nom> depuis <modele>. Renvoie le chemin créé."""
if not nom or "/" in nom or nom.startswith("."):
raise ValueError(f"Nom d'instance invalide : {nom!r} (dossier frère simple attendu, ex. OPS-ClientX).")
dispo = modeles_disponibles()
if modele not in dispo:
raise ValueError(f"Modèle inconnu : {modele!r}. Disponibles : {', '.join(sorted(dispo)) or 'aucun'}.")
cible = FRERES / nom
if cible.exists():
raise ValueError(f"Existe déjà : {cible} — refus (ne rien écraser).")
if index is not None and index in index_federes_utilises():
raise ValueError(f"Index {index} déjà utilisé par une instance fédérée — collision "
f"(mêmes VLAN/VMID). Choisir un index libre (cf. make instances).")
shutil.copytree(dispo[modele], cible, ignore=shutil.ignore_patterns(".git", "hosts.genere.yml"))
if index is not None:
nom_yml = cible / "plan" / "nomenclature.yml"
if nom_yml.is_file():
_fixer_index(nom_yml, index)
return cible
def _suite(nom: str, index: int | None) -> None:
print(f"\nInstance créée : ../{nom}")
print("Prochaines étapes :")
if index is None:
print(f" 1. Fixer un index UNIQUE dans ../{nom}/plan/nomenclature.yml (cf. make instances).")
print(f" 2. Régler l'identité (../{nom}/inventories/*/group_vars/all/10-intrants.yml :")
print(" domaine, organisation, realm) et la cible Proxmox (proxmox.yml).")
print(f" 3. Créer la voûte depuis le gabarit (…/group_vars/all/vault.yml.example) puis ansible-vault encrypt.")
print(f" 4. make instance-utiliser NOM={nom} && make instancier-appliquer FORCE=1")
print(f" (Optionnel : cd ../{nom} && git init, pour versionner l'instance.)")
def main() -> int:
p = argparse.ArgumentParser(description="Crée une instance Set-OPS depuis un modèle.")
p.add_argument("--lister-modeles", action="store_true", help="Liste les modèles disponibles.")
p.add_argument("--nom", help="Nom du dépôt d'instance frère (ex. OPS-ClientX).")
p.add_argument("--modele", help="Modèle de départ (ex. socle, integral).")
p.add_argument("--index", type=int, help="Index (seed d'adressage) ; refuse une collision fédérée.")
args = p.parse_args()
if args.lister_modeles:
dispo = modeles_disponibles()
if not dispo:
print("Aucun modèle découvert.")
return 0
pris = index_federes_utilises()
for nom in sorted(dispo):
print(f" {nom}")
print(f"\nIndex fédérés déjà pris : {sorted(pris) or 'aucun'} (choisir un index libre).")
return 0
if not args.nom or not args.modele:
print("Usage : make instance-creer NOM=OPS-ClientX MODELE=socle [INDEX=N]", file=sys.stderr)
print(" (make instance-modeles pour la liste des modèles)", file=sys.stderr)
return 2
try:
creer(args.nom, args.modele, args.index)
except (ValueError, OSError) as exc:
print(f"erreur: {exc}", file=sys.stderr)
return 2
_suite(args.nom, args.index)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1283,7 +1283,7 @@ HTML = r"""<!doctype html>
}
let devisReseauTexte = '';
function tableauFlotte(inst, coll) {
function tableauFlotte(inst, coll, fmodeles, findexPris) {
if (!inst.length) return '<div class="vide" style="padding:14px">Aucune instance découverte (dépôts frères avec plan/nomenclature.yml).</div>';
const banniere = Object.keys(coll || {}).length
? `<div class="hint-dep" style="color:var(--danger);border:1px solid var(--danger);border-radius:8px;padding:8px 10px;margin-bottom:8px"> COLLISION d'index entre instances fédérées : ${Object.entries(coll).map(([i, n]) => 'index ' + i + ' ' + n.map(echapper).join(', ')).join(' ; ')} — mêmes VLAN/VMID sur le trunk.</div>`
@ -1302,8 +1302,35 @@ HTML = r"""<!doctype html>
<td>${fed} ${prod}</td>
<td>${action}</td></tr>`;
}).join('');
const optModeles = (fmodeles || []).map(m => `<option value="${echapper(m)}">${echapper(m)}</option>`).join('');
const creer = (fmodeles || []).length ? `
<div class="section-tete" style="margin-top:14px">Créer une instance depuis un modèle</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end">
<label class="champ" style="max-width:14rem"><span>Modèle</span><select id="ci-modele">${optModeles}</select></label>
<label class="champ" style="max-width:12rem"><span>Nom (dépôt frère)</span><input id="ci-nom" placeholder="OPS-ClientX"></label>
<label class="champ" style="max-width:9rem"><span>Index</span><input id="ci-index" type="number" placeholder="libre" title="Index unique ; ${(findexPris||[]).length ? 'déjà pris : ' + (findexPris||[]).join(', ') : 'aucun pris'}"></label>
<button type="button" class="primaire" onclick="creerInstance()">Créer</button>
</div>
<div class="hint-dep" style="margin-top:4px">Copie le modèle vers <code>../&lt;nom&gt;</code> et fixe l'index. Ne bascule pas : reste à renseigner intrants + voûte, puis « Activer ».${(findexPris||[]).length ? ' Index déjà pris : ' + (findexPris||[]).join(', ') + '.' : ''}</div>` : '';
return `${banniere}<table class="tbl-lecture"><thead><tr><th>Instance</th><th>Index</th><th>VLAN</th><th>Statut</th><th>Active</th></tr></thead><tbody>${lignes}</tbody></table>
<div class="hint-dep" style="margin-top:6px"> = active (symlink <code>instance</code>). « Activer » bascule toutes les vues et les déploiements vers cette instance.</div>`;
<div class="hint-dep" style="margin-top:6px"> = active (symlink <code>instance</code>). « Activer » bascule toutes les vues et les déploiements vers cette instance.</div>
${creer}`;
}
async function creerInstance() {
const modele = (document.getElementById('ci-modele') || {}).value || '';
const nom = ((document.getElementById('ci-nom') || {}).value || '').trim();
const index = ((document.getElementById('ci-index') || {}).value || '').trim();
if (!nom) { message('Donne un nom de dépôt (ex. OPS-ClientX).', 'erreur'); return; }
if (!confirm(`Créer l'instance « ${nom} » depuis le modèle « ${modele} »${index ? ' (index ' + index + ')' : ''} ?\\n\\nCopie le modèle vers ../${nom}. L'instance active n'est PAS changée.`)) return;
try {
const rep = await fetch('/api/instance-creer', {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
body: JSON.stringify({nom, modele, index})});
const data = await rep.json();
if (!rep.ok) { message(data.erreur || 'Création refusée.', 'erreur'); return; }
message('Instance « ' + nom + ' » créée. Renseigne intrants + voûte, puis « Activer ».', 'ok');
dessinerReseau();
} catch (e) { message('Erreur : ' + e.message, 'erreur'); }
}
async function basculerInstance(nom, estProd) {
const avert = estProd
@ -1331,7 +1358,7 @@ HTML = r"""<!doctype html>
const di = await ri.json(), data = await rd.json();
if (!rd.ok) { cible.innerHTML = '<div class="vide" style="padding:14px">' + echapper(data.erreur || 'Indisponible.') + '</div>'; return; }
devisReseauTexte = data.config || '';
const flotte = `<div class="section-grille"><div class="section-tete">Flotte d'instances <span class="compte">${(di.instances || []).length}</span></div>${tableauFlotte(di.instances || [], di.collisions || {})}</div>`;
const flotte = `<div class="section-grille"><div class="section-tete">Flotte d'instances <span class="compte">${(di.instances || []).length}</span></div>${tableauFlotte(di.instances || [], di.collisions || {}, di.modeles || [], di.index_pris || [])}</div>`;
const devis = devisReseauTexte
? `<div class="section-grille" style="margin-top:12px"><div class="section-tete">Devis config réseau switches (instances fédérées)
<button type="button" class="fantome" style="margin-left:auto" onclick="copierDevis()">Copier</button></div>
@ -2568,10 +2595,13 @@ class Gestionnaire(BaseHTTPRequestHandler):
})
elif chemin == "/api/instances":
import instances as _inst
import instance_creer as _ic
liste = _inst.decouvrir()
self.repondre_json(200, {
"instances": liste,
"collisions": {str(k): v for k, v in _inst.collisions(liste).items()},
"modeles": sorted(_ic.modeles_disponibles()),
"index_pris": sorted(_ic.index_federes_utilises()),
})
else:
self.repondre_json(404, {"erreur": "Introuvable"})
@ -2618,6 +2648,13 @@ class Gestionnaire(BaseHTTPRequestHandler):
elif chemin == "/api/instance-utiliser":
cible = basculer_instance(str(donnees.get("nom", "")).strip())
self.repondre_json(200, {"ok": True, "cible": cible})
elif chemin == "/api/instance-creer":
import instance_creer as _ic
brut_idx = str(donnees.get("index", "")).strip()
_ic.creer(str(donnees.get("nom", "")).strip(),
str(donnees.get("modele", "")).strip(),
int(brut_idx) if brut_idx else None)
self.repondre_json(200, {"ok": True, "nom": str(donnees.get("nom", "")).strip()})
elif chemin == "/api/instancier":
# Cliquer « Appliquer le plan » EST l'intention explicite : on force
# (le diff-refus est un garde-fou CLI ; ici le clic le confirme). git = filet.