#!/usr/bin/env python3 """Generateur d'inventaire Set-OPS (Phase 3) : plan -> hosts.yml. NON destructif : ecrit dans inventories/production/hosts.yml.genere et compare SEMANTIQUEMENT (via ansible-inventory --list) avec l'inventaire actuel. Aucune bascule tant que la comparaison n'est pas vide et validee. Derivation : - host vars : ansible_host/ansible_user + proxmox_* (IP/VMID/VLAN/passerelle derives de la nomenclature ; placement/taille depuis docs/serveurs.yml) ; - groupes : socle (serveurs_debian/durcis) + groupes de service derives des applications de l'hote + groupe d'etat (hotes_actifs/hotes_planifies). - Les integrations clients_* ne sont PAS derivables du plan a ce stade : la comparaison les revele (lacune a combler en Phase 3b). """ from __future__ import annotations import json import subprocess import sys from pathlib import Path import yaml from inventory_rules import ( charger_applications, charger_nomenclature, charger_serveurs, deriver_nomenclature, fonction_seq, ) RACINE = Path(__file__).resolve().parents[1] INVENTAIRE = RACINE / "inventories/production/hosts.yml" GENERE = RACINE / "inventories/production/hosts.genere.yml" FICHIER_SERVEURS = RACINE / "docs/serveurs.yml" FICHIER_APPLICATIONS = RACINE / "docs/applications.yml" FICHIER_NOMENCLATURE = RACINE / "docs/nomenclature.yml" GROUPES_SOCLE = ["serveurs_debian", "serveurs_durcis"] PLACEMENT = [("noeud", "proxmox_noeud"), ("stockage", "proxmox_stockage"), ("disque", "proxmox_disque_taille"), ("memoire", "proxmox_memoire"), ("coeurs", "proxmox_coeurs")] def generer() -> dict: serveurs = charger_serveurs(FICHIER_SERVEURS).get("serveurs", {}) apps = charger_applications(FICHIER_APPLICATIONS).get("applications", {}) nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE) services_par_hote: dict = {} for app in apps.values(): services_par_hote.setdefault(app.get("hote"), set()).add(app.get("groupe")) children: dict = { "modeles_vm": {"hosts": {}}, "hotes_actifs": {"hosts": {}}, "hotes_planifies": {"hosts": {}}, } for nom, srv in serveurs.items(): _, seq = fonction_seq(nom) d = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {} hostvars = { "ansible_host": d.get("adresse_ip"), "ansible_user": "ansible", "proxmox_cidr": d.get("cidr"), "proxmox_passerelle": d.get("passerelle"), "proxmox_vlan": d.get("vlan"), "proxmox_vmid": int(d["vmid"]) if d.get("vmid") else None, } for cle_srv, cle_var in PLACEMENT: if str(srv.get(cle_srv, "")).strip(): hostvars[cle_var] = srv[cle_srv] etat = "hotes_actifs" if srv.get("etat") == "actif" else "hotes_planifies" children[etat]["hosts"][nom] = hostvars groupes = set(GROUPES_SOCLE) | services_par_hote.get(nom, set()) | set(srv.get("integrations") or []) for groupe in sorted(groupes): children.setdefault(groupe, {"hosts": {}})["hosts"][nom] = None return {"all": {"children": children}} def ecrire() -> None: entete = ("# Inventaire GENERE depuis le plan (make instancier). NE PAS editer a la main.\n" "# Source : docs/serveurs.yml + docs/applications.yml + docs/nomenclature.yml.\n") with GENERE.open("w", encoding="utf-8") as fichier: fichier.write(entete) yaml.safe_dump(generer(), fichier, default_flow_style=False, sort_keys=True, allow_unicode=True) def _resolu(fichier: Path) -> tuple[dict, dict]: sortie = subprocess.run(["ansible-inventory", "-i", str(fichier), "--list"], capture_output=True, text=True, check=True).stdout data = json.loads(sortie) hostvars = data.get("_meta", {}).get("hostvars", {}) groupes: dict = {} for groupe, info in data.items(): if groupe in ("_meta", "all", "ungrouped") or not isinstance(info, dict): continue for hote in (info.get("hosts") or []): groupes.setdefault(hote, set()).add(groupe) return hostvars, groupes def comparer() -> int: if not GENERE.exists(): ecrire() hv_act, grp_act = _resolu(INVENTAIRE) hv_gen, grp_gen = _resolu(GENERE) hotes = sorted(set(hv_act) | set(hv_gen)) ecarts = 0 for hote in hotes: if hote not in hv_gen: print(f" - {hote} : ABSENT du genere"); ecarts += 1; continue if hote not in hv_act: print(f" - {hote} : EN TROP dans le genere"); ecarts += 1; continue g_manquants = grp_act.get(hote, set()) - grp_gen.get(hote, set()) g_surplus = grp_gen.get(hote, set()) - grp_act.get(hote, set()) vars_diff = [k for k in set(hv_act[hote]) | set(hv_gen[hote]) if hv_act[hote].get(k) != hv_gen[hote].get(k)] if g_manquants or g_surplus or vars_diff: ecarts += 1 print(f" - {hote} :") if g_manquants: print(f" groupes non reproduits : {', '.join(sorted(g_manquants))}") if g_surplus: print(f" groupes en trop : {', '.join(sorted(g_surplus))}") if vars_diff: details = ", ".join(f"{k} (inv={hv_act[hote].get(k)!r} gen={hv_gen[hote].get(k)!r})" for k in sorted(vars_diff)) print(f" vars differentes : {details}") if ecarts == 0: print("DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible.") else: print(f"\n{ecarts} hote(s) avec ecart. Bascule INTERDITE tant que ce n'est pas vide.") return 0 def main() -> int: import argparse parser = argparse.ArgumentParser(description="Generateur d'inventaire Set-OPS (Phase 3).") sub = parser.add_subparsers(dest="commande", required=True) sub.add_parser("generer", help="Ecrit hosts.yml.genere depuis le plan.") sub.add_parser("comparer", help="Compare le genere a l'inventaire actuel (semantique).") args = parser.parse_args() try: if args.commande == "generer": ecrire() print(f"{GENERE.relative_to(RACINE)} genere depuis le plan.") elif args.commande == "comparer": return comparer() except Exception as exc: print(f"erreur: {exc}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main())