#!/usr/bin/env python3 """Inventaire dynamique des machines de l'HEBERGEUR, lu dans `underlay.yml`. ansible-playbook -i scripts/site_inventaire.py playbooks/groupes/serveur_ops_site.yml POURQUOI DYNAMIQUE, ET PAS UN `hosts.yml` GENERE. La regle du depot est qu'un inventaire de tenant est un ARTEFACT GENERE, jamais edite a la main : on edite le plan, puis `make instancier`. Cette regle existe parce qu'un tenant DERIVE tout de son index — le fichier intermediaire est le prix a payer pour rendre la derivation inspectable. Un site ne derive de rien. Sa declaration EST deja sa forme finale : `underlay.yml` dit l'adresse, le nœud, le pont, les services. Materialiser un second fichier qui repete la meme chose ne rendrait rien plus inspectable — ca creerait seulement un artefact de plus a garder en phase, et une occasion de plus de le laisser deriver de sa source. LES GROUPES SONT LES SERVICES. Une machine qui declare `services: [serveur_ops_site]` atterrit dans le groupe `serveur_ops_site`, et `playbooks/groupes/serveur_ops_site.yml` la trouve sans qu'on ait rien a cabler. C'est la meme convention que pour les tenants : ce qui se partage entre un site et un tenant, ce sont les ROLES, pas la forme du plan. """ from __future__ import annotations import argparse import json import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import yaml # noqa: E402 import underlay as U # noqa: E402 from inventory_rules import integrations_universelles # noqa: E402 # L'inventaire des tenants passe par ce compte ; le site n'a aucune raison d'en differer. UTILISATEUR_DEFAUT = "ansible" # LE SOCLE S'APPLIQUE AUSSI AU SITE. Une machine de l'hebergeur n'est pas d'une autre # espece : elle veut le meme durcissement SSH, les memes horloges, le meme pare-feu que # n'importe quelle machine de la flotte. L'oublier lui aurait donne un SSH par defaut et # aucune regle de frontiere — la machine la plus puissante du site, la moins protegee. GROUPE_SOCLE = "serveur_debian" # LE PLANCHER `/etc/hosts` SE DERIVE DE CE GROUPE, et de nul autre. # # `hosts_statiques` construit sa liste depuis `hotes_actifs` + `hotes_planifies` — des # groupes qu'un inventaire de tenant fabrique, et que celui du site ne fabriquait pas. Le # role tournait donc sans erreur et ecrivait un plancher VIDE : `localhost`, et rien # d'autre. Un succes qui n'a rien fait, la pire forme d'echec. # # Le site n'a pas d'hotes « planifies » — une machine y est declaree ou elle n'existe pas. # Elles vont donc toutes dans `hotes_actifs`. GROUPE_FLOTTE = "hotes_actifs" def plan_dir() -> Path | None: """Le `plan/` du SITE, a cote de son `underlay.yml` — derive du symlink, jamais redit. UN SITE A BIEN UN PLAN (2026-08-25). Ce qui le distingue d'un tenant n'est pas l'absence de plan mais l'absence de DERIVATION : un tenant tire son adressage de son `index`, un site le declare, parce qu'il EST le terrain. Il ne passe donc pas par `instancier` — mais il a bien des machines, des services et des intrants a declarer. Faute d'avoir fait cette distinction, ce plan a d'abord ete reconstruit morceau par morceau dans `underlay.yml`, sans etre nomme : chaque manque est arrive par surprise au lieu d'etre previsible. `underlay.yml` decrit ce qui est MESURE (la fabric) ; le plan decrit ce qui est VOULU. """ c = U.chemin() return (c.resolve().parent / "plan") if c else None def _charger(nom: str) -> dict: d = plan_dir() f = (d / nom) if d else None if not f or not f.is_file(): return {} return yaml.safe_load(f.read_text(encoding="utf-8")) or {} def inventaire() -> dict: u = U.charger() if u is None: # Aucun underlay monte : un inventaire VIDE, pas une erreur. Ansible doit pouvoir # etre lance sans site sans que ca ressemble a une panne. return {"_meta": {"hostvars": {}}} intrants = _charger("10-intrants.yml") serveurs = (_charger("serveurs.yml") or {}).get("serveurs") or {} applications = (_charger("applications.yml") or {}).get("applications") or {} if not serveurs: return {"_meta": {"hostvars": {}}} par_reseau = {r.get("nom"): r for r in U.reseaux(u)} # Les sous-reseaux ou vivent REELLEMENT des machines du site : l'etendue de # l'ecosysteme, telle que le plan la dessine. _zones_du_site = sorted({str((par_reseau.get(s.get("reseau")) or {}).get("sous_reseau")) for s in serveurs.values() if str(s.get("etat", "actif")) == "actif" and (par_reseau.get(s.get("reseau")) or {}).get("sous_reseau")}) # LE RESOLVEUR D'AMORCAGE SE DERIVE, IL NE S'ECRIT PAS (2026-08-25). # # Il a ete ecrit en dur deux fois, et il a eu tort les deux fois : d'abord la frontiere # (`10.0.3.1`) alors que le site avait son DNS, puis `10.0.3.51` — juste jusqu'a ce que # le decoupage en zones deplace la machine en `10.0.34.11`. # # Une adresse ecrite a la main est une copie ; une copie se perime. Celle-ci se lit # desormais la ou elle est vraie : l'hote qui porte `serveur_resolveur` dans ce plan. # Le plan peut encore la surcharger — pour l'amorcage d'un site tout neuf, ou le # resolveur n'existe pas encore — mais ce n'est plus le cas normal. _resolveur = next((str(s.get("ip")) for nom, s in serveurs.items() if any(a.get("hote") == nom and a.get("groupe") == "serveur_resolveur" for a in applications.values()) and str(s.get("etat", "actif")) == "actif"), "") # Ce que TOUTE machine du site recoit, des intrants du plan. communes: dict = {} if _resolveur: communes["dns_amorcage"] = _resolveur for cle in ("domaine_interne", "organisation", "dns_amorcage"): if intrants.get(cle): communes[cle] = str(intrants[cle]) if intrants.get("cle_ssh"): communes["ansible_ssh_private_key_file"] = intrants["cle_ssh"] if intrants.get("rebond"): communes["ansible_ssh_common_args"] = ( f"-o ProxyJump={intrants['rebond']} -o StrictHostKeyChecking=no") # Les integrations universelles, moins celles que le site ne peut pas honorer. exemptees = dict(intrants.get("integrations_exemptes") or {}) universelles = [r for r in integrations_universelles() if r not in exemptees] # LES EXPOSITIONS : le nom du SERVICE, pas celui de la machine. Un nom de service # survit au demenagement du service ; un nom de machine, non. # # `edge` nomme le GROUPE qui sert ce FQDN — c'est ce qu'attend `hosts_statiques`. # Chez un tenant c'est l'edge nginx, qui termine le TLS pour tout le monde. Le site # n'a pas d'edge : chaque service se sert lui-meme, donc le groupe est le sien. La # forme reste la meme, ce qui la remplit change. expositions: list[dict] = [] for nom_app, app in applications.items(): for fqdn in (app.get("expose") or []): expositions.append({"fqdn": str(fqdn), "edge": str(app.get("groupe") or ""), "application": nom_app}) # --- LES GARDES DU PLAN, LA OU LE PLAN EST LU ---------------------------- # # Elles vivaient dans `underlay.valider()` tant que les machines habitaient la carte. # Le plan les emporte avec lui : une garde loin de ce qu'elle garde finit par garder # autre chose. Elles refusent ici, avant que quoi que ce soit ne soit clone. erreurs: list[str] = [] noeuds = {h.get("nom") for h in U.hotes(u) if h.get("role") == "hyperviseur"} vus_ip: dict[str, str] = {str(h.get("ip")): str(h.get("nom")) for h in U.hotes(u)} vus_vmid: dict[int, str] = {} for nom, srv in serveurs.items(): if str(srv.get("etat", "actif")) != "actif": continue r = par_reseau.get(srv.get("reseau")) if not r: erreurs.append(f"serveur '{nom}': reseau '{srv.get('reseau')}' inconnu de " f"`underlay.yml` — la fabric est la source de ces valeurs") continue if not str(r.get("pont", "") or "").strip(): erreurs.append(f"serveur '{nom}': le reseau '{r.get('nom')}' ne declare aucun " f"`pont:` — aucun pont d'hyperviseur ne le porte, une VM y " f"naitrait sourde") if srv.get("noeud") not in noeuds: erreurs.append(f"serveur '{nom}': noeud '{srv.get('noeud')}' n'est pas un " f"hyperviseur declare dans `underlay.hotes`") ip = str(srv.get("ip") or "") if ip in vus_ip: erreurs.append(f"serveur '{nom}': IP {ip} deja prise par '{vus_ip[ip]}'") vus_ip[ip] = nom vmid = srv.get("vmid") if not isinstance(vmid, int): erreurs.append(f"serveur '{nom}': `vmid` absent ou non entier") elif vmid in vus_vmid: erreurs.append(f"serveur '{nom}': vmid {vmid} en double avec " f"'{vus_vmid[vmid]}'") else: vus_vmid[vmid] = nom for nom_app, app in applications.items(): if app.get("hote") not in serveurs: erreurs.append(f"application '{nom_app}': hote '{app.get('hote')}' n'est pas " f"declare dans `plan/serveurs.yml`") sans_service = [n for n in serveurs if not any(a.get("hote") == n for a in applications.values())] if sans_service: erreurs.append("serveur(s) sans aucune application : " + ", ".join(sorted(sans_service)) + " — une machine du site existe POUR un role") if erreurs: raise SystemExit("plan du site refuse :\n - " + "\n - ".join(erreurs)) hostvars: dict[str, dict] = {} groupes: dict[str, list[str]] = {} for nom, srv in serveurs.items(): if str(srv.get("etat", "actif")) != "actif": continue r = par_reseau.get(srv.get("reseau")) or {} hostvars[nom] = { "ansible_host": str(srv["ip"]), "ansible_user": srv.get("utilisateur", UTILISATEUR_DEFAUT), "site_reseau": srv.get("reseau"), "site_pont": r.get("pont"), "site_noeud": srv.get("noeud"), "proxmox_vmid": srv.get("vmid"), # L'espace d'adressage de cet ecosysteme. Un tenant le derive de son index ; # le site le tient du reseau ou vivent ses machines. Meme sens, autre source. # `serveur_resolveur` s'en sert pour savoir QUI a le droit de l'interroger. "setops_supernet": str(r.get("sous_reseau") or ""), # QUI A LE DROIT D'INTERROGER LE RESOLVEUR — TOUTES LES ZONES DU SITE. # # `serveur_resolveur` derive sa liste d'autorisation de `setops_supernet`, qui # vaut le sous-reseau de la machine. C'etait juste tant que le site etait un # `/24` plat. Decoupe en zones, le resolveur n'autorisait plus que la SIENNE : # les trois autres se faisaient refuser, et la panne ressemble a un DNS mort # alors que c'est une ACL. # # Un resolveur ouvert au monde est un relais d'amplification ; celui-ci est # ouvert a son ecosysteme, et a rien d'autre. "serveur_resolveur_reseaux_autorises": _zones_du_site, # Un role partage peut avoir besoin de savoir de quel cote il tourne. "setops_site": True, # Le plan du site, pour les roles qui lisent des registres. "setops_plan_dir": str(plan_dir() or ""), "hosts_statiques_expositions": expositions, **communes, # En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le # plan declare pour toutes. **(srv.get("variables") or {}), } groupes.setdefault(GROUPE_SOCLE, []).append(nom) groupes.setdefault(GROUPE_FLOTTE, []).append(nom) for integ in universelles: groupes.setdefault(integ, []).append(nom) for integ in (srv.get("integrations") or []): groupes.setdefault(str(integ), []).append(nom) # LES GROUPES VIENNENT DU REGISTRE DES APPLICATIONS, comme chez un tenant. for app in applications.values(): hote, groupe = app.get("hote"), app.get("groupe") if hote in hostvars and groupe: groupes.setdefault(str(groupe), []).append(hote) out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()} out["site"] = {"hosts": sorted(hostvars)} out["_meta"] = {"hostvars": hostvars} return out def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--list", action="store_true") ap.add_argument("--host") a = ap.parse_args() if a.host: print(json.dumps(inventaire().get("_meta", {}).get("hostvars", {}).get(a.host, {}))) else: print(json.dumps(inventaire(), indent=2, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())