From d8474d1c9fa00d03ef617d92f1b0e04e69ee5905 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 23 Jul 2026 14:13:36 -0400 Subject: [PATCH] =?UTF-8?q?Cr=C3=A9er=20une=20instance=20depuis=20un=20mod?= =?UTF-8?q?=C3=A8le=20(CLI=20+=20GUI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ../, 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 --- CHANGELOG.md | 29 ++++++++ Makefile | 10 +++ docs/audit/preuve-2026-07-23.md | 6 +- scripts/instance_creer.py | 120 ++++++++++++++++++++++++++++++++ scripts/inventory_gui.py | 43 +++++++++++- 5 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 scripts/instance_creer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b7069..f18224a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `../`, 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) diff --git a/Makefile b/Makefile index 5fc1113..9d914e7 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/audit/preuve-2026-07-23.md b/docs/audit/preuve-2026-07-23.md index b9c2525..de98a64 100644 --- a/docs/audit/preuve-2026-07-23.md +++ b/docs/audit/preuve-2026-07-23.md @@ -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. | diff --git a/scripts/instance_creer.py b/scripts/instance_creer.py new file mode 100644 index 0000000..e7fb7b7 --- /dev/null +++ b/scripts/instance_creer.py @@ -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-`) 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 ../ depuis . 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()) diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index dc35f89..25e9af5 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -1283,7 +1283,7 @@ HTML = r""" } let devisReseauTexte = ''; - function tableauFlotte(inst, coll) { + function tableauFlotte(inst, coll, fmodeles, findexPris) { if (!inst.length) return '
Aucune instance découverte (dépôts frères avec plan/nomenclature.yml).
'; const banniere = Object.keys(coll || {}).length ? `
⚠ 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.
` @@ -1302,8 +1302,35 @@ HTML = r""" ${fed} ${prod} ${action}`; }).join(''); + const optModeles = (fmodeles || []).map(m => ``).join(''); + const creer = (fmodeles || []).length ? ` +
Créer une instance depuis un modèle
+
+ + + + +
+
Copie le modèle vers ../<nom> et fixe l'index. Ne bascule pas : reste à renseigner intrants + voûte, puis « Activer ».${(findexPris||[]).length ? ' Index déjà pris : ' + (findexPris||[]).join(', ') + '.' : ''}
` : ''; return `${banniere}${lignes}
InstanceIndexVLANStatutActive
-
★ = active (symlink instance). « Activer » bascule toutes les vues et les déploiements vers cette instance.
`; +
★ = active (symlink instance). « Activer » bascule toutes les vues et les déploiements vers cette instance.
+ ${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""" const di = await ri.json(), data = await rd.json(); if (!rd.ok) { cible.innerHTML = '
' + echapper(data.erreur || 'Indisponible.') + '
'; return; } devisReseauTexte = data.config || ''; - const flotte = `
Flotte d'instances ${(di.instances || []).length}
${tableauFlotte(di.instances || [], di.collisions || {})}
`; + const flotte = `
Flotte d'instances ${(di.instances || []).length}
${tableauFlotte(di.instances || [], di.collisions || {}, di.modeles || [], di.index_pris || [])}
`; const devis = devisReseauTexte ? `
Devis config réseau — switches (instances fédérées)
@@ -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.