GUI : bascule d'instance (vraiment multi-instance)

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-23 12:12:29 -04:00
parent dbc78f69ec
commit 1ad7b48483
3 changed files with 84 additions and 6 deletions

View file

@ -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

View file

@ -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', <nom>)` | — |
| 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),

View file

@ -1292,14 +1292,33 @@ HTML = r"""<!doctype html>
const vlan = i.vlans ? (i.vlans[0] + '-' + i.vlans[1]) : '';
const fed = i.federe ? '<span class="chip">fédérée</span>' : '<span class="chip" style="opacity:.6">local</span>';
const prod = i.production ? '<span class="chip chi-tls-requis">prod</span>' : '';
const action = i.actif
? '<span class="chip chi-tls-requis">active</span>'
: `<button type="button" class="fantome" onclick="basculerInstance('${echapper(i.nom)}', ${i.production ? 'true' : 'false'})">Activer</button>`;
return `<tr>
<td class="mono">${i.actif ? '' : ''}${echapper(i.nom)}</td>
<td class="mono">${i.index}</td>
<td class="mono">${vlan}</td>
<td>${fed} ${prod}</td></tr>`;
<td>${fed} ${prod}</td>
<td>${action}</td></tr>`;
}).join('');
return `${banniere}<table class="tbl-lecture"><thead><tr><th>Instance</th><th>Index</th><th>VLAN</th><th>Statut</th></tr></thead><tbody>${lignes}</tbody></table>
<div class="hint-dep" style="margin-top:6px"> = active (symlink <code>instance</code>). Basculer en CLI : <code>make instance-utiliser NOM=&lt;dépôt&gt;</code>.</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>`;
}
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"""<!doctype html>
"""
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}")