setops_production : garde-fou de prudence, plus un blocage de déploiement

Le déploiement réel est permis sur TOUTE instance (un bac à sable déploie
sur son infra lab : isolé ET fonctionnel). Le drapeau ne bloque plus ;
il marque la PROD pour exiger une confirmation renforcée (badge/bouton
rouge, re-saisie du nom d'hôte). executer_flux ne refuse plus selon le
drapeau. Badge « PROD » / « bac à sable » dans l'en-tête.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-06-30 16:20:18 -04:00
parent e2b924cedc
commit 0e7cefbb2f
2 changed files with 31 additions and 21 deletions

View file

@ -11,13 +11,15 @@
instances existantes (découpage lab/production) continuent de fonctionner à
l'identique ; les nouvelles peuvent adopter `inventories/principal/`. Deuxième pierre
de la séparation **par instance** (la 1re étant le drapeau `setops_production`).
- **Garde-fou de déploiement par instance (`setops_production`).** Le droit de
déployer pour de vrai (GUI Vérifier/Déployer, `executer_flux`) ne dépend plus du
*nom* de l'inventaire mais d'un drapeau explicite `setops_production` dans
`group_vars/all/` de l'instance (true = prod, false = bac à sable). Rétro-compatible
(à défaut de drapeau, ancien repère « inventaire production »). Pose la base de la
séparation **par instance** : un bac à sable garde `false` et ne peut rien déployer,
quel que soit le nom de ses dossiers.
- **Garde-fou de prudence par instance (`setops_production`).** Le déploiement réel
est désormais possible sur **toute instance** (un bac à sable déploie sur *son*
infra lab — il est isolé **et** fonctionnel). Le drapeau `setops_production` dans
`group_vars/all/` ne **bloque** plus rien : il marque la PRODUCTION pour exiger une
**confirmation renforcée** au déploiement (bannière/badge rouge « PROD », bouton
Déployer en rouge, re-saisie du nom d'hôte) ; un bac à sable (`false`) affiche
« bac à sable » et déploie sans cette étape. Rétro-compatible (à défaut de drapeau,
ancien repère « inventaire production »). Le GUI affiche toujours quel type
d'instance est monté.
### Modifié
- **Voûte de secrets unique par environnement.** Fini les voûtes éparpillées : tous

View file

@ -255,7 +255,7 @@ def inventaire_api(path: Path) -> dict:
dependencies = charger_dependances(FICHIER_DEPENDANCES)
return {
"inventaire": os.path.relpath(path, RACINE),
"production": instance_autorise_deploiement(path),
"production": instance_est_production(path),
"groupes": groupes_disponibles(data),
"dependances": dependencies,
"nomenclature": charger_nomenclature(FICHIER_NOMENCLATURE),
@ -278,12 +278,13 @@ def _lire_yaml_dict(path: Path) -> dict:
return data if isinstance(data, dict) else {}
def instance_autorise_deploiement(path: Path) -> bool:
"""Le déploiement réel est-il permis pour l'instance de cet inventaire ?
def instance_est_production(path: Path) -> bool:
"""Cette instance est-elle la PRODUCTION ? (garde-fou de prudence, pas un blocage)
Repère explicite, par instance : `setops_production` dans
`<inventaire>/group_vars/all/`. Rétro-compatibilité : à défaut de drapeau,
on retombe sur l'ancien repère (inventaire nommé « production »).
Le déploiement est permis sur toute instance (lab compris, vers son infra) ;
ce drapeau ne sert qu'à exiger une confirmation renforcée côté GUI quand c'est
la prod. Repère explicite : `setops_production` dans `<inventaire>/group_vars/all/`.
Rétro-compatibilité : à défaut, ancien repère (inventaire nommé « production »).
"""
base = path.parent / "group_vars" / "all"
fichiers = sorted(base.glob("*.yml")) if base.is_dir() else []
@ -737,6 +738,8 @@ HTML = r"""<!doctype html>
.badge-classe.const { background: color-mix(in srgb, var(--ambre) 20%, transparent); color: var(--ambre); }
.badge-classe.def { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
.badge-classe.cat { background: color-mix(in srgb, var(--teal) 20%, transparent); color: var(--teal); }
.badge-prod { display: inline-flex; align-items: center; font-size: 11px; font-weight: 800; letter-spacing: .06em; padding: 3px 9px; border-radius: 999px; background: var(--rouge); color: #fff; }
.badge-lab { display: inline-flex; align-items: center; font-size: 11px; font-weight: 800; letter-spacing: .04em; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--teal) 22%, transparent); color: var(--teal); }
.integ-cases { display: flex; flex-wrap: wrap; gap: 6px 12px; padding: 4px 2px; }
.integ-case { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: var(--texte); cursor: pointer; }
.integ-case input { width: 14px; height: 14px; flex: none; accent-color: var(--teal); }
@ -946,6 +949,7 @@ HTML = r"""<!doctype html>
const actifs = hotes.filter(h => h.etat === 'actif').length;
const bloques = hotes.filter(h => hoteBloque(h)).length;
document.getElementById('resume').innerHTML = `
${estProduction ? '<span class="badge-prod" title="Instance de PRODUCTION : déploiement sous confirmation renforcée">PROD</span>' : '<span class="badge-lab" title="Instance bac à sable (setops_production: false)">bac à sable</span>'}
<span class="chip"><b>${hotes.length}</b> hôtes</span>
<span class="chip"><span class="pt actif"></span><b>${actifs}</b> actifs</span>
<span class="chip"><span class="pt plan"></span><b>${hotes.length - actifs}</b> planifiés</span>
@ -1320,13 +1324,14 @@ HTML = r"""<!doctype html>
: '<div class="ch-stub">aucun serveur de base ici</div>';
hoteOpCourant = s.nom;
let pied;
if (h && h.etat === 'actif' && !hoteBloque(h) && estProduction) {
if (h && h.etat === 'actif' && !hoteBloque(h)) {
const hi = hotes.indexOf(h);
pied = `<div class="pied"><span class="grandit"></span>
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="primaire" onclick="lancer('deployer', ${hi})">Déployer</button></div>`;
<button type="button" id="btn-deployer" class="${clsDep}" onclick="lancer('deployer', ${hi})">Déployer</button></div>`;
} else {
const txt = !h ? 'Applique le plan pour instancier cet hôte' : (h.etat !== 'actif' ? 'Hôte actif requis pour déployer' : (!estProduction ? 'Déploiement : inventaire production requis' : 'Prérequis manquant'));
const txt = !h ? 'Applique le plan pour instancier cet hôte' : (h.etat !== 'actif' ? 'Hôte actif requis pour déployer' : 'Prérequis manquant');
pied = `<div class="pied"><span class="hint-dep">${txt}</span></div>`;
}
const statBadge = s.statut === 'divergence' ? '<span class="badge-statut bloque">divergent</span>'
@ -1661,8 +1666,14 @@ HTML = r"""<!doctype html>
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'
+ '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,
'Applique les playbooks de groupes sur la VM réelle, en root (sudo). Action non transactionnelle : pas de rollback automatique en cas d\'échec.');
(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.');
if (vault === null) return;
motVault = vault;
}
@ -1864,9 +1875,6 @@ class Gestionnaire(BaseHTTPRequestHandler):
if not MOTIF_HOTE.match(hote or ""):
self.repondre_json(400, {"erreur": "Nom d'hote invalide."})
return
if not instance_autorise_deploiement(self.inventaire):
self.repondre_json(409, {"erreur": "Deploiement non autorise pour cette instance (setops_production: false)."})
return
if hote not in hotes_actifs_fichier(self.inventaire):
self.repondre_json(409, {"erreur": f"{hote} n'est pas un hote actif sauvegarde."})
return