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:
parent
dbc78f69ec
commit
1ad7b48483
3 changed files with 84 additions and 6 deletions
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -1,5 +1,31 @@
|
||||||
# CHANGELOG — Set-OPS
|
# 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)
|
## 2026-07-23 (suite)
|
||||||
|
|
||||||
### Ajouté — gestion multi-instances : vue d'ensemble + garde-fou de collision
|
### Ajouté — gestion multi-instances : vue d'ensemble + garde-fou de collision
|
||||||
|
|
|
||||||
|
|
@ -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`) | — |
|
| 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 | — |
|
| 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>)` | — |
|
| 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 |
|
| 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),
|
> ⚠️ **Deux directions de binding, assumées** : `app→app` côté app (instancier),
|
||||||
|
|
|
||||||
|
|
@ -1292,14 +1292,33 @@ HTML = r"""<!doctype html>
|
||||||
const vlan = i.vlans ? (i.vlans[0] + '-' + i.vlans[1]) : '—';
|
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 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 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>
|
return `<tr>
|
||||||
<td class="mono">${i.actif ? '★ ' : ''}${echapper(i.nom)}</td>
|
<td class="mono">${i.actif ? '★ ' : ''}${echapper(i.nom)}</td>
|
||||||
<td class="mono">${i.index}</td>
|
<td class="mono">${i.index}</td>
|
||||||
<td class="mono">${vlan}</td>
|
<td class="mono">${vlan}</td>
|
||||||
<td>${fed} ${prod}</td></tr>`;
|
<td>${fed} ${prod}</td>
|
||||||
|
<td>${action}</td></tr>`;
|
||||||
}).join('');
|
}).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>
|
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>). Basculer en CLI : <code>make instance-utiliser NOM=<dépôt></code>.</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>`;
|
||||||
|
}
|
||||||
|
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() {
|
async function dessinerReseau() {
|
||||||
const cible = document.getElementById('grilles');
|
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):
|
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
|
def log_message(self, *args) -> None: # silence par defaut
|
||||||
pass
|
pass
|
||||||
|
|
@ -2565,6 +2615,9 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
||||||
elif chemin == "/api/intrants":
|
elif chemin == "/api/intrants":
|
||||||
ecrire_intrants(donnees)
|
ecrire_intrants(donnees)
|
||||||
self.repondre_json(200, inventaire_api(self.inventaire))
|
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":
|
elif chemin == "/api/instancier":
|
||||||
# Cliquer « Appliquer le plan » EST l'intention explicite : on force
|
# 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.
|
# (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)
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
Gestionnaire.inventaire = args.inventaire.resolve()
|
|
||||||
serveur = ThreadingHTTPServer((args.hote, args.port), Gestionnaire)
|
serveur = ThreadingHTTPServer((args.hote, args.port), Gestionnaire)
|
||||||
print(f"Interface inventaire: http://{args.hote}:{args.port}/")
|
print(f"Interface inventaire: http://{args.hote}:{args.port}/")
|
||||||
print(f"Inventaire: {Gestionnaire.inventaire}")
|
print(f"Inventaire: {Gestionnaire.inventaire}")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue