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>
572 lines
28 KiB
Python
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())
|