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 à instances existantes (découpage lab/production) continuent de fonctionner à
l'identique ; les nouvelles peuvent adopter `inventories/principal/`. Deuxième pierre l'identique ; les nouvelles peuvent adopter `inventories/principal/`. Deuxième pierre
de la séparation **par instance** (la 1re étant le drapeau `setops_production`). 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 - **Garde-fou de prudence par instance (`setops_production`).** Le déploiement réel
déployer pour de vrai (GUI Vérifier/Déployer, `executer_flux`) ne dépend plus du est désormais possible sur **toute instance** (un bac à sable déploie sur *son*
*nom* de l'inventaire mais d'un drapeau explicite `setops_production` dans infra lab — il est isolé **et** fonctionnel). Le drapeau `setops_production` dans
`group_vars/all/` de l'instance (true = prod, false = bac à sable). Rétro-compatible `group_vars/all/` ne **bloque** plus rien : il marque la PRODUCTION pour exiger une
(à défaut de drapeau, ancien repère « inventaire production »). Pose la base de la **confirmation renforcée** au déploiement (bannière/badge rouge « PROD », bouton
séparation **par instance** : un bac à sable garde `false` et ne peut rien déployer, Déployer en rouge, re-saisie du nom d'hôte) ; un bac à sable (`false`) affiche
quel que soit le nom de ses dossiers. « 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é ### Modifié
- **Voûte de secrets unique par environnement.** Fini les voûtes éparpillées : tous - **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) dependencies = charger_dependances(FICHIER_DEPENDANCES)
return { return {
"inventaire": os.path.relpath(path, RACINE), "inventaire": os.path.relpath(path, RACINE),
"production": instance_autorise_deploiement(path), "production": instance_est_production(path),
"groupes": groupes_disponibles(data), "groupes": groupes_disponibles(data),
"dependances": dependencies, "dependances": dependencies,
"nomenclature": charger_nomenclature(FICHIER_NOMENCLATURE), "nomenclature": charger_nomenclature(FICHIER_NOMENCLATURE),
@ -278,12 +278,13 @@ def _lire_yaml_dict(path: Path) -> dict:
return data if isinstance(data, dict) else {} return data if isinstance(data, dict) else {}
def instance_autorise_deploiement(path: Path) -> bool: def instance_est_production(path: Path) -> bool:
"""Le déploiement réel est-il permis pour l'instance de cet inventaire ? """Cette instance est-elle la PRODUCTION ? (garde-fou de prudence, pas un blocage)
Repère explicite, par instance : `setops_production` dans Le déploiement est permis sur toute instance (lab compris, vers son infra) ;
`<inventaire>/group_vars/all/`. Rétro-compatibilité : à défaut de drapeau, ce drapeau ne sert qu'à exiger une confirmation renforcée côté GUI quand c'est
on retombe sur l'ancien repère (inventaire nommé « production »). 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" base = path.parent / "group_vars" / "all"
fichiers = sorted(base.glob("*.yml")) if base.is_dir() else [] 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.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.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-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-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 { 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); } .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 actifs = hotes.filter(h => h.etat === 'actif').length;
const bloques = hotes.filter(h => hoteBloque(h)).length; const bloques = hotes.filter(h => hoteBloque(h)).length;
document.getElementById('resume').innerHTML = ` 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"><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 actif"></span><b>${actifs}</b> actifs</span>
<span class="chip"><span class="pt plan"></span><b>${hotes.length - actifs}</b> planifiés</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>'; : '<div class="ch-stub">aucun serveur de base ici</div>';
hoteOpCourant = s.nom; hoteOpCourant = s.nom;
let pied; let pied;
if (h && h.etat === 'actif' && !hoteBloque(h) && estProduction) { if (h && h.etat === 'actif' && !hoteBloque(h)) {
const hi = hotes.indexOf(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-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 { } 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>`; pied = `<div class="pied"><span class="hint-dep">${txt}</span></div>`;
} }
const statBadge = s.statut === 'divergence' ? '<span class="badge-statut bloque">divergent</span>' const statBadge = s.statut === 'divergence' ? '<span class="badge-statut bloque">divergent</span>'
@ -1661,8 +1666,14 @@ HTML = r"""<!doctype html>
let vault = ''; let vault = '';
if (mode === 'deployer') { if (mode === 'deployer') {
if (!verifie[hote]) { message('Vérifiez (dry-run) d\'abord.', 'erreur'); return; } 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, 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; if (vault === null) return;
motVault = vault; motVault = vault;
} }
@ -1864,9 +1875,6 @@ class Gestionnaire(BaseHTTPRequestHandler):
if not MOTIF_HOTE.match(hote or ""): if not MOTIF_HOTE.match(hote or ""):
self.repondre_json(400, {"erreur": "Nom d'hote invalide."}) self.repondre_json(400, {"erreur": "Nom d'hote invalide."})
return 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): if hote not in hotes_actifs_fichier(self.inventaire):
self.repondre_json(409, {"erreur": f"{hote} n'est pas un hote actif sauvegarde."}) self.repondre_json(409, {"erreur": f"{hote} n'est pas un hote actif sauvegarde."})
return return