Set-OPS-Public/scripts/site_inventaire.py
Daniel Allaire b7884217ce le rebond se decide par le NOM, pas par l adresse
Troisieme formulation, et les deux premieres etaient fausses.

Suis-je dans un RESEAU du site : le poste porte 10.37.0.17, donc il y est, et il
aurait perdu son rebond alors qu il en a besoin. Etre dans un reseau ne dit rien
de ce qu on atteint.

Mon adresse est-elle dans underlay.hotes : hotes ne contient que les EQUIPEMENTS.
Les machines du site vivent dans son PLAN, et le module le dit lui-meme. La
comparaison portait sur une liste vide et rendait toujours False, sans rien
signaler — une garde muette qui ne gardait rien.

Le runner s appelle site-ops-01, et c est une cle du plan du site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-14 10:15:55 -04:00

769 lines
42 KiB
Python
Executable file

#!/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 _controleur_est_une_machine_du_site() -> bool:
"""Le controleur EST-IL l'une des machines que le PLAN du site declare ?
C'EST LA TROISIEME FORMULATION, ET LES DEUX PREMIERES ETAIENT FAUSSES :
1. « suis-je dans un RESEAU du site ? » — le poste de l'exploitant porte
`10.37.0.17`, donc il est dans le reseau `management`, et il aurait perdu son
rebond alors qu'il en a besoin : ses paquets arrivent sur une autre patte de la
frontiere, avec d'autres regles. Etre DANS un reseau ne dit rien de ce qu'on
atteint.
2. « mon adresse est-elle dans `underlay.hotes` ? » — `hotes` ne contient que les
EQUIPEMENTS (switches, hyperviseurs, frontiere). Les MACHINES du site vivent dans
son PLAN, et le module le dit lui-meme a `machines()`. La comparaison portait donc
toujours sur une liste vide, et rendait toujours False, sans rien signaler.
LA BONNE QUESTION SE POSE PAR LE NOM. Le runner s'appelle `site-ops-01`, et c'est une
cle du plan du site. Aucune derivation d'adresse, aucune liste a tenir a jour.
En cas de doute — nom illisible, plan muet — on rend False : poser le rebond est le
comportement d'avant, donc le seul qu'on sache sur. Degrader, jamais deviner.
"""
import socket
try:
court = socket.gethostname().split(".")[0]
except Exception:
return False
try:
serveurs = (underlay_mod.lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
except Exception:
return False
return court in serveurs
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)}
# L'AUTORITE DE TEMPS, COTE SITE (2026-09-11).
#
# Ici la frontiere est la passerelle DIRECTE de chaque zone — pas besoin de passer par
# le lien de transit comme un tenant. On prend donc la patte de la frontiere posee sur
# LA ZONE DE LA MACHINE : le chemin le plus court, et celui qu'aucune regle
# supplementaire n'a besoin d'ouvrir.
_temps_par_reseau = {str(_h.get("reseau")): str(_h.get("ip"))
for _h in U.hotes(u)
if _h.get("role") == "frontiere" and _h.get("ip")
and str(_h.get("etat", "actif")) != "reserve"}
# 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"), "")
# LE DEPOT DES BINAIRES DIRECTS — meme derivation que le resolveur, pour la meme
# raison : l'adresse se lit la ou elle est vraie, dans le plan qui porte le service.
#
# Forgejo, Keycloak, Nextcloud et oauth2-proxy ne vivent dans aucun depot apt : le
# controleur les tire puis les pousse par SSH. Le cache du site les tient desormais
# (`serveur_artefacts`, LocalDirs), et c'est ce qui permet a un runner de monter un
# ecosysteme entier sans sortir. Mesure du 2026-09-12 : sans lui, 570 Mo par runner.
_cache_site = next((str(s.get("ip")) for nom, s in serveurs.items()
if any(a.get("hote") == nom and a.get("groupe") == "serveur_artefacts"
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
if _cache_site:
communes["setops_depot_binaires"] = f"http://{_cache_site}:3142/setops-binaires"
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"]
# LE SITE N'AVAIT AUCUN MOYEN D'AUTORISER UNE CLE D'ADMINISTRATION (2026-09-14).
#
# Un locataire declare `ssh_baseline_cles_admin` dans ses group_vars, et `ssh_baseline`
# les pose sur toute sa flotte. Cote site, rien ne transmettait cette liste : ses
# machines n'autorisaient donc que la cle posee par cloud-init a leur naissance.
#
# CE QUE CA COUTAIT, ET C'EST STRUCTUREL : le runner du SITE ne pouvait entrer sur
# AUCUNE machine du site. Il sait inseminer un locataire — c'est prouve — et il ne
# savait pas configurer l'hebergeur qui le porte. Tout le site avait ete deploye
# depuis le poste de l'exploitant, et rien ne disait que c'etait la seule voie.
if intrants.get("cles_admin"):
communes["ssh_baseline_cles_admin"] = intrants["cles_admin"]
# LE REBOND NE SERT QU'A CELUI QUI EST DEHORS (2026-09-14).
#
# Il etait pose sans condition. Depuis le poste de l'exploitant c'est juste : aucune
# route directe ne mene aux zones du site. Depuis le runner du site — qui vit DANS une
# de ces zones — c'est un detour qui casse : il doit s'authentifier aupres de la
# frontiere, ou sa cle n'est pas autorisee, et OpenSSH rend alors
#
# Host key verification failed.
# Connection closed by UNKNOWN port 65535
#
# un message qui accuse les cles d'HOTE alors que l'echec est une authentification, et
# sur le SAUTEUR, pas sur la cible. Une heure de fausse piste.
#
# ON NE DEMANDE PAS UN INTRANT DE PLUS : le controleur sait ou il est. S'il porte deja
# une adresse dans un reseau du site, il n'a personne a sauter.
if intrants.get("rebond") and not _controleur_est_une_machine_du_site():
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"])
# Le nom de la frontiere, tel que la carte le donne — pour etiqueter ses journaux.
_nom_frontiere = next((str(h.get("nom")) for h in U.hotes(u)
if h.get("role") == "frontiere"
and str(h.get("etat") or "actif") == "actif"), "frontiere")
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"),
# La frontiere, autorite de temps — sa patte SUR LA ZONE DE CETTE MACHINE.
"chrony_serveurs": ([_temps_par_reseau[str(srv.get("reseau"))]]
if str(srv.get("reseau")) in _temps_par_reseau else []),
# 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.
# LE POSTGRESQL DU SITE ETAIT HORS DE LA DOCTRINE (2026-09-12).
#
# `serveur_postgresql_tls_actif` vaut `false` par defaut, et le tenant le
# met a `true` dans ses group_vars. Le SITE ne le mettait nulle part : son
# PostgreSQL servait le certificat AUTO-SIGNE du paquet Debian
# (`ssl-cert-snakeoil.pem`), pas celui de son AC.
#
# PERSONNE NE L'AVAIT VU parce qu'aucun client du site n'exigeait la
# verification. Le premier a l'exiger — l'import du schema Icinga DB, qui
# se connecte par le FQDN — a echoue sur `certificate verify failed`, et
# c'est ainsi que l'ecart s'est montre. Le defaut n'a pas casse la
# construction : la construction a revele le defaut.
#
# Ce n'est pas un blocage d'amorcage, c'est un trou de zero-confiance
# est-ouest : le site chiffrait sans que personne ne verifie a qui.
# ET LES CLIENTS DOIVENT ETRE NOMMES. Le tenant declare
# `serveur_postgresql_reseaux_autorises: [supernet]` ; le site ne declarait
# rien, donc `pg_hba` n'autorisait que le local :
#
# aucune entree dans pg_hba.conf pour l hote 10.37.36.11,
# utilisateur icingadb, chiffrement SSL
#
# Les zones du site, et elles seules — pas un `0.0.0.0/0` qui rendrait le
# verrou `hostssl` decoratif.
"serveur_postgresql_reseaux_autorises": _zones_du_site,
# `tls_force` pose `hostssl` : toute connexion NON chiffree est refusee.
# On ne l'active qu'apres avoir confirme que le certificat servi vient bien
# de l'AC — c'est fait, mesure a 17:31 ce jour.
"serveur_postgresql_tls_force": True,
"serveur_postgresql_tls_actif": True,
"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,
# LE COLLECTEUR ECOUTE AUSSI CE QUI NE PEUT PAS POUSSER. La frontiere n'a pas
# d'agent : elle emet du syslog, et Alloy le traduit vers Loki. Active sur la
# machine qui PORTE Loki — un second recepteur ailleurs serait une seconde
# configuration a garder en phase.
**({"client_journal_syslog_ecoute": f"{srv.get('ip')}:1514",
"client_journal_syslog_emetteur": _nom_frontiere,
# La sonde interroge la MEME etiquette que celle posee sur l'ecouteur :
# une seule derivation, deux consommateurs.
"serveur_loki_sonde_frontiere_etiquette": _nom_frontiere}
if "serveur_loki" in _groupes_de(nom) else {}),
# 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
_ip_loki = ""
for _n_app, _a_app in applications.items():
if str(_a_app.get("groupe")) == "serveur_loki":
_ip_loki = str((serveurs.get(_a_app.get("hote")) or {}).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
# `<hote>.<domaine>`, 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 {}),
# LES JOURNAUX, EUX, PARTENT D'ICI — et par une ADRESSE, meme raison que
# ci-dessus : pas de plancher `/etc/hosts` sur un hyperviseur.
**({"client_journal_loki_url":
f"http://{_ip_loki}:3100/loki/api/v1/push"} if _ip_loki 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)
# LES JOURNAUX SE POUSSENT, EUX. La route specifique vers les zones du site — celle
# qui manquait pour 35 et 36 — suffit a un push : il part de la source et n'attend
# qu'un accuse. Un scrape, lui, exige que la reponse revienne par le meme chemin,
# ce que la route par defaut gelee empeche. D'ou metriques TIREES, journaux POUSSES.
groupes.setdefault("client_journal", []).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())