GUI : bouton « Pousser » (flux 100 % cliquable)

Chaque objet du détail se matérialise sur son hôte, en streaming console
(vault + confirmation prod) :
- Serveur → 🖥 Pousser clone la VM (make creer-vm), même sur un planifié —
  comble le trou : le GUI ne clonait pas.
- Application → Pousser déploie l'hôte porteur.
- Base → Pousser déploie l'hôte du serveur de BD (crée la base).
Nouveaux modes creer/pousser dans executer_flux + routes /api/creer et
/api/pousser. Flux complet : éditer → Appliquer → Pousser → Vérifier →
Déployer, sans quitter le navigateur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-07-01 15:58:32 -04:00
parent da85af2a62
commit 7a98678bd9
2 changed files with 79 additions and 24 deletions

View file

@ -1,5 +1,22 @@
# CHANGELOG — Set-OPS
## 2026-07-01
### Ajouté
- **Bouton « Pousser » dans le GUI — le flux devient 100 % cliquable.** Chaque objet
du détail se matérialise sur son hôte, en streaming console (avec mot de passe vault
+ confirmation renforcée en prod) :
- **Serveur**`🖥 Pousser` clone la VM depuis le golden template (`make creer-vm`),
disponible même sur un hôte planifié — comble le trou : le GUI ne clonait pas.
- **Application**`Pousser` déploie l'hôte porteur (`make deployer HOTE=<hôte>`).
- **Base**`Pousser` déploie l'hôte du serveur de BD (crée la base).
Nouveaux modes `creer` / `pousser` dans `executer_flux` + routes `/api/creer` et
`/api/pousser`. Flux complet depuis l'interface : éditer → ⚙ Appliquer → 🖥 Pousser
→ Vérifier → Déployer.
- **Premier déploiement RÉEL validé de bout en bout** (cluster asgard) : flux canonique
plan-piloté clonant + durcissant + faisant tourner un PowerDNS qui résout. Voir les 3
correctifs ci-dessous.
## 2026-06-30
### Ajouté

View file

@ -1084,7 +1084,12 @@ HTML = r"""<!doctype html>
<label class="champ"><span>Groupe Ansible</span><select onchange="definirServeurBd('groupe', this.value)">${optGroupe}</select></label>
</div>
<div class="section-tete" style="margin:12px 2px 2px">Bases hébergées ici</div>${iciHtml}
<div class="pied"><span class="hint-dep">Sauvegarde (Ctrl+S) puis « Appliquer le plan ».</span></div>
${(() => {
const hS = hotes.find(h => h.nom === s.hote);
if (hS && hS.etat === 'actif') { const hiS = hotes.indexOf(hS);
return `<div class="pied"><span class="hint-dep">Pousser déploie l'hôte « ${echapper(s.hote)} ».</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiS})" title="Déployer l'hôte du serveur de BD">Pousser</button></div>`; }
return `<div class="pied"><span class="hint-dep">${s.hote ? `Active + pousse d'abord l'hôte « ${echapper(s.hote)} ».` : 'Aucun hôte assigné.'}</span></div>`;
})()}
</article>`;
} else {
const a = bdApplis[info.i];
@ -1106,7 +1111,13 @@ HTML = r"""<!doctype html>
<label class="champ"><span>Secret (Vault)</span><input value="${echapper(a.secret)}" placeholder="vault_bd_app" oninput="definirBaseBd('secret', this.value)"></label>
</div>
<div class="dsn mono" style="margin-top:8px">${echapper(dsnBase(a))}</div>
<div class="pied"><span class="hint-dep">Sauvegarde (Ctrl+S) puis « Appliquer le plan ».</span></div>
${(() => {
const srv = bdServeurs.find(x => x.nom === a.serveur);
const hB = srv ? hotes.find(h => h.nom === srv.hote) : null;
if (hB && hB.etat === 'actif') { const hiB = hotes.indexOf(hB);
return `<div class="pied"><span class="hint-dep">Pousser déploie « ${echapper(srv.hote)} » (le serveur de BD qui héberge cette base).</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiB})" title="Déployer le serveur de BD (crée cette base)">Pousser</button></div>`; }
return `<div class="pied"><span class="hint-dep">${(srv && srv.hote) ? `Active + pousse d'abord l'hôte « ${echapper(srv.hote)} ».` : 'Serveur de BD sans hôte assigné.'}</span></div>`;
})()}
</article>`;
}
}
@ -1192,7 +1203,14 @@ HTML = r"""<!doctype html>
</div>
<div class="section-tete" style="margin:12px 2px 4px">Bases consommées</div>
${liens}
<div class="pied"><span class="hint-dep">Sauvegarde (Ctrl+S) puis « Appliquer le plan » pour régénérer.</span></div>
${(() => {
const hA = hotes.find(x => x.nom === a.hote);
if (hA && hA.etat === 'actif') {
const hiA = hotes.indexOf(hA);
return `<div class="pied"><span class="hint-dep">Pousser déploie l'hôte porteur « ${echapper(a.hote)} ».</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiA})" title="Déployer l'hôte porteur (rend l'application vivante)">Pousser</button></div>`;
}
return `<div class="pied"><span class="hint-dep">${a.hote ? `Active + pousse d'abord l'hôte « ${echapper(a.hote)} ».` : 'Aucun hôte assigné.'}</span></div>`;
})()}
</article>`;
}
@ -1306,19 +1324,21 @@ HTML = r"""<!doctype html>
? `<div class="ch-bases">${bds.map(x => `<div class="ch-base"><span class="badge-bd">${echapper(x.type)}</span> <button type="button" class="lien-objet" onclick="ouvrirBaseServeur('${echapper(x.nom)}')">${echapper(x.nom)}</button> <span class="mono">:${echapper(x.port)}</span></div>`).join('')}</div>`
: '<div class="ch-stub">aucun serveur de base ici</div>';
hoteOpCourant = s.nom;
let pied;
const hi = h ? hotes.indexOf(h) : -1;
let boutons = '';
if (h) boutons += `<button type="button" title="Cloner la VM depuis le golden template (Proxmox)" onclick="lancer('creer', ${hi})">🖥 Pousser</button>`;
if (h && h.etat === 'actif' && !hoteBloque(h)) {
const hi = hotes.indexOf(h);
const clsDep = estProduction ? 'danger' : 'primaire';
pied = `<div class="pied">${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<span class="grandit"></span>
<button type="button" id="btn-verifier" onclick="lancer('verifier', ${hi})">Vérifier</button>
<button type="button" id="btn-deployer" class="${clsDep}" onclick="lancer('deployer', ${hi})">Déployer</button></div>`;
boutons += `<button type="button" id="btn-verifier" onclick="lancer('verifier', ${hi})">Vérifier</button>`
+ `<button type="button" id="btn-deployer" class="${clsDep}" onclick="lancer('deployer', ${hi})">Déployer</button>`;
}
let pied;
if (boutons) {
const bloque = h && h.etat === 'actif' && hoteBloque(h);
const hint = bloque ? `<span class="hint-dep">Prérequis manquant : ${[...new Set((h.groupes || []).flatMap(g => manquantsPourGroupe(g)))].join(', ')}</span>` : '';
pied = `<div class="pied">${estProduction ? '<span class="badge-prod">PROD</span>' : ''}${hint}<span class="grandit"></span>${boutons}</div>`;
} else {
const manquants = h ? [...new Set((h.groupes || []).flatMap(g => manquantsPourGroupe(g)))] : [];
const txt = !h ? 'Applique le plan pour instancier cet hôte'
: (h.etat !== 'actif' ? 'Hôte actif requis pour déployer'
: ('Prérequis manquant' + (manquants.length ? ' : ' + manquants.join(', ') : '')));
pied = `<div class="pied"><span class="hint-dep">${txt}</span></div>`;
pied = `<div class="pied"><span class="hint-dep">Applique le plan pour instancier cet hôte ( Appliquer le plan)</span></div>`;
}
const statBadge = s.statut === 'divergence' ? '<span class="badge-statut bloque">divergent</span>'
: s.statut === 'absent' ? '<span class="badge-statut attente">non instancié</span>'
@ -1617,21 +1637,28 @@ HTML = r"""<!doctype html>
if (!hote) { message('Hôte introuvable.', 'erreur'); return; }
if (occupe) return;
let vault = '';
if (mode === 'deployer') {
if (!verifie[hote]) { message('Vérifiez (dry-run) d\'abord.', 'erreur'); return; }
if (estProduction) {
const saisi = prompt('⚠️ PRODUCTION — déploiement réel sur « ' + hote + ' ».\n'
if (mode === 'deployer' && !verifie[hote]) { message('Vérifiez (dry-run) d\'abord.', 'erreur'); return; }
if (mode === 'creer' || mode === 'deployer' || mode === 'pousser') {
if (estProduction && (mode === 'deployer' || mode === 'pousser')) {
const saisi = prompt('⚠️ PRODUCTION — action réelle sur « ' + hote + ' ».\n'
+ 'Tape le nom exact de l\'hôte pour confirmer :');
if (saisi === null) return;
if (saisi.trim() !== hote) { message('Confirmation annulée : le nom ne concorde pas.', 'erreur'); return; }
}
vault = await ouvrirModale('Déployer ' + hote,
(estProduction ? '⚠️ PRODUCTION. ' : '') + 'Applique les playbooks de groupes sur la VM réelle, en root (sudo). Action non transactionnelle : pas de rollback automatique en cas d\'échec.');
const av = estProduction ? '⚠️ PRODUCTION. ' : '';
const txt = mode === 'creer'
? 'Clone la VM « ' + hote + ' » depuis le golden template sur Proxmox (création réelle).'
: mode === 'pousser'
? av + 'Déploie l\'hôte « ' + hote + ' » (applique ses playbooks, en root). Non transactionnel.'
: av + 'Applique les playbooks de groupes sur la VM réelle, en root (sudo). Non transactionnel.';
const titre = (mode === 'creer' ? 'Pousser — créer la VM ' : mode === 'pousser' ? 'Pousser — déployer ' : 'Déployer ') + hote;
vault = await ouvrirModale(titre, txt);
if (vault === null) return;
motVault = vault;
}
occupe = true; majBoutonsDeploiement();
ouvrirConsole(mode === 'deployer' ? 'Déploiement' : 'Vérification (dry-run)', hote);
const titreConsole = {verifier: 'Vérification (dry-run)', deployer: 'Déploiement', creer: 'Création VM (clone)', pousser: 'Poussée (déploiement)'}[mode] || mode;
ouvrirConsole(titreConsole, hote);
try {
const rep = await fetch('/api/' + mode, {
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
@ -1656,7 +1683,8 @@ HTML = r"""<!doctype html>
if (rc === null) rc = 1;
finirConsole(rc);
if (mode === 'verifier' && rc === 0) { verifie[hote] = true; message('Vérification réussie — déploiement débloqué pour ' + hote + '.', 'ok'); }
else if (mode === 'deployer') { verifie[hote] = false; message(rc === 0 ? 'Déploiement terminé.' : 'Déploiement en échec.', rc === 0 ? 'ok' : 'erreur'); }
else if (mode === 'creer') { message(rc === 0 ? 'VM « ' + hote + ' » créée.' : 'Échec de la création.', rc === 0 ? 'ok' : 'erreur'); if (rc === 0) charger(); }
else if (mode === 'deployer' || mode === 'pousser') { verifie[hote] = false; message(rc === 0 ? 'Poussée terminée.' : 'Poussée en échec.', rc === 0 ? 'ok' : 'erreur'); }
} catch (e) {
ecrireConsole('Erreur client : ' + e.message + '\n'); finirConsole(1);
}
@ -1828,7 +1856,12 @@ class Gestionnaire(BaseHTTPRequestHandler):
if not MOTIF_HOTE.match(hote or ""):
self.repondre_json(400, {"erreur": "Nom d'hote invalide."})
return
if hote not in hotes_actifs_fichier(self.inventaire):
tous_hotes = {h.get("nom") for h in liste_hotes(charger_yaml(self.inventaire))}
if hote not in tous_hotes:
self.repondre_json(409, {"erreur": f"{hote} est inconnu de l'inventaire (applique le plan d'abord)."})
return
# 'creer' (clone) est permis meme sur un hote planifie ; les autres exigent actif.
if mode in ("verifier", "deployer", "pousser") and hote not in hotes_actifs_fichier(self.inventaire):
self.repondre_json(409, {"erreur": f"{hote} n'est pas un hote actif sauvegarde."})
return
if mode == "deployer" and not VERIF_OK.get(hote):
@ -1839,7 +1872,8 @@ class Gestionnaire(BaseHTTPRequestHandler):
return
fichier_vault = None
try:
cible = "deployer" if mode == "deployer" else "verifier-deploiement"
cible = {"deployer": "deployer", "pousser": "deployer",
"creer": "creer-vm"}.get(mode, "verifier-deploiement")
env = dict(os.environ, PYTHONUNBUFFERED="1", ANSIBLE_FORCE_COLOR="0")
if vault:
fd, fichier_vault = tempfile.mkstemp(prefix="setops-vault-") # 0600 par defaut
@ -1866,7 +1900,7 @@ class Gestionnaire(BaseHTTPRequestHandler):
rc = proc.wait()
if mode == "verifier" and rc == 0:
VERIF_OK[hote] = True
elif mode == "deployer":
elif mode in ("deployer", "pousser"):
VERIF_OK.pop(hote, None)
self._ecrire_flux(f"\n__FIN__ rc={rc}\n")
finally:
@ -1934,6 +1968,10 @@ class Gestionnaire(BaseHTTPRequestHandler):
self.executer_flux(str(donnees.get("hote", "")), "verifier", donnees.get("vault"))
elif chemin == "/api/deployer":
self.executer_flux(str(donnees.get("hote", "")), "deployer", donnees.get("vault"))
elif chemin == "/api/creer":
self.executer_flux(str(donnees.get("hote", "")), "creer", donnees.get("vault"))
elif chemin == "/api/pousser":
self.executer_flux(str(donnees.get("hote", "")), "pousser", donnees.get("vault"))
else:
self.repondre_json(404, {"erreur": "Introuvable"})
except Exception as exc: