#!/usr/bin/env python3 """Generateur d'inventaire Set-OPS (Phase 3) : plan -> hosts.yml. NON destructif : ecrit dans instance/inventories/production/hosts.genere.yml 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 instance/plan/serveurs.yml) ; - groupes : socle (serveur_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 os 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] INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) INVENTAIRE = INSTANCE / "inventories/production/hosts.yml" GENERE = INSTANCE / "inventories/production/hosts.genere.yml" FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml" FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml" FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml" GROUPES_SOCLE = ["serveur_debian", "serveur_durci"] 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(path: Path = GENERE) -> None: entete = ("# Inventaire GENERE depuis le plan (make instancier / instancier-appliquer).\n" "# NE PAS editer a la main : edite instance/plan/serveurs.yml + instance/plan/applications.yml.\n" "# Source : instance/plan/serveurs.yml + instance/plan/applications.yml + instance/plan/nomenclature.yml.\n") with path.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 compter_ecarts(reference: Path, genere: Path) -> int: hv_act, grp_act = _resolu(reference) 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}") return ecarts def comparer() -> int: ecrire(GENERE) ecarts = compter_ecarts(INVENTAIRE, GENERE) if ecarts == 0: print("DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible.") else: print(f"\n{ecarts} hote(s) avec ecart.") return 0 def appliquer(force: bool = False) -> int: """Ecrit hosts.yml depuis le plan. Refuse si le diff n'est pas vide (sauf --force).""" ecrire(GENERE) ecarts = compter_ecarts(INVENTAIRE, GENERE) if ecarts and not force: print(f"\nDiff NON vide ({ecarts} hote(s)). Revois 'make instancier'. " "Utilise FORCE=1 pour appliquer un changement intentionnel du plan.") return 1 ecrire(INVENTAIRE) print(f"\n{os.path.relpath(INVENTAIRE, RACINE)} (re)genere depuis le plan. " "git est le filet : git diff / git checkout pour revenir.") 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.genere.yml depuis le plan.") sub.add_parser("comparer", help="Compare le genere a l'inventaire actuel (semantique).") pa = sub.add_parser("appliquer", help="Ecrit hosts.yml depuis le plan (refuse si diff non vide).") pa.add_argument("--force", action="store_true", help="Applique meme si le diff n'est pas vide.") args = parser.parse_args() try: if args.commande == "generer": ecrire() print(f"{os.path.relpath(GENERE, RACINE)} genere depuis le plan.") elif args.commande == "comparer": return comparer() elif args.commande == "appliquer": return appliquer(force=args.force) except Exception as exc: print(f"erreur: {exc}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main())