This repository has been archived on 2026-06-26. You can view files and clone it, but cannot push or open issues or pull requests.
Set-OPS/scripts/inventory_gui.py

376 lines
14 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Interface web locale pour gerer un inventaire Set-OPS."""
from __future__ import annotations
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import argparse
import json
from pathlib import Path
from urllib.parse import urlparse
import yaml
RACINE = Path(__file__).resolve().parents[1]
INVENTAIRE_DEFAUT = RACINE / "inventories/production/hosts.yml"
DOSSIER_PLAYBOOKS_GROUPES = RACINE / "playbooks/groupes"
GROUPES_OPERATIONNELS_PREFIXES = ("serveurs_", "clients_")
GROUPES_ETAT = ("hotes_actifs", "hotes_planifies")
def charger_yaml(path: Path) -> dict:
if not path.exists():
return {"all": {"children": {}}}
with path.open("r", encoding="utf-8") as fichier:
data = yaml.safe_load(fichier) or {}
if not isinstance(data, dict):
raise ValueError(f"{path} ne contient pas une table YAML.")
data.setdefault("all", {})
data["all"].setdefault("children", {})
return data
def ecrire_yaml(path: Path, data: dict) -> None:
with path.open("w", encoding="utf-8") as fichier:
yaml.safe_dump(data, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
def enfants(data: dict) -> dict:
return data.setdefault("all", {}).setdefault("children", {})
def groupes_disponibles(data: dict) -> list[str]:
groupes = set(enfants(data))
groupes.update(playbook.stem for playbook in DOSSIER_PLAYBOOKS_GROUPES.glob("*.yml"))
return sorted(group for group in groupes if group.startswith(GROUPES_OPERATIONNELS_PREFIXES))
def variables_hote(data: dict, hote: str) -> dict:
resultat: dict = {}
for groupe_data in enfants(data).values():
hosts = groupe_data.get("hosts", {})
valeurs = hosts.get(hote)
if isinstance(valeurs, dict):
resultat.update(valeurs)
return resultat
def liste_hotes(data: dict) -> list[dict]:
noms = set()
for groupe_data in enfants(data).values():
noms.update(groupe_data.get("hosts", {}))
hotes = []
for nom in sorted(noms):
groupes = []
etat = "planifie"
for groupe, groupe_data in enfants(data).items():
if nom not in groupe_data.get("hosts", {}):
continue
if groupe == "hotes_actifs":
etat = "actif"
elif groupe in GROUPES_ETAT:
continue
elif groupe.startswith(GROUPES_OPERATIONNELS_PREFIXES):
groupes.append(groupe)
variables = variables_hote(data, nom)
hotes.append(
{
"nom": nom,
"etat": etat,
"adresse_ip": variables.get("ansible_host", ""),
"utilisateur_ansible": variables.get("ansible_user", "ansible"),
"vmid": variables.get("proxmox_vmid", ""),
"groupes": sorted(groupes),
}
)
return hotes
def inventaire_api(path: Path) -> dict:
data = charger_yaml(path)
return {
"inventaire": str(path.relative_to(RACINE)),
"groupes": groupes_disponibles(data),
"hotes": liste_hotes(data),
}
def donnees_hote(hote: dict) -> dict:
variables: dict = {}
adresse_ip = str(hote.get("adresse_ip", "")).strip()
utilisateur = str(hote.get("utilisateur_ansible", "")).strip()
vmid = str(hote.get("vmid", "")).strip()
if adresse_ip:
variables["ansible_host"] = adresse_ip
if utilisateur:
variables["ansible_user"] = utilisateur
if vmid:
variables["proxmox_vmid"] = int(vmid) if vmid.isdigit() else vmid
return variables
def construire_inventaire(payload: dict, groupes_connus: list[str]) -> dict:
enfants_data: dict = {
"modeles_vm": {"hosts": {}},
"hotes_actifs": {"hosts": {}},
"hotes_planifies": {"hosts": {}},
}
for groupe in groupes_connus:
enfants_data.setdefault(groupe, {"hosts": {}})
enfants_data.setdefault("hotes_proxmox", {"hosts": {}})
for hote in payload.get("hotes", []):
nom = str(hote.get("nom", "")).strip()
if not nom:
continue
variables = donnees_hote(hote)
groupe_etat = "hotes_actifs" if hote.get("etat") == "actif" else "hotes_planifies"
enfants_data[groupe_etat]["hosts"][nom] = variables
for groupe in hote.get("groupes", []):
if groupe not in groupes_connus:
continue
enfants_data[groupe]["hosts"][nom] = {}
return {"all": {"children": enfants_data}}
def valider_payload(payload: dict, groupes_connus: list[str]) -> None:
noms: set[str] = set()
for hote in payload.get("hotes", []):
nom = str(hote.get("nom", "")).strip()
if not nom:
raise ValueError("Un hote a un nom vide.")
if nom in noms:
raise ValueError(f"Hote en double: {nom}")
noms.add(nom)
groupes = hote.get("groupes", [])
if not groupes:
raise ValueError(f"Aucun groupe operationnel pour: {nom}")
inconnus = sorted(set(groupes) - set(groupes_connus))
if inconnus:
raise ValueError(f"Groupes inconnus pour {nom}: {', '.join(inconnus)}")
HTML = r"""<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Set-OPS - Inventaire</title>
<style>
:root { color-scheme: light; --fond:#f7f7f4; --texte:#202124; --muted:#63665f; --ligne:#d8d9d2; --accent:#276749; --accent2:#1f4e79; --danger:#9b2c2c; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--fond); color: var(--texte); }
header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 24px; border-bottom: 1px solid var(--ligne); background: #fff; position: sticky; top: 0; z-index: 3; }
h1 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: 0; }
main { padding: 18px 24px 32px; }
.barre { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
button { border: 1px solid var(--ligne); background: #fff; color: var(--texte); padding: 8px 11px; border-radius: 6px; cursor: pointer; font-weight: 650; }
button.primaire { background: var(--accent); color: #fff; border-color: var(--accent); }
button.danger { color: var(--danger); }
button:disabled { opacity: .55; cursor: not-allowed; }
input, select { width: 100%; border: 1px solid var(--ligne); border-radius: 5px; padding: 7px 8px; background: #fff; color: var(--texte); font: inherit; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--ligne); }
th, td { border-bottom: 1px solid var(--ligne); padding: 8px; text-align: left; vertical-align: top; }
th { font-size: 12px; text-transform: uppercase; color: var(--muted); background: #fbfbf8; position: sticky; top: 65px; z-index: 2; }
td.nom { min-width: 170px; }
td.ip { min-width: 150px; }
td.vmid { width: 100px; }
td.etat { width: 115px; }
td.groupes { min-width: 360px; }
.groupes-liste { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 5px 10px; }
label { display: inline-flex; align-items: center; gap: 6px; color: var(--texte); }
label input { width: auto; }
.meta { color: var(--muted); font-size: 13px; }
.message { min-height: 22px; color: var(--accent2); font-size: 14px; }
.erreur { color: var(--danger); }
@media (max-width: 900px) {
header { align-items: flex-start; flex-direction: column; }
main { padding: 12px; overflow-x: auto; }
th { top: 115px; }
}
</style>
</head>
<body>
<header>
<div>
<h1>Inventaire Set-OPS</h1>
<div class="meta" id="inventaire"></div>
</div>
<div class="barre">
<button type="button" onclick="ajouterHote()">Ajouter</button>
<button type="button" onclick="charger()">Recharger</button>
<button type="button" class="primaire" onclick="sauvegarder()">Sauvegarder</button>
</div>
</header>
<main>
<div class="message" id="message"></div>
<table>
<thead>
<tr>
<th>Hôte</th>
<th>État</th>
<th>Adresse IP</th>
<th>Utilisateur</th>
<th>VMID</th>
<th>Groupes</th>
<th></th>
</tr>
</thead>
<tbody id="lignes"></tbody>
</table>
</main>
<script>
let groupes = [];
let hotes = [];
function message(texte, erreur=false) {
const el = document.getElementById('message');
el.textContent = texte;
el.className = erreur ? 'message erreur' : 'message';
}
async function charger() {
const reponse = await fetch('/api/inventaire');
const data = await reponse.json();
groupes = data.groupes;
hotes = data.hotes;
document.getElementById('inventaire').textContent = data.inventaire;
dessiner();
message('Inventaire chargé.');
}
function ajouterHote() {
hotes.push({nom:'', etat:'planifie', adresse_ip:'', utilisateur_ansible:'ansible', vmid:'', groupes:['serveurs_debian','serveurs_durcis']});
dessiner();
}
function supprimerHote(index) {
hotes.splice(index, 1);
dessiner();
}
function definir(index, champ, valeur) {
hotes[index][champ] = valeur;
}
function basculerGroupe(index, groupe, actif) {
const ensemble = new Set(hotes[index].groupes || []);
if (actif) ensemble.add(groupe); else ensemble.delete(groupe);
hotes[index].groupes = Array.from(ensemble).sort();
}
function dessiner() {
const tbody = document.getElementById('lignes');
tbody.innerHTML = '';
hotes.forEach((hote, index) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="nom"><input value="${echapper(hote.nom || '')}" oninput="definir(${index}, 'nom', this.value)"></td>
<td class="etat"><select onchange="definir(${index}, 'etat', this.value)">
<option value="actif" ${hote.etat === 'actif' ? 'selected' : ''}>actif</option>
<option value="planifie" ${hote.etat !== 'actif' ? 'selected' : ''}>planifié</option>
</select></td>
<td class="ip"><input value="${echapper(hote.adresse_ip || '')}" oninput="definir(${index}, 'adresse_ip', this.value)"></td>
<td><input value="${echapper(hote.utilisateur_ansible || '')}" oninput="definir(${index}, 'utilisateur_ansible', this.value)"></td>
<td class="vmid"><input value="${echapper(String(hote.vmid || ''))}" oninput="definir(${index}, 'vmid', this.value)"></td>
<td class="groupes"><div class="groupes-liste">${groupes.map(groupe => `
<label><input type="checkbox" ${(hote.groupes || []).includes(groupe) ? 'checked' : ''} onchange="basculerGroupe(${index}, '${groupe}', this.checked)"> ${groupe}</label>
`).join('')}</div></td>
<td><button type="button" class="danger" onclick="supprimerHote(${index})">Retirer</button></td>
`;
tbody.appendChild(tr);
});
}
function echapper(valeur) {
return valeur.replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
async function sauvegarder() {
const reponse = await fetch('/api/inventaire', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({hotes})
});
const data = await reponse.json();
if (!reponse.ok) {
message(data.erreur || 'Sauvegarde refusée.', true);
return;
}
groupes = data.groupes;
hotes = data.hotes;
dessiner();
message('Inventaire sauvegardé.');
}
charger().catch(err => message(err.message, true));
</script>
</body>
</html>
"""
class Gestionnaire(BaseHTTPRequestHandler):
inventaire: Path = INVENTAIRE_DEFAUT
def repondre(self, status: int, contenu: bytes, content_type: str) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(contenu)))
self.end_headers()
self.wfile.write(contenu)
def repondre_json(self, status: int, data: dict) -> None:
self.repondre(status, json.dumps(data, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def do_GET(self) -> None:
chemin = urlparse(self.path).path
try:
if chemin == "/":
self.repondre(200, HTML.encode("utf-8"), "text/html; charset=utf-8")
elif chemin == "/api/inventaire":
self.repondre_json(200, inventaire_api(self.inventaire))
else:
self.repondre_json(404, {"erreur": "Introuvable"})
except Exception as exc:
self.repondre_json(500, {"erreur": str(exc)})
def do_POST(self) -> None:
chemin = urlparse(self.path).path
try:
if chemin != "/api/inventaire":
self.repondre_json(404, {"erreur": "Introuvable"})
return
longueur = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(longueur).decode("utf-8"))
courant = charger_yaml(self.inventaire)
groupes_connus = groupes_disponibles(courant)
valider_payload(payload, groupes_connus)
nouveau = construire_inventaire(payload, groupes_connus)
ecrire_yaml(self.inventaire, nouveau)
self.repondre_json(200, inventaire_api(self.inventaire))
except Exception as exc:
self.repondre_json(400, {"erreur": str(exc)})
def main() -> int:
parser = argparse.ArgumentParser(description="Interface web locale pour gerer un inventaire Set-OPS.")
parser.add_argument("--inventaire", type=Path, default=INVENTAIRE_DEFAUT)
parser.add_argument("--hote", default="127.0.0.1")
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}")
serveur.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())