From 1ad7b484830a919099c5be07e2bd966a798a769a Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Thu, 23 Jul 2026 12:12:29 -0400 Subject: [PATCH] GUI : bascule d'instance (vraiment multi-instance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demande explicite et repetee de l'operateur : gerer les instances DEPUIS le GUI. - Inventaire resolu DYNAMIQUEMENT : le serveur figeait l'inventaire au demarrage (args.inventaire.resolve()). Il est desormais relu depuis le symlink instance/ a chaque requete (propriete Gestionnaire.inventaire) -> la bascule prend effet sans redemarrer. Les FICHIER_* de plan suivaient deja le lien. SETOPS_INVENTAIRE force encore un inventaire fixe (CI). - POST /api/instance-utiliser + basculer_instance(nom) : repointe le symlink avec les garde-fous de make instance-utiliser + validation STRICTE (nom doit etre une instance decouverte -> pas de traversee de chemin ; ../etc refuse, teste). - Vue Reseau : bouton « Activer » par instance dans la table de flotte. Confirme (avertissement renforce si production), bascule, recharge -> toutes les vues et les deploiements visent la nouvelle instance. L'edition de federe reste au CLI (rarement change) ; la bascule est CLI ET GUI. docs/carte-set-ops.md : la ligne Multi-instance du tableau des mecanismes documente la decouverte (glob des dossiers freres, aucun registre) et le garde-fou P21. Valide : node --check ; bascule testee de bout en bout (repoint -> inventaire dynamique suit -> traversee refusee -> restauration) ; make verifier rc=0 CONFORME 21/21. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 26 +++++++++++++++++ docs/carte-set-ops.md | 2 +- scripts/inventory_gui.py | 62 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e627ccb..30b7069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # CHANGELOG — Set-OPS +## 2026-07-23 (suite 2) + +### Ajouté — bascule d'instance depuis le GUI (vraiment multi-instance) +Demande explicite et répétée de l'opérateur : gérer les instances **depuis le GUI**, pas +seulement en CLI. Le plan de contrôle reste maison, mais cette capacité y entre. + +- **Inventaire résolu dynamiquement.** Le serveur GUI figeait l'inventaire au démarrage + (`args.inventaire.resolve()`). Il est désormais relu **à chaque requête** depuis le + symlink `instance/` (propriété `Gestionnaire.inventaire`) — la bascule prend effet sans + redémarrer. Les chemins de plan (`FICHIER_*`) étaient déjà relatifs au symlink et suivent + de même. `SETOPS_INVENTAIRE` force encore un inventaire fixe (CI). +- **`POST /api/instance-utiliser`** + `basculer_instance(nom)` — repointe le symlink avec + les garde-fous de `make instance-utiliser`, plus une **validation stricte** : `nom` doit + être une instance **découverte** (dossier frère), ce qui interdit toute traversée de + chemin (testé : `../etc` refusé). +- **GUI, vue Réseau** — la table de flotte gagne un bouton **« Activer »** par instance + (l'active affiche « active »). Il confirme (avec avertissement renforcé si l'instance est + en **production**), bascule, puis recharge la page : toutes les vues et les déploiements + visent la nouvelle instance. + +L'édition du drapeau `federe` reste au CLI (rarement changé). La bascule, elle, est +maintenant CLI **et** GUI. + +Validé : `node --check` du GUI ; bascule testée de bout en bout (repoint → l'inventaire +dynamique suit → traversée refusée → restauration) ; `make verifier` rc=0 CONFORME 21/21. + ## 2026-07-23 (suite) ### Ajouté — gestion multi-instances : vue d'ensemble + garde-fou de collision diff --git a/docs/carte-set-ops.md b/docs/carte-set-ops.md index 67b9bd0..cb3cc48 100644 --- a/docs/carte-set-ops.md +++ b/docs/carte-set-ops.md @@ -34,7 +34,7 @@ Ce que je re-découvre sinon. **Consulter avant de concevoir un nouveau mécanis | Ordonnancement socle-first | socle/durci avant les `client_*` | `serveur_debian`/`serveur_durci` d'abord (posent `/etc/hosts` via `hosts_statiques`) | — | | Sûreté check-mode | dry-run fiable | `when: not ansible_check_mode` sur les tâches de service + handlers | — | | Voûte au déploiement | secret jamais en clair | `ANSIBLE_VAULT_PASSWORD_FILE` / `~/.config/setops-vault-pass` ; déréférencé par `lookup('vars', )` | — | -| Multi-instance | un dépôt par écosystème | symlink `instance/` | `multi-instances.md` | +| Multi-instance | un dépôt par écosystème ; l'active = symlink `instance/`, les autres **découvertes par convention** (dossiers frères, aucun registre) | active : symlink `instance/` ; découverte : `scripts/instances.py` / `devis_reseau.py` (glob `../*/plan/nomenclature.yml` avec `index`) ; garde-fou collision : preuve **P21** | `multi-instances.md` | | Exposition → edge | app expose un FQDN public servi par un edge | `plan/domaines.yml` + `expose` (applications) | `bindings-conception.md` §4 | > ⚠️ **Deux directions de binding, assumées** : `app→app` côté app (instancier), diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 034449b..dc35f89 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -1292,14 +1292,33 @@ HTML = r""" const vlan = i.vlans ? (i.vlans[0] + '-' + i.vlans[1]) : '—'; const fed = i.federe ? 'fédérée' : 'local'; const prod = i.production ? 'prod' : ''; + const action = i.actif + ? 'active' + : ``; return ` ${i.actif ? '★ ' : ''}${echapper(i.nom)} ${i.index} ${vlan} - ${fed} ${prod}`; + ${fed} ${prod} + ${action}`; }).join(''); - return `${banniere}${lignes}
InstanceIndexVLANStatut
-
★ = active (symlink instance). Basculer en CLI : make instance-utiliser NOM=<dépôt>.
`; + return `${banniere}${lignes}
InstanceIndexVLANStatutActive
+
★ = active (symlink instance). « Activer » bascule toutes les vues et les déploiements vers cette instance.
`; + } + async function basculerInstance(nom, estProd) { + const avert = estProd + ? `\\n\\n⚠ « ${nom} » est une instance de PRODUCTION. Déployer y touchera l'infra réelle.` + : ''; + if (!confirm(`Basculer l'instance active vers « ${nom} » ?\\n\\nToutes les vues et les déploiements viseront alors « ${nom} ».${avert}`)) return; + try { + const rep = await fetch('/api/instance-utiliser', { + method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, + body: JSON.stringify({nom})}); + const data = await rep.json(); + if (!rep.ok) { message(data.erreur || 'Bascule refusée.', 'erreur'); return; } + message('Instance active : ' + nom + ' — rechargement…', 'ok'); + setTimeout(() => location.reload(), 500); + } catch (e) { message('Erreur : ' + e.message, 'erreur'); } } async function dessinerReseau() { const cible = document.getElementById('grilles'); @@ -2375,8 +2394,39 @@ HTML = r""" """ +def basculer_instance(nom: str) -> str: + """Repointe le symlink `instance/` vers un depot frere. Renvoie la nouvelle cible. + + Memes garde-fous que `make instance-utiliser`, plus une validation STRICTE : `nom` + doit etre une instance DECOUVERTE (dossier frere avec plan/nomenclature.yml), ce + qui interdit toute traversee de chemin (pas de '/', '..', cible arbitraire). + """ + import instances as _inst + connues = {i["nom"] for i in _inst.decouvrir()} + if nom not in connues: + raise ValueError(f"Instance inconnue : {nom!r} (attendu : {', '.join(sorted(connues)) or 'aucune'}).") + cible = RACINE.parent / nom + if not cible.is_dir(): + raise ValueError(f"Dossier introuvable : ../{nom}") + lien = RACINE / "instance" + if lien.exists() and not lien.is_symlink(): + raise ValueError("'instance' existe et n'est pas un symlink — refus.") + if lien.is_symlink() or lien.exists(): + lien.unlink() + lien.symlink_to(f"../{nom}") + return os.readlink(lien) + + class Gestionnaire(BaseHTTPRequestHandler): - inventaire: Path = INVENTAIRE_DEFAUT + # Inventaire de l'instance ACTIVE, resolu DYNAMIQUEMENT depuis le symlink + # `instance/` a chaque acces : ainsi la bascule d'instance (POST + # /api/instance-utiliser) prend effet sans redemarrer le serveur. Les chemins + # de plan (FICHIER_*) sont deja relatifs au symlink et suivent de meme. + # SETOPS_INVENTAIRE force un inventaire fixe (CI / usage explicite). + @property + def inventaire(self) -> Path: + force = os.environ.get("SETOPS_INVENTAIRE") + return Path(force) if force else _inventaire(INSTANCE, "principal", "production") def log_message(self, *args) -> None: # silence par defaut pass @@ -2565,6 +2615,9 @@ class Gestionnaire(BaseHTTPRequestHandler): elif chemin == "/api/intrants": ecrire_intrants(donnees) self.repondre_json(200, inventaire_api(self.inventaire)) + elif chemin == "/api/instance-utiliser": + cible = basculer_instance(str(donnees.get("nom", "")).strip()) + self.repondre_json(200, {"ok": True, "cible": cible}) 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. @@ -2594,7 +2647,6 @@ def main() -> int: parser.add_argument("--port", type=int, default=8765) args = parser.parse_args() - Gestionnaire.inventaire = args.inventaire.resolve() serveur = ThreadingHTTPServer((args.hote, args.port), Gestionnaire) print(f"Interface inventaire: http://{args.hote}:{args.port}/") print(f"Inventaire: {Gestionnaire.inventaire}")