Set-OPS-Public/scripts/instancier.py
Daniel Allaire 2beef04dc6 contexte : etape 3, l'instancier lit la fiche du site (P91)
Le site depose fiche-site.yml chez ses locataires (make fiches-site-
deposer) ; l'instancier y lit temps, delegation DNS et routage. Sans le
site, l'inventaire genere est identique au versionne, a l'octet pres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:23:17 -04:00

572 lines
28 KiB
Python

#!/usr/bin/env python3
"""Generateur d'inventaire Set-OPS (Phase 3) : plan -> hosts.yml.
NON destructif : ecrit hosts.genere.yml a cote de l'inventaire de l'instance 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 client_* : les universelles viennent de la POLITIQUE du role
(roles/client_*/meta/integration.yml), les facultatives de la liste du plan.
Voir inventory_rules.integrations_de() — source unique des trois lecteurs
(inventaire, voute, GUI).
"""
from __future__ import annotations
import os
import json
import subprocess
import sys
from pathlib import Path
import yaml
import underlay as underlay_mod # noqa: E402
from devis_sdn import vnet_de # noqa: E402
from inventory_rules import ( # noqa: E402
ecriture_atomique,
charger_applications,
charger_domaines,
charger_nomenclature,
charger_serveurs,
deriver_nomenclature,
deriver_ressources,
plancher_memoire,
expositions_des_applications,
fonction_seq,
integrations_de,
integrations_universelles,
instance_courante,
inventaire_de,
liens_acceptes,
supernet_de,
)
RACINE = Path(__file__).resolve().parents[1]
ROLES = RACINE / "roles"
INSTANCE = instance_courante()
INVENTAIRE = inventaire_de(INSTANCE)
GENERE = INVENTAIRE.with_name("hosts.genere.yml")
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml"
FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml"
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
GROUPES_SOCLE = ["serveur_debian", "serveur_durci"]
PLACEMENT = [("noeud", "proxmox_noeud"), ("stockage", "proxmox_stockage"),
("disque", "proxmox_disque_taille"), ("memoire", "proxmox_memoire"),
("coeurs", "proxmox_coeurs")]
# --- Bindings (liens) : relations declaratives entre applications -----------------
# Une application declare ses 'liens' ([{vers, role}]) dans plan/applications.yml ;
# chaque role decrit les liens qu'il accepte dans roles/<groupe>/meta/liens.yml
# (setops_liens.accepte). instancier resout la cible (FQDN interne derive) et injecte
# les variables en host_vars du consommateur. Cf. docs/bindings-conception.md.
def _domaine_interne() -> str:
"""Lit domaine_interne dans group_vars/all de l'instance (keystone, non chiffre)."""
base = INVENTAIRE.parent / "group_vars" / "all"
if base.is_dir():
for fichier in sorted(base.glob("*.yml")):
try:
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
continue
if isinstance(data, dict) and str(data.get("domaine_interne", "")).strip():
return str(data["domaine_interne"]).strip()
return ""
def _fiche_du_site() -> dict | None:
"""La fiche que l'hebergeur a deposee chez CE locataire (`fiche-site.yml`), ou None.
ETAPE 3 DU CHEMIN (docs/conception-contextes.md §6). Trois valeurs de l'inventaire venaient
d'une lecture DIRECTE du site, monte sur le poste : la zone a deleguer et ses resolveurs,
les serveurs de temps, le mode de routage. Generer sur le runner du locataire, qui n'a pas
le depot du site, les aurait perdues. La fiche les porte, et l'inventaire devient le meme
ou qu'on le genere. Sans fiche (transition), on lit encore le site, et on le dit.
"""
try:
import contexte
return contexte.Locataire(INSTANCE.resolve()).fiche_du_site()
except Exception:
return None
FICHE_SITE = _fiche_du_site()
if FICHE_SITE is None:
print("note : pas de `fiche-site.yml` chez ce locataire — temps, delegation DNS et routage "
"lus directement dans le site monte (transition).", file=sys.stderr)
def _zones_deleguees() -> list:
"""La zone de l'HEBERGEUR, et l'adresse a qui la demander. [] si aucune carte.
POURQUOI CETTE DERIVATION EXISTE (mesure du 2026-09-02, reconstruction de Chezlepro
depuis zero). Un locataire depend de services du SITE par leur NOM : son cache
d'artefacts, sa forge, son depot de sauvegarde, son autorite. Ces noms vivent dans une
zone que SON resolveur ne sert pas.
Tant que ses machines pointaient directement sur le resolveur du site, ca marchait —
par accident. Reconstruites proprement, elles utilisent LEUR resolveur, qui ignorait
la zone de l'hebergeur : la recette a echoue sur la RESTITUTION d'une sauvegarde, avec
« Could not resolve hostname sauvegarde.genese.internal ». La sauvegarde etait
intacte ; c'est le chemin pour la NOMMER qui manquait.
DERIVEE DU PLAN DU SITE, PAS DE `dns_amorcage` — et la nuance a failli couter cher.
La premiere version prenait `dns_amorcage` pour « l'adresse du resolveur du site ».
C'est vrai chez Chezlepro ; c'est FAUX chez Technolibre, dont l'amorcage pointe sur
`9.9.9.9,149.112.112.112`. On aurait delegue la zone souveraine de l'hebergeur a
Quad9, qui n'en sait rien — et le NXDOMAIN rendu aurait ressemble a une zone vide.
P03 l'a attrape avant tout deploiement, en comparant l'inventaire de CHAQUE instance
a son plan.
#
L'adresse vient donc du PLAN DU SITE : la machine qui y porte `serveur_resolveur`.
C'est la seule source qui dise, par construction, qui resout la zone du site.
REND [] QUAND LA CARTE N'EST PAS MONTEE, et c'est le comportement d'un EMANCIPE : il
ne delegue plus rien, donc ne peut plus nommer les services du site. C'est ce qu'on
veut CONSTATER d'une emancipation, pas une panne a reparer.
"""
if FICHE_SITE is not None:
gen = FICHE_SITE.get("generation") or {}
zone = str(gen.get("zone_site") or "").strip()
if not zone or zone == _domaine_interne():
return []
adresses = [str(a) for a in gen.get("resolveurs_site") or [] if a]
return [{"nom": zone, "adresses": adresses}] if adresses else []
try:
import underlay as underlay_mod
zone = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
.get("domaine_interne") or "").strip()
except Exception:
return []
# Un site qui s'heberge lui-meme partage la zone de son instance : il n'y a alors
# rien a deleguer, et se declarer transitaire de soi-meme ferait une boucle.
if not zone or zone == _domaine_interne():
return []
try:
adresses = [a for a in underlay_mod.adresses_site_portant("serveur_resolveur") if a]
except Exception:
adresses = []
if not adresses:
return []
return [{"nom": zone, "adresses": adresses}]
def _attributs_cible(vers: str, apps: dict, serveurs: dict, nomenclature: dict, domaine: str) -> dict | None:
"""Resout une cible de lien en attributs substituables. Phase 1 : applications."""
if vers in apps:
hote = apps[vers].get("hote")
srv = serveurs.get(hote, {})
_, seq = fonction_seq(str(hote))
d = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {}
return {
"genre": "application",
"hote": hote,
"fqdn": f"{hote}.{domaine}" if domaine else str(hote),
"ip": d.get("adresse_ip"),
}
return None
def _substituer(valeur, attrs: dict):
"""Remplace {cible.X} dans une chaine ; laisse bool/int tels quels."""
if not isinstance(valeur, str):
return valeur
for cle, val in attrs.items():
valeur = valeur.replace("{cible." + cle + "}", str(val))
return valeur
def resoudre_liens(apps: dict, serveurs: dict, nomenclature: dict) -> dict:
"""Derive {hote: {var: valeur}} depuis les 'liens' des applications (bindings)."""
domaine = _domaine_interne()
injections: dict = {}
for nom_app, app in apps.items():
accepte = None
for lien in (app.get("liens") or []):
vers, role = lien.get("vers"), lien.get("role")
if accepte is None:
accepte = liens_acceptes(str(app.get("groupe", "")), RACINE)
spec = accepte.get(role)
if not spec:
raise ValueError(
f"application '{nom_app}': lien role '{role}' non accepte par "
f"'{app.get('groupe')}' (roles/{app.get('groupe')}/meta/liens.yml).")
attrs = _attributs_cible(str(vers), apps, serveurs, nomenclature, domaine)
if attrs is None:
raise ValueError(f"application '{nom_app}': cible de lien '{vers}' introuvable.")
attendu = spec.get("cible")
if attendu and attendu != attrs["genre"]:
raise ValueError(
f"application '{nom_app}': lien '{role}' attend une cible '{attendu}', "
f"'{vers}' est '{attrs['genre']}'.")
sur_conso = spec.get("injecte_sur", "consommateur") == "consommateur"
cible_hote = app.get("hote") if sur_conso else attrs["hote"]
for var, gabarit in (spec.get("variables") or {}).items():
injections.setdefault(cible_hote, {})[var] = _substituer(gabarit, attrs)
return injections
def generer() -> dict:
serveurs = charger_serveurs(FICHIER_SERVEURS).get("serveurs", {})
apps = charger_applications(FICHIER_APPLICATIONS).get("applications", {})
nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE)
injections = resoudre_liens(apps, serveurs, nomenclature)
# Expositions du plan (champ 'expose') -> pour deriver les SANs de chaque edge.
domaines = charger_domaines(FICHIER_DOMAINES)
expositions = expositions_des_applications({"applications": apps}, domaines)
# LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE (2026-09-10).
#
# Quatre roles — grafana, forgejo, nextcloud, keycloak — portaient en defaut une
# DEVINETTE du nom sous lequel ils sont servis (`grafana.{{ domaine_interne }}`).
# Tant que le plan suit la meme convention, la devinette tombe juste et personne ne
# voit qu'il y a deux sources. Renommer une exposition la fait mentir : nginx sert le
# nouveau nom, le certificat le porte, la zone le publie — et le service continue de
# fabriquer ses URL de retour avec l'ancien. Le SSO renvoie alors dans le vide.
#
# On derive donc `<groupe>_hostname` de l'exposition declaree, quand elle est UNIQUE
# (deux expositions ne designent aucun nom canonique : on laisse le role choisir).
# `setdefault` cote hote : une valeur posee a la main dans les group_vars gagne encore.
# PAR HOTE *ET* PAR GROUPE, PAS PAR GROUPE SEUL (2026-09-15).
#
# Un ROLE peut servir DEUX applications sur DEUX machines. `serveur_oauth2_proxy` en
# est l'exemple : une instance devant la vigie sur le noeud de supervision, une autre
# devant la console sur le runner. Deux passerelles, deux clients Keycloak, deux noms.
#
# Indexee par groupe seul, la table gardait le DERNIER nom rencontre et le donnait a
# TOUS les hotes du groupe. Mesure du 2026-09-15 sur Chezlepro :
#
# mon-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
# ops-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
#
# La passerelle de la VIGIE se croyait donc la console : son URL de retour OIDC aurait
# vise l'autre machine, et le SSO de la supervision serait tombe. Le defaut du
# 2026-09-10 — « le service porte le nom que son role devine » — reapparaissait par
# une autre porte, et P67 ne le voyait pas puisqu'elle comparait a la valeur qu'elle
# derivait elle-meme de la meme facon.
#
# Une application nomme un COUPLE (machine, role). C'est la clef juste.
hostname_par_hote_groupe: dict = {}
for app in apps.values():
exp = [str(f).strip() for f in (app.get("expose") or []) if str(f).strip()]
groupe, hote = str(app.get("groupe") or ""), str(app.get("hote") or "")
if groupe and hote and len(exp) == 1:
hostname_par_hote_groupe[(hote, groupe)] = exp[0]
# La patte de la frontiere que les tenants traversent — calculee une fois.
_temps_frontiere: list = []
try:
_carte = None if FICHE_SITE is not None else underlay_mod.charger()
if FICHE_SITE is not None:
_temps_frontiere = [str(x) for x in (FICHE_SITE.get("generation") or {})
.get("serveurs_temps") or []]
elif _carte:
# LE LIEN DE TRANSIT SE DECLARE, IL NE SE DEVINE PAS. `reseau_transit()`
# le derive de `passerelle_sortie` — le champ qui dit « c'est par la qu'on
# sort ». Le reconnaitre a son prefixe d'adresse aurait marche ici et menti
# chez le prochain hebergeur.
_t = underlay_mod.reseau_transit(_carte) or {}
_res_transit = {str(_t.get("nom"))} if _t.get("nom") else set()
_temps_frontiere = sorted({str(h["ip"]) for h in underlay_mod.hotes(_carte)
if h.get("role") == "frontiere" and h.get("ip")
and str(h.get("reseau")) in _res_transit
and str(h.get("etat", "actif")) != "reserve"})
except Exception:
_temps_frontiere = []
services_par_hote: dict = {}
for app in apps.values():
services_par_hote.setdefault(app.get("hote"), set()).add(app.get("groupe"))
# Politique d'integration : lue une fois, appliquee a tous (voir D-33).
universelles = integrations_universelles(RACINE)
# TOUS les groupes de service du plan : une integration universelle dont le
# service central n'existe pas ici n'a personne a qui parler (2026-08-22).
services_du_plan = {g for gs in services_par_hote.values() for g in gs}
# Rattachement reseau d'une VM. En SDN, elle appartient a son VNet — pas a un pont
# de l'hyperviseur avec une etiquette. Le nom du VNet se DERIVE (index + zone),
# exactement comme le VMID et l'adresse : rien a declarer, rien qui puisse diverger.
#
# Sans cette derivation, `make creer-vm` posait les VM sur `proxmox_clone_pont`
# (`vmbr1`) avec l'etiquette du VLAN — l'ancien monde. C'est ce qu'il a fallu
# corriger a la main sur infra-pki-01, et les treize suivantes auraient suivi.
#
# L'ETIQUETTE DOIT ETRE VIDE en SDN : le VNet la porte deja. En poser une seconde
# au niveau de la carte donnerait un double etiquetage.
sdn = (str((FICHE_SITE.get("generation") or {}).get("routage_tenants")) == "sdn"
if FICHE_SITE is not None
else underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn")
index_tenant = nomenclature.get("index")
# UNE MACHINE SANS FONCTION DECLAREE N'A PAS D'ADRESSE — ET LE DISAIT PAS (2026-08-24).
#
# `deriver_nomenclature(...) or {}` avale l'echec : une fonction absente de
# `nomenclature.fonctions` rend un dictionnaire vide, et la machine entre dans
# l'inventaire avec `ansible_host: None` et `vlan: None`. La generation se declare
# REUSSIE. La panne n'apparait qu'au deploiement, sous une forme incomprehensible —
# Ansible tentant de joindre une adresse qui n'existe pas.
#
# Constate en ajoutant `ops-01` aux modeles : `presence-web` range son socle en zone 1
# (« Fondations ») et n'a pas de categorie 4. La machine a ete posee, la fonction non,
# et rien ne l'a signale.
#
# On refuse en BLOC plutot qu'a la premiere fautive : l'exploitant corrige son plan
# une fois, pas une machine a la fois.
# CE GENERATEUR EST CELUI DES TENANTS, ET DE PERSONNE D'AUTRE (2026-08-24).
#
# J'y avais ajoute une branche « adressage declare » pour y faire entrer les machines
# d'un SITE. C'etait le debut d'une confusion : un site et un tenant ne sont pas deux
# variantes d'une meme chose. Un site est le TERRAIN, un tenant ce qui y pousse.
#
# Le symptome etait visible tout de suite — une branche `si l'adresse est declaree`,
# un `nomenclature.yml` de site reduit a une coquille vide, et un site exclu des devis
# par ABSENCE d'index plutot que par nature. Une exclusion fondee sur un manque est
# une exclusion qu'un ajout innocent casserait.
#
# Les machines de l'hebergeur se declarent desormais dans `underlay.yml`, a cote des
# switches et des hyperviseurs — du mobilier de fabric. Ce qui se partage entre les
# deux mondes, ce sont les ROLES, pas la forme du plan.
sans_fonction = [
nom for nom, srv in serveurs.items()
if not (deriver_nomenclature(str(srv.get("fonction", "")),
fonction_seq(nom)[1], nomenclature) or {}).get("adresse_ip")
]
if sans_fonction:
connues = ", ".join(sorted((nomenclature.get("fonctions") or {}))) or "(aucune)"
raise SystemExit(
"Machines sans adresse derivable — leur `fonction` n'est pas declaree dans "
"`nomenclature.fonctions` :\n"
+ "".join(f" - {n} : fonction « {serveurs[n].get('fonction', '')} »\n" for n in sans_fonction)
+ f"Fonctions connues de ce plan : {connues}")
zones_deleguees = _zones_deleguees()
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 = {
# Supernet du tenant, DERIVE du seed comme tout le reste. Il repond a la
# question « quels clients mon service doit-il accepter ? », posee par
# PostgreSQL (pg_hba), Postfix (reseaux de confiance) et tout service qui
# filtre par reseau. Le figer a la main dans group_vars, c'etait garantir
# qu'il devienne faux : les deux valeurs trouvees le 2026-08-07 dataient de
# l'ancien monde (10.11.0.0/16 pour un tenant en 10.27.0.0/16), avec un
# commentaire « AJUSTER » jamais suivi. PostgreSQL refusait Keycloak.
"setops_supernet": supernet_de(int(index_tenant)) if index_tenant is not None else None,
"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,
}
if sdn and index_tenant is not None and d.get("zone") is not None:
hostvars["proxmox_pont"] = vnet_de(int(index_tenant), d["zone_libelle"])
hostvars["proxmox_etiquette_vlan"] = "" # le VNet porte deja le tag
else:
hostvars["proxmox_etiquette_vlan"] = d.get("vlan")
for cle_srv, cle_var in PLACEMENT:
if str(srv.get(cle_srv, "")).strip():
hostvars[cle_var] = srv[cle_srv]
# Ressources derivees des logiciels (groupes de service) + socle SE.
# setdefault : une valeur explicite du plan (PLACEMENT ci-dessus) gagne.
ressources = deriver_ressources(sorted(services_par_hote.get(nom, set())), ROLES)
hostvars.setdefault("proxmox_coeurs", ressources["coeurs"])
hostvars.setdefault("proxmox_memoire", ressources["memoire"])
# LE PLANCHER — ce que l'hote peut REPRENDRE, derive de ce que la machine PORTE.
# Voir `inventory_rules.plancher_memoire` : une base de donnees garde un plancher
# haut, un service au repos non. `setdefault` laisse le plan trancher.
hostvars.setdefault("proxmox_memoire_min", plancher_memoire(
int(hostvars["proxmox_memoire"]), sorted(services_par_hote.get(nom, set()))))
hostvars.setdefault("proxmox_disque_taille", ressources["disque"])
# Variables derivees des liens (bindings) declares par les applications.
for var, val in injections.get(nom, {}).items():
hostvars[var] = val
services = services_par_hote.get(nom, set())
groupes = set(GROUPES_SOCLE) | services | integrations_de(srv, services, universelles, services_du_plan=services_du_plan)
# SANs d'exposition : les FQDN que cet edge sert (derives du plan) -> cert edge
# (client_pki_sans), au lieu d'une liste manuelle. Vide si l'hote n'est pas un edge.
sans = sorted({e["fqdn"] for e in expositions if e.get("edge") in groupes})
if sans:
hostvars["sans_exposition"] = sans
# L'AUTORITE DE TEMPS, DERIVEE DE L'UNDERLAY (2026-09-11).
#
# Un tenant atteint la frontiere par le LIEN DE TRANSIT : ses passerelles de zone
# (10.17.x.1) sont tenues par le SDN de Proxmox, pas par elle. On prend donc la
# patte de la frontiere active posee sur le reseau que les tenants traversent.
#
# `etat: reserve` est exclu : la seconde frontiere est declaree et ne porte encore
# aucune adresse. Vide si aucun underlay — le role ne touchera alors a rien.
if _temps_frontiere:
hostvars.setdefault("chrony_serveurs", _temps_frontiere)
# Nom public de chaque service porte par cet hote, tel que le plan l'expose.
for groupe_expose in sorted(groupes):
fqdn = hostname_par_hote_groupe.get((nom, groupe_expose))
if fqdn:
hostvars.setdefault(f"{groupe_expose}_hostname", fqdn)
# Le resolveur de l'ecosysteme doit savoir a qui demander la zone de l'hebergeur.
if "serveur_resolveur" in groupes and zones_deleguees:
hostvars["serveur_resolveur_zones_deleguees"] = zones_deleguees
etat = "hotes_actifs" if srv.get("etat") == "actif" else "hotes_planifies"
children[etat]["hosts"][nom] = hostvars
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 ecriture_atomique(path) 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]:
cmd = ["ansible-inventory", "-i", str(fichier), "--list"]
# Une voute chiffree (group_vars/all/vault.yml) fait echouer ansible-inventory sans
# mot de passe (exit 4).
#
# UNE VOUTE, UNE CLE (depuis le 2026-08-28). Ce bloc tentait
# `~/.config/setops-vault-pass` — le mot de passe UNIQUE d'avant la separation, qui
# n'ouvre plus rien. `instancier comparer` echouait donc en exit 4 sur un message qui
# ne parlait que d'`ansible-inventory` : on ne pouvait plus relire l'inventaire en
# place pour le comparer au genere, donc plus l'appliquer sans `--force`, donc plus
# voir ce qu'on changeait.
#
# `voutes.py identites` rend la liste `etiquette@chemin` de toutes les voutes
# connues ; Ansible essaie chacune et retient celle qui ouvre. On ne pose rien si
# l'appelant a deja renseigne l'environnement : sa valeur est plus precise que la
# notre.
if not os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") \
and not os.environ.get("ANSIBLE_VAULT_IDENTITY_LIST"):
try:
ident = subprocess.run(
[sys.executable, str(Path(__file__).parent / "voutes.py"), "identites"],
capture_output=True, text=True, check=True).stdout.strip()
except (subprocess.CalledProcessError, OSError):
ident = ""
if ident:
os.environ["ANSIBLE_VAULT_IDENTITY_LIST"] = ident
sortie = subprocess.run(cmd, 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(strict: bool = False) -> int:
"""Compare le genere a l'applique. `strict` fait ECHOUER sur un ecart.
DEUX APPELANTS, DEUX BESOINS. `make instancier` est une INSPECTION : on veut voir
le diff avant de decider, et un code d'erreur y transformerait la lecture en panne.
La preuve P03, elle, AFFIRME que « le plan reproduit l'inventaire » — sans `strict`,
elle renvoyait 0 quel que soit le nombre d'hotes divergents et ne pouvait donc pas
echouer. Constate le 2026-08-12 : quatorze hotes divergeaient, la preuve passait.
"""
ecrire(GENERE)
ecarts = compter_ecarts(INVENTAIRE, GENERE)
if ecarts == 0:
print("DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible.")
return 0
print(f"\n{ecarts} hote(s) avec ecart.")
if strict:
print("L'inventaire APPLIQUE ne correspond plus au plan. Soit le plan a change et "
"il faut le basculer (`make instancier-appliquer`), soit hosts.yml a ete "
"edite a la main — ce que le depot interdit.")
return 1 if strict else 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.")
sp_cmp = sub.add_parser("comparer", help="Compare le genere a l'inventaire actuel (semantique).")
sp_cmp.add_argument("--strict", action="store_true",
help="Sortir en erreur si le diff n'est pas vide (P03).")
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(strict=getattr(args, "strict", False))
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())