#!/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. # # CE COMMENTAIRE DISAIT VRAI, LE CODE N'EN FAISAIT QUE LA MOITIE (2026-09-02). # # Seul `serveur_debian` etait pose. `serveur_durci` — auditd, fail2ban, apparmor, # sysctl, unattended-upgrades, journald, ssh_hardening, nftables — ne l'etait pas. Un # inventaire de tenant met CHAQUE machine dans les deux (`inventory_host.py`) ; celui du # site n'en mettait aucune dans le second. # # Ce que ca laissait sans protection : la racine de l'autorite de certification, la # forge qui porte le genome, le cache d'artefacts par ou passe chaque paquet installe # sur chaque machine de chaque locataire, et le depot ou tous deposent leur etat. Les # machines les plus consequentes de l'installation etaient les moins durcies — et # personne ne pouvait le voir, puisque le commentaire affirmait le contraire. # # TROUVE EN CHERCHANT AUTRE CHOSE : un depot de sauvegarde refusait une cle valide, et # le fichier de durcissement SSH fautif datait d'une version abandonnee le 2026-08-09. # Rien ne l'avait jamais remplace parce que rien n'appliquait le role qui le remplace. GROUPE_SOCLE = ["serveur_debian", "serveur_durci"] # 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": {}}} # LES SUPERNETS DES TENANTS QUE CE SITE HEBERGE. # # LA MEME SOURCE QUE LES FLUX, UN FILTRE DIFFERENT — et la difference compte. # # `resoudre_flux._supernets_voisins()` EXCLUT l'instance montee : il sert a declarer # les flux d'un tenant vers ses VOISINS, et personne n'est son propre voisin. Le site, # lui, sert TOUS ceux qu'il heberge — y compris celui qu'on pilote au moment ou l'on # genere cet inventaire. Reutiliser la fonction telle quelle aurait prive de # resolution le seul tenant qu'on est en train de deployer, et la panne serait apparue # chez lui seul (constate en la branchant, le 2026-08-30 : Chezlepro manquait). # # On reutilise donc la DECOUVERTE partagee (`decouvrir_du_site`, lecon de P41) sans # son filtre : deux recensements de tenants finiraient par diverger, deux filtres non. # # Rend [] si la carte de l'hebergeur n'est pas montee : le site se decrit alors comme # avant, sans preter son resolveur. Degrader, jamais deviner. try: import devis_reseau from inventory_rules import supernet_de _supernets_tenants = sorted({supernet_de(int(n["index"])) for _nom, _p, n in devis_reseau.decouvrir_du_site() if n.get("index") is not None}) except Exception: _supernets_tenants = [] # LES LOCATAIRES DU DEPOT DE SAUVEGARDE — UN COMPTE UNIX CHACUN. # # Le depot du site recoit l'etat de PLUSIEURS ecosystemes. `serveur_backup`, concu # pour le depot d'un tenant, n'a qu'un compte et qu'une cle : la ou il n'y a qu'un # locataire, ca suffit. Ici, ca ne suffit plus — une seule cle partagee donnerait a # chaque tenant la lecture des instantanes de tous les autres. Une sauvegarde qui # expose ce qu'elle protege ne protege rien. # # D'ou un compte par locataire, chacun avec SON home en 0700 et SA cle. L'isolation # est alors celle du noyau, pas une convention de nommage de repertoires. # # La cle PUBLIQUE d'un tenant n'est pas un secret : elle vit deja en clair dans son # `group_vars/serveur_backup.yml`. C'est la MOITIE PRIVEE qui reste chez lui, dans sa # voute, que le site ne detient pas et n'a pas a detenir. Le site heberge des # octets chiffres par restic cote client : il ne peut pas les lire non plus. # # Meme decouverte que ci-dessus (`decouvrir_du_site`) : le site sert les tenants # qu'il heberge, et cesse de les servir quand ils s'emancipent. def _locataires_sauvegarde() -> list: out = [] try: import devis_reseau racine_instances = devis_reseau.DOSSIER_INSTANCES decouverts = devis_reseau.decouvrir_du_site() except Exception: return out for nom_depot, prefixe, _n in decouverts: gv = (racine_instances / nom_depot / "inventories" / "principal" / "group_vars" / "serveur_backup.yml") try: cle = str((yaml.safe_load(gv.read_text(encoding="utf-8")) or {}) .get("serveur_backup_pubkey") or "").strip() except OSError: cle = "" # Un locataire sans cle publique declaree n'est pas une erreur du site : # c'est un tenant qui n'a pas encore de sauvegarde. On ne lui ouvre pas de # compte, et surtout on n'en ouvre pas un SANS cle — il accepterait alors # tout ce que le compte `restic` accepte deja. if cle: out.append({"nom": nom_depot, "compte": "restic-" + prefixe.lower(), "pubkey": cle}) return out _locataires_backup = _locataires_sauvegarde() # OU LE SITE DEPOSE SON PROPRE ETAT. # # Le site a de l'etat a lui, et pas des moindres : la racine de son autorite de # certification, et la forge qui porte le genome. Tout le reste est reconstructible # par le code — le cache se re-remplit, la zone DNS se regenere depuis le plan, les # depots du runner vivent dans git. # # IL DEPOSE SUR SON PROPRE DEPOT, avec SA propre identite (`backup_pubkey` du plan, # moitie privee dans `underlay.vault.yml`). Celle des locataires ne lui sert a rien # et ne doit surtout pas lui servir : chaque deposant a son compte, le sien est # `restic` — celui que `serveur_backup` cree, avec `/srv/restic/site` pour home. # # LE NOM DU SERVICE, PAS L'ADRESSE. `client_backup_repo` grave cette valeur dans le # chemin de CHAQUE instantane : une adresse rendrait le depot indeplacable. On prend # donc le `expose` declare par l'application qui porte `serveur_backup_site` — # la meme source que celle qu'un locataire utilise. _cible_backup = next( (str(f) for app in applications.values() if str(app.get("groupe") or "") == "serveur_backup_site" for f in (app.get("expose") or [])), "") # Les groupes d'une machine du site, tels que `plan/applications.yml` les declare. def _groupes_de(nom_hote: str) -> set: return {str(a.get("groupe")) for a in applications.values() if str(a.get("hote")) == nom_hote and a.get("groupe")} 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]) # L'identite de sauvegarde DU SITE (moitie publique). Le depot du site l'autorise # pour ses propres machines ; les locataires ont chacun la leur, ailleurs. if intrants.get("backup_pubkey"): communes["serveur_backup_pubkey"] = str(intrants["backup_pubkey"]) 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)) # LES ADRESSES DE LA FABRIC, pour que Prometheus puisse la TIRER (2026-09-10). # # Le job principal de Prometheus vaut `client_metrique ∩ hotes_actifs`, et les # hyperviseurs sont volontairement HORS de `hotes_actifs` : ce groupe sert aux # operations de flotte (`deployer-tout`), et un hyperviseur n'est pas une VM de la # flotte. L'intersection les excluait donc, alors meme qu'ils portent node_exporter. # # `serveur_prometheus_cibles_supplementaires` est le point d'extension prevu par le # role. On y met la fabric sous son PROPRE job : elle n'est pas la flotte, et un # tableau qui les melange ferait croire a un parc homogene. _fabric_metriques = sorted( f"{h['ip']}:9100" for h in U.hotes(u) if str(h.get("role")) == "hyperviseur" and str(h.get("ip", "")).startswith("192.168.11.")) # CE QUE LA SUPERVISION IRA VOIR : chaque patte de la frontiere, nommee par la zone # qu'elle sert. Les commutateurs restent dehors — D-48 les tient hors flotte, et rien # ne les rend interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir. _fabric_surveillee = [] for _h in U.hotes(u): if str(_h.get("role")) != "frontiere": continue # UN NOM RESERVE N'EST PAS UN EQUIPEMENT. `bifrost-2` est declare pour que le lien # de transit soit documente et que le nom soit pris — le boitier n'existe pas. # Le surveiller donnerait deux CRITICAL permanents, et une alarme toujours rouge # est une alarme qu'on cesse de lire. if str(_h.get("etat") or "actif") != "actif": continue _ip = str(_h.get("ip") or "") _res = str(_h.get("reseau") or "") if not _ip: continue _fabric_surveillee.append({ "nom": f"frontiere-{_ip.replace('.', '-')}", "adresse": _ip, "role": f"frontiere — patte {_res or _ip}", }) _fabric_surveillee.sort(key=lambda x: x["adresse"]) 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. # LE SITE PRETE SON RESOLVEUR PENDANT LA JEUNESSE DE SES LOCATAIRES # (2026-08-30). L'hote qui porte `serveur_resolveur_site` admet EN PLUS les # supernets des tenants que ce site heberge. # # POURQUOI ICI ET PAS DANS LE ROLE : l'inventaire connait le plan du site ET # le registre des tenants ; le role, lui, tourne sur une machine qui n'a # aucune raison de lire la carte de l'hebergeur. La derivation appartient a # qui detient les deux sources. # # CE QUE CA A COUTE DE NE PAS L'AVOIR : `infra-pki-01`, premiere machine a # porter l'autorite de Chezlepro, ne pouvait resoudre aucun nom. `apt` s'en # sortait par le mandataire du cache, qui resout a sa place — tout le reste # (`get_url`, un depot tiers en HTTPS, un `git clone`) restait aveugle. "serveur_resolveur_reseaux_autorises": ( _zones_du_site + _supernets_tenants if "serveur_resolveur_site" in _groupes_de(nom) else _zones_du_site), # LE PARE-FEU DE MACHINE, EN DEUX TEMPS (2026-09-02). # # `serveur_durci` porte `nftables_baseline`, qui refuse tout ce qui n'est pas # declare. Chez un tenant, trois pieces le rendent applicable : un registre de # flux resolu POUR SES HOTES, un chemin ou lire le jeu de regles genere, et un # `nftables_admin_ssh` qui dit par ou l'administration entre. # # Le site n'en avait aucune : `resoudre_flux.py nftables` n'ecrit que pour # l'instance montee, et le chemin par defaut (`inventory_dir/../../`) sort du # depot pour un inventaire DYNAMIQUE, dont le `inventory_dir` est `scripts/`. # # Poser le pare-feu avant ces pieces, ce serait appliquer une politique # `drop` sans aucune regle : les six machines du site tomberaient d'un coup, # et le deploiement qui les repare passe justement par SSH. On pose donc les # neuf autres roles d'abord — ils ne coupent rien — et le pare-feu ensuite, # quand il aura de quoi laisser passer. "nftables_baseline_enabled": True, # LE CHEMIN DU JEU DE REGLES, DIT ICI PARCE QUE LE DEFAUT NE PEUT PAS LE DIRE. # # `nftables_baseline` derive son chemin de `inventory_dir` — juste pour un # tenant, dont l'inventaire est un FICHIER sous `instance/inventories/...`. # Le site a un inventaire DYNAMIQUE : son `inventory_dir` est `scripts/`, et # le defaut pointait donc un cran AU-DESSUS du depot, sur un chemin qui # n'existe pas. Le role serait tombe sur son repli — une politique `drop` # sans les regles resolues. # # Les regles du site vivent a cote de son plan, comme sa carte et sa voute : # `make flux` les y ecrit (`resoudre_flux.py nftables-site`). "nftables_baseline_ruleset_genere": str( (U.chemin().resolve().parent / "flux-genere" / f"{nom}.nft") if U.chemin() else ""), # Ce que le marqueur verifiera : la liste ci-dessus les admet-elle vraiment ? "serveur_resolveur_site_supernets_tenants": _supernets_tenants, # `MaxStartups` COMPTE LES CONNEXIONS NON AUTHENTIFIEES, et un depot de SITE # en voit la somme de tous ses locataires — dont les minuteurs se reveillent # a la meme heure. Au-dela du seuil, sshd coupe AVANT la banniere : le client # rapporte `kex_exchange_identification: Connection reset by peer`, qui # accuse le reseau pour un refus applicatif. Douze sauvegardes simultanees, # une perdue (2026-09-01). # # EN host_var, ET NON EN PARAMETRE DE DEPENDANCE (regression du 2026-09-02). # La valeur vivait dans le `meta` de `serveur_backup_site` : elle ne portait # donc que dans le play de CE role. Le jour ou `serveur_durci` a apporte # `ssh_hardening` a toutes les machines du site, son passage — le dernier — a # remis le depot a la valeur par defaut, sans rien signaler. Un reglage qui # depend de l'ordre des plays est un reglage qui reviendra en arriere. # # Ici, tout play qui applique `ssh_hardening` a cet hote la voit. # A qui la supervision parle. Vide, le role le SIGNALE au deploiement. "serveur_icinga_destinataire": str(intrants.get("supervision_courriel") or ""), # LE POSTFIX DU SITE EXPEDIE, IL NE RECOIT PAS. # # `serveur_postfix` est d'abord le serveur de courriel d'un ecosysteme : il # resout ses destinataires dans un annuaire LDAP et remet a Dovecot. Le site # n'a ni l'un ni l'autre, et n'heberge aucune boite — il lui faut seulement # de quoi faire SORTIR une alerte, en son propre nom. "serveur_postfix_mode": "relais", # LE GRAFANA DU SITE N'A PAS DE SSO — pas par oubli, par D-87. # # Le SSO d'un tenant supposerait que le site emprunte l'identite d'un # ecosysteme qu'il doit pouvoir survivre ; un Keycloak DU site ferait # remonter l'identite dans le site, exactement ce que D-87 refuse. Reste # le compte local, que le site atteint par `sudo` sur l'hote — et il faut # alors REOUVRIR le formulaire, que le mode SSO ferme. # # Les deux lignes vont ensemble : eteindre l'une sans allumer l'autre # laisse un Grafana sans aucune porte. Le role refuse desormais cette # combinaison, plutot que de la deployer. "serveur_grafana_oidc_actif": False, "serveur_grafana_connexion_locale": True, # Le depot du site, pour les machines du site qui y deposent. Vide s'il n'y # en a pas — `client_backup` refuse alors, plutot que de viser un nom qui # ne repond pas : mieux vaut un deploiement qui s'arrete qu'une sauvegarde # qu'on croit faite. **({"client_backup_cible": _cible_backup, "client_backup_utilisateur_distant": "restic"} if _cible_backup else {}), # Les ecosystemes qui deposent leur etat ici, un compte Unix chacun. "serveur_backup_site_locataires": ( _locataires_backup if "serveur_backup_site" in _groupes_de(nom) else []), # 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, # LA FABRIC SURVEILLEE PAR CONTROLE ACTIF (2026-09-10). # # Un pare-feu n'accueille aucun agent : Icinga doit aller VOIR. Une entree par # PATTE de la frontiere, parce que chaque zone depend de son interface a elle — # une interface eteinte coupe une zone pendant que les autres vont bien. **({"serveur_icinga_fabric": _fabric_surveillee} if (_fabric_surveillee and "serveur_icinga" in _groupes_de(nom)) else {}), # La fabric, en job separe — voir `_fabric_metriques` plus haut. **({"serveur_prometheus_cibles_supplementaires": [{"job": "fabric", "cibles": _fabric_metriques}]} if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}), **communes, # En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le # plan declare pour toutes. **(srv.get("variables") or {}), } for _socle in GROUPE_SOCLE: groupes.setdefault(_socle, []).append(nom) if "serveur_backup_site" in _groupes_de(nom): hostvars[nom]["ssh_hardening_max_startups"] = "60:30:200" 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) # --- LES HYPERVISEURS ENTRENT, MAIS SEULEMENT POUR ETRE VUS (2026-09-10) ---------- # # L'Icinga du site ne connaissait que ses sept VM. Mesure : depuis `site-mon-01`, les # trois hyperviseurs ET sa propre passerelle rendaient 100 % de perte au ping. La # machine qui PORTE les VM etait invisible de la supervision qui les surveille. # # D-48 le permet, et le dit : « les hyperviseurs sont gerables par Ansible ; hors # flotte ne vaut que pour les commutateurs et la frontiere ». Ils peuvent donc porter # les integrations d'observation, ce qui vaut infiniment mieux qu'un ping : charge, # disque, unites systemd en echec, certificat. # # ILS N'ENTRENT PAS DANS LE SOCLE, ET C'EST LE POINT DELICAT. Un hyperviseur Proxmox # n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son # pare-feu, son SSH et ses sysctl — sur la machine qui tient tout le reste. Ils sont # donc places dans leur PROPRE groupe, hors de `GROUPE_SOCLE` et hors de # `GROUPE_FLOTTE` : `make site-appliquer GROUPE=serveur_durci` ne les touchera jamais, # parce qu'ils n'y sont pas. # # Ce que ca coute a dire : un groupe qui recoit des roles doit etre nomme a chaque # fois. C'est le prix d'un perimetre explicite, et il est moins cher qu'un # durcissement applique par megarde a un hyperviseur. # L'adresse du temoin, derivee comme tout le reste : l'hote qui porte `serveur_icinga`. _ip_icinga = "" for _n_app, _a_app in applications.items(): if str(_a_app.get("groupe")) == "serveur_icinga": _s = serveurs.get(_a_app.get("hote")) or {} _ip_icinga = str(_s.get("ip") or "") break _hyperviseurs = {} for _h in U.hotes(u): if str(_h.get("role")) != "hyperviseur": continue _nom, _ip = str(_h.get("nom") or ""), str(_h.get("ip") or "") if not _nom or not _ip: continue # UNE SEULE ADRESSE PAR HYPERVISEUR : l'underlay en declare trois par machine # (administration, transport, fabric). On retient celle du plan d'ADMINISTRATION, # la seule que le poste et le runner routent — les autres ne sont pas joignables # d'ou l'on parle. if _nom in _hyperviseurs: continue if not _ip.startswith("192.168.11."): continue _hyperviseurs[_nom] = _ip for _nom, _ip in sorted(_hyperviseurs.items()): hostvars[_nom] = { "ansible_host": _ip, "ansible_user": UTILISATEUR_DEFAUT, "setops_site": True, "setops_plan_dir": str(plan_dir() or ""), # PAS DE PARE-FEU DERIVE : aucun `nftables_baseline_ruleset_genere`. Un # hyperviseur porte son propre filtrage, pose par Proxmox et par l'exploitant. # # LE TEMOIN SE JOINT PAR SON ADRESSE, PAS PAR SON NOM. Le porteur de sante vise # `.`, ce qui suppose le plancher `/etc/hosts` du socle — que ces # machines n'ont pas, et ne doivent pas avoir : Proxmox s'en sert pour l'identite # de noeud du cluster. Mesure : `curl: (6) Could not resolve host` sur les trois. **({"client_sante_icinga_url": f"https://{_ip_icinga}:5665"} if _ip_icinga else {}), **communes, } groupes.setdefault("hyperviseurs", []).append(_nom) # ON TIRE, ON NE POUSSE PAS — ET C'EST LA ROUTE GELEE QUI LE DECIDE (2026-09-10). # # Toute la supervision de Set-OPS est PASSIVE : chaque noeud mesure et pousse. Ici # c'est impossible, et la mesure le dit sans ambiguite : # # asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site) # route par defaut via 192.168.11.254 GELEE (D-57) # # Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site. # Le porteur de sante expirait donc en 20 s. Le remede evident — router 10.0.0.0/8 # par la frontiere — touche la route par defaut d'un hyperviseur EN SERVICE, ce que # D-57 interdit : « risquer de perdre l'hyperviseur ET le chemin pour le reparer ». # # LE SENS INVERSE, LUI, EST OUVRABLE : la frontiere route deja `serveur_ops_site` # vers `SETOPS_FABRIC`. Prometheus TIRE donc les metriques, et rien ne pousse. # # CE QU'ON NE PERD PAS EN ECHANGE. `node_exporter` expose 1005 unites systemd avec # leur etat — exactement ce que la sonde `sante` mesurait, plus la charge, le # disque et l'horloge. On perd le mecanisme du `ttl` (le SILENCE qui alerte) ; # Prometheus le remplace par sa propre cible `down`, que la sonde `collecte` # rapporte deja. groupes.setdefault("client_metrique", []).append(_nom) 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())