Some checks are pending
verifier / verifier (push) Waiting to run
Le controle de dependances a REFUSE le deploiement de patient 0 : client_journal requiert serveur_loki, client_metrique requiert serveur_prometheus, serveur_forgejo requiert serveur_postgresql et serveur_postfix. Quatre refus, une seule racine — le moteur suppose que tout ecosysteme porte tous les services. Apres les intrants (P32) et les bases (P35), troisieme manifestation en cinq jours. Et le mur que rencontrerait toute offre plus petite que l'ecosysteme de reference. UNE INTEGRATION UNIVERSELLE A BESOIN D'UN INTERLOCUTEUR. « Tout hote est mesure » est vrai dans un ecosysteme qui porte un Prometheus ; ailleurs, la meme phrase pose sur chaque machine un client qui n'a personne a qui parler. La regle est desormais DERIVEE : le service central d'une integration est celui que le registre des dependances lui donne deja. Rien de neuf a tenir a jour, donc rien de neuf a oublier. Chezlepro -> diff VIDE (tous ses services existent, rien ne change) patient 0 -> client_backup, client_pki, client_unbound UNE EXIGENCE N'EST PAS TOUJOURS ABSOLUE. Deux notions manquaient au registre : `sauf_si` l'exigence tombe sous condition (Forgejo + SQLite) `utilise_si_present` un agrement, jamais bloquant (Forgejo notifie SI un MTA existe) Les confondre obligeait une forge a deployer une pile courriel entiere pour exister. ET LA LECON D'HIER A SERVI : trois lecteurs avaient besoin le meme jour de lire une variable d'instance (P35, les clauses sauf_si, le generateur). Trois copies auraient recommence ce qu'on venait de refermer. Il y en a UNE, dans inventory_rules. make verifier 41/41 ; make ci 41/41 ; inventaire de Chezlepro identique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
349 lines
16 KiB
Python
349 lines
16 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 (
|
|
charger_applications,
|
|
charger_domaines,
|
|
charger_nomenclature,
|
|
charger_serveurs,
|
|
deriver_nomenclature,
|
|
deriver_ressources,
|
|
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 _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)
|
|
|
|
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 = underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn"
|
|
index_tenant = nomenclature.get("index")
|
|
|
|
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"])
|
|
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
|
|
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 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]:
|
|
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). Si ANSIBLE_VAULT_PASSWORD_FILE n'est pas deja fourni, on
|
|
# tente le fichier conventionnel Set-OPS ~/.config/setops-vault-pass.
|
|
if not os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"):
|
|
conv = Path.home() / ".config" / "setops-vault-pass"
|
|
if conv.is_file():
|
|
cmd += ["--vault-password-file", str(conv)]
|
|
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())
|