Set-OPS-Public/scripts/site_inventaire.py
Daniel Allaire 7a2f179f39 repertoires partages : un seul mode par repertoire ; le depot de binaires du site sert de nouveau
/var/lib/setops etait tenu par quatre roles (0750 contre 0755) : apt-cacher-ng
ne le traversait plus, le depot rendait 403 et chaque runner reconstruit allait
chercher 500 Mo sur Internet. /etc/setops (0700 contre 0755) et /srv/restic au
site portaient le meme desaccord. test_repertoires_partages.py le refuse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:01:54 -04:00

1031 lines
58 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
import materiel as _materiel # noqa: E402
from inventory_rules import expositions_des_applications # 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
# `U`, PAS `underlay_mod` — ET LE `except` AVALAIT LA FAUTE (2026-09-14).
#
# La premiere version appelait `underlay_mod`, un nom qui n'existe pas dans ce
# fichier : le module y est importe `as U`. Python levait donc un `AttributeError`
# a CHAQUE appel — et le `except Exception: return False` le rendait muet.
#
# RESULTAT : la fonction repondait toujours « je ne suis pas une machine du site », le
# rebond etait toujours pose, et rien ne le disait. Une garde qui se tait ne garde
# rien ; c'est exactement le defaut que ce depot traque ailleurs, ecrit ici.
#
# LE `except` NE COUVRE PLUS QUE CE QU'IL DOIT : un plan illisible ou absent, pas une
# faute de programmation. Une erreur d'attribut ou de nom doit remonter.
court = socket.gethostname().split(".")[0]
try:
serveurs = (U.lire_plan_site("serveurs.yml") or {}).get("serveurs") or {}
except (OSError, ValueError) as e:
print(f"note : plan du site illisible ({e.__class__.__name__}) — rebond conserve.",
file=sys.stderr)
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()
# LES RELATIONS DU DNS PUBLIC DU SITE (2026-09-16).
#
# Pour chaque locataire du site qui declare une zone `autorite: primaire-cache` : son
# autoritatif, et les zones que le serveur public du site doit en tirer. Meme decouverte
# que les sauvegardes — le site sert les locataires qu'il heberge, et cesse de les servir
# quand ils partent.
#
# L'ADRESSE DU PRIMAIRE SE DERIVE DE LA NOMENCLATURE DU LOCATAIRE, jamais d'une liste.
# Une table d'adresses ecrite a la main a deja fait publier huit adresses fausses le meme
# jour, dans un document remis a un client.
#
# SEULES LES ZONES `primaire-cache`. Les zones `.internal` vivent aussi dans
# `domaines_publics` (`autorite: auto-heberge`) : trier sur le nom du registre les
# aurait publiees au monde.
def _relations_dns_public() -> list:
out = []
try:
import devis_reseau
from inventory_rules import deriver_nomenclature
racine_instances = devis_reseau.DOSSIER_INSTANCES
decouverts = devis_reseau.decouvrir_du_site()
except Exception:
return out
for nom_depot, _prefixe, _n in decouverts:
plan = racine_instances / nom_depot / "plan"
try:
doms = (yaml.safe_load((plan / "domaines.yml").read_text(encoding="utf-8"))
or {}).get("domaines_publics") or {}
apps = (yaml.safe_load((plan / "applications.yml").read_text(encoding="utf-8"))
or {}).get("applications") or {}
srvs = (yaml.safe_load((plan / "serveurs.yml").read_text(encoding="utf-8"))
or {}).get("serveurs") or {}
nomencl = yaml.safe_load((plan / "nomenclature.yml").read_text(encoding="utf-8")) or {}
except OSError:
continue
zones = sorted(z for z, c in doms.items()
if isinstance(c, dict) and str(c.get("autorite")) == "primaire-cache")
if not zones:
continue
hote = next((str(a.get("hote")) for a in apps.values()
if str(a.get("groupe")) == "serveur_powerdns"), "")
s = srvs.get(hote) or {}
d = deriver_nomenclature(s.get("fonction"), s.get("sequence", 1), nomencl) if s else None
if not d:
continue # un primaire sans adresse derivable : pas de relation, pas de trou
colocalise = any(str(a.get("groupe")) == "serveur_resolveur"
and str(a.get("hote")) == hote for a in apps.values())
slug = nom_depot.lower().removeprefix("ops-")
out.append({"ecosysteme": nom_depot,
"cle_tsig": f"tsig-{slug}",
"secret_var": f"vault_tsig_dns_public_{slug.replace('-', '_')}",
"primaire": d["adresse_ip"],
"port": 5300 if colocalise else 53,
"zones": zones,
# LES ZONES QUE LE LOCATAIRE SIGNE. Le site ne detient aucune cle ; il
# doit pourtant savoir qu'une zone signee lui arrive NUE, ou avec des
# signatures proches d'expirer — c'est lui que le monde interroge.
"signees": sorted(z for z in zones if doms[z].get("dnssec") is True)})
return out
_relations_dns = _relations_dns_public()
# 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"
# L'AMORCAGE APT DES MACHINES DU SITE LUI-MEME (2026-09-14).
#
# `dns_amorcage` etait derive juste au-dessus ; son jumeau ne l'etait pas. Le
# socle n'ecrit `00-setops-amorcage-artefacts` que `when artefacts_amorcage is
# defined` — jamais, donc, pour une machine du site. Le fichier du GABARIT
# survivait alors intact.
#
# CE QUE CA A COUTE, mesure le 2026-09-14 sur `site-edge-01`, premiere machine
# du site creee depuis le renumerotage :
#
# Failed to update apt cache after 5 retries
# Acquire::http::Proxy "http://10.0.33.21:3142" <- adresse d'AVANT
#
# Le gabarit porte cette ligne depuis sa fabrication, le 2026-09-01. Les machines
# nees AVANT le renumerotage n'ont rien vu : l'adresse etait juste, puis
# `client_artefacts` a pose un fichier qui trie APRES et l'a emporte. Une machine
# NEUVE, elle, doit faire son premier `apt update` avec le seul fichier du
# gabarit — et il ment.
#
# LE SITE FOURNIT DEJA CETTE VALEUR A SES LOCATAIRES (`site_intrants.py`). Il ne
# se la donnait pas a lui-meme. C'est encore « le site revele ce que le tenant
# cache », a l'envers : ce que l'hebergeur pretait sans l'appliquer chez lui.
communes["artefacts_amorcage"] = f"{_cache_site}:3142"
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"])
# LA CLE AUSSI EST UNE PROPRIETE DU CONTROLEUR, PAS DU SITE (2026-09-14).
#
# `cle_ssh` vaut `~/.ssh/id_ed25519_ansible_chezlepro` : le chemin sur le POSTE de
# l'exploitant. Impose au runner du site, il designe un fichier qui n'existe pas chez
# lui — et Ansible echoue sans dire que le chemin vient d'ailleurs.
#
# Le runner a SA propre paire, celle que le plan du site autorise desormais sur ses
# machines (`cles_admin`). Lui laisser son defaut, c'est le laisser etre lui-meme.
#
# MEME CONDITION QUE LE REBOND, ET CE N'EST PAS UN HASARD : les deux decrivent COMMENT
# ON ARRIVE, pas ce vers quoi on va. Ce qui depend de l'endroit d'ou l'on part n'a pas
# sa place dans la description d'un site.
if intrants.get("cle_ssh") and not _controleur_est_une_machine_du_site():
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`, et
# c'est de la que `client_pki` tire les SAN du certificat.
#
# ON APPELLE LA FONCTION PARTAGEE, ON NE LA REECRIT PLUS (2026-09-14).
#
# Cette boucle disait : « le site n'a pas d'edge, donc chaque service se sert
# lui-meme, donc le groupe est le sien ». C'etait vrai — jusqu'au jour ou le site a
# eu un edge. La meme regle vivait alors a DEUX endroits : ici, et dans
# `expositions_des_applications`, qui lit le registre des domaines. Seule la seconde
# a appris ; celle-ci est restee sur l'ancienne verite.
#
# CE QUE CA A COUTE. `client_pki` retient un FQDN si son `edge` est un GROUPE DE
# CET HOTE. L'edge appartient a `serveur_nginx` ; cette boucle rendait
# `serveur_grafana`, `serveur_icingaweb2`... Aucun nom ne correspondait, et le
# certificat de `site-edge-01` ne portait QUE celui de la machine — du TLS correct
# sur trois noms que personne ne peut appeler.
#
# Deux ecritures d'une meme regle finissent par diverger. Celle-ci n'existe plus.
expositions = expositions_des_applications(
{"applications": applications},
{"domaines_publics": (U.lire_plan_site("domaines.yml") or {}).get("domaines_publics") or {}},
)
# --- 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_nommes = {
f"{h['ip']}:9100": str(h.get("nom")) for h in U.hotes(u)
if str(h.get("role")) == "hyperviseur" and str(h.get("ip", "")).startswith("192.168.11.")}
_fabric_metriques = sorted(_fabric_nommes)
# LA FRONTIERE, QUAND SON EXPORTATEUR EST DECLARE (2026-09-17). L'adresse est celle de sa
# patte sur la zone declaree, lue dans l'underlay ; le nom est celui du boitier.
_frontiere_metriques: dict[str, str] = {}
try:
_carte_o = U.chemin().resolve().parent / "opnsense.yml" if U.chemin() else None
_expo_f = ((yaml.safe_load(_carte_o.read_text(encoding="utf-8")) or {})
.get("opnsense_exportateur_metriques") or {}) if _carte_o and _carte_o.is_file() else {}
except (OSError, yaml.YAMLError):
_expo_f = {}
if _expo_f:
for _h in U.hotes(u):
if (str(_h.get("role")) == "frontiere" and str(_h.get("etat") or "actif") == "actif"
and str(_h.get("reseau")) == str(_expo_f.get("patte")) and _h.get("ip")):
_frontiere_metriques[f"{_h['ip']}:{_expo_f.get('port', 9100)}"] = str(_h.get("nom"))
# 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")
# LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE — AUSSI AU SITE (2026-09-14).
#
# `instancier` derive `<groupe>_hostname` de l'exposition declaree depuis le
# 2026-09-10, et P67 le garde. Les deux ne connaissent que l'instance MONTEE, donc un
# locataire. L'inventaire du site, lui, n'en derivait AUCUN : ses cinq groupes exposes
# tombaient tous sur la devinette de leur role.
#
# Quatre tombaient juste par convention — `forge`, `pki`, `dns`, `sauvegarde`. Le
# cinquieme non : le plan expose `observatoire.genese.internal`, et `serveur_grafana`
# devine `grafana.{{ domaine_interne }}`. Grafana fabriquait donc son `root_url`, ses
# liens d'alerte et son URL de retour OIDC avec un nom que rien ne sert.
#
# C'EST LE MEME DEFAUT QU'IL Y A QUATRE JOURS, de l'autre cote de la cloture. La garde
# avait ete posee la ou il s'etait montre, et pas la ou il pouvait se montrer aussi.
#
# UNE SEULE EXPOSITION, sinon rien : deux noms ne designent aucun canonique, et le
# role garde alors la main. `setdefault` cote hote : une valeur ecrite a la main dans
# les variables d'une machine gagne encore.
# PAR HOTE *ET* PAR GROUPE (2026-09-15) — un role peut servir deux applications sur
# deux machines, et la table indexee par groupe seul donnait le DERNIER nom a tous.
# Voir `instancier.py`, ou le meme defaut a fait croire a la passerelle de la vigie
# qu'elle etait la console.
_hostname_par_hote_groupe: dict = {}
for _app in applications.values():
_exp = [str(f).strip() for f in (_app.get("expose") or []) if str(f).strip()]
_g, _h = str(_app.get("groupe") or ""), str(_app.get("hote") or "")
if _g and _h and len(_exp) == 1:
_hostname_par_hote_groupe[(_h, _g)] = _exp[0]
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 ""),
# LA RACINE DE `serveur_backup` SUR LA MACHINE DU DEPOT DU SITE (2026-10-01). Elle y
# est appliquee DEUX fois : en dependance de `serveur_backup_site`, qui lui passe
# `/srv/restic/site`, et par son propre groupe (le temoin des depots dans Icinga),
# qui reprenait le defaut `/srv/restic` — en 0700 au compte `restic`. Joue seul, ce
# second passage refermait le parent des depots de tous les locataires : sshd
# refuse alors leurs comptes (`StrictModes`, mesure le 2026-09-01). La valeur est
# LUE dans la dependance, pas recopiee : `test_repertoires_partages.py`.
**({"serveur_backup_racine": _racine_backup_du_site()}
if "serveur_backup_site" in _groupes_de(nom) else {}),
# La liste que la sonde `connectivite` teste, ecrite a cote du ruleset et pour
# la meme raison (2026-09-30) : au site, le defaut pointait hors du depot, et la
# sonde aurait tourne sans liste.
"nftables_baseline_connectivite_genere": str(
(U.chemin().resolve().parent / "flux-genere" / f"{nom}.connectivite.json")
if U.chemin() else ""),
# Ce que le marqueur verifiera : la liste ci-dessus les admet-elle vraiment ?
"serveur_resolveur_site_supernets_tenants": _supernets_tenants,
# Les zones que le serveur DNS public du site doit tirer, et de qui. Vide
# partout ailleurs : aucune autre machine du site ne sert Internet.
"serveur_dns_public_relations": (
_relations_dns if "serveur_dns_public" in _groupes_de(nom) else []),
# `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,
# LA CONSOLE DE SUPERVISION GERE SES COMPTES ELLE-MEME, AU SITE.
#
# Meme raison que pour Grafana juste au-dessus : un SITE n'a ni annuaire ni
# Keycloak — ce sont des services d'ECOSYSTEME, et l'hebergeur n'en est pas
# un. Les deux autres modes d'Icinga Web 2 (`ldap`, `external`) supposent
# l'un ou l'autre, et n'ont de sens que chez un locataire.
#
# `db` EST LE MODE NATIF : sa propre base, sa propre page de connexion, et la
# gestion des comptes DANS l'interface. La premiere ecriture posait un
# `auth_basic` nginx devant — un vestibule devant une application qui sait
# deja s'authentifier (corrige le 2026-09-15).
"serveur_icingaweb2_auth": "db",
# LA CONSOLE D'EXPLOITATION DU SITE, ALLUMEE (2026-09-15).
#
# `locale` ET PAS `oidc`, ET LA DIFFERENCE AVEC LA VIGIE EST TOUT LE SUJET.
# Icinga Web 2 sait s'authentifier : on lui a RETIRE son vestibule. Le GUI de
# Set-OPS, lui, n'a AUCUNE authentification — `GET /` sert la page a qui la
# demande, avec son jeton dedans. Le vestibule est donc sa seule serrure, et
# non une couche de trop.
#
# Un site n'a ni annuaire ni Keycloak : `locale` est le repli, et il est
# declare comme tel dans le role. Chez un locataire, ce sera `oidc`.
"serveur_ops_gui_actif": True,
"serveur_ops_gui_auth": "locale",
"serveur_ops_gui_admin_motdepasse": "{{ vault_setops_gui_admin | default('') }}",
# L'EDGE SERT LE CERTIFICAT DE L'AC INTERNE, PAS LE BOUCHON DE DEBIAN.
#
# `serveur_nginx` a pour DEFAUT `ssl-cert-snakeoil.pem`, avec un commentaire
# qui date d'avant la PKI : « a remplacer par des certificats de l'AC interne
# plus tard ». Chez un locataire, `group_vars/serveur_nginx.yml` le remplace.
# L'inventaire du site est DYNAMIQUE et n'a pas de group_vars : l'edge est
# donc ne en servant du snakeoil.
#
# C'EST LE PLUS GRAVE DES REPLIS DE LA JOURNEE. Les autres rendaient un
# service muet ou une regle inerte — celui-la rend du TLS qui RESSEMBLE a du
# TLS. Le cadenas s'affiche, la connexion est chiffree, et rien n'est prouve :
# le certificat servi n'est signe par personne et ne porte aucun des noms.
#
# Le chemin est celui que `client_pki` ecrit, et le meme que celui des
# locataires — une seule convention, pas deux.
"serveur_nginx_certificat":
"/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.crt",
"serveur_nginx_cle":
"/etc/step/certs/{{ ansible_fqdn | default(ansible_hostname) }}.key",
# 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 {}),
# LE MATERIEL : un catalogue, deux lecteurs (tableau Grafana, verdict Icinga).
# Voir `scripts/materiel.py` — les seuils vivent la, une fois.
**({"setops_materiel": _materiel.catalogue(),
"setops_materiel_hotes": [
{"nom": _n, "adresse": _c.rsplit(":", 1)[0], "job": "fabric",
"role": "hyperviseur"}
for _c, _n in sorted(_fabric_nommes.items(), key=lambda x: x[1])]
+ [{"nom": _n, "adresse": _c.rsplit(":", 1)[0], "job": "frontiere",
"role": "frontiere"}
for _c, _n in sorted(_frontiere_metriques.items(), key=lambda x: x[1])]}
if (_fabric_nommes and ({"serveur_grafana", "serveur_icinga"}
& set(_groupes_de(nom)))) else {}),
# La fabric, en job separe — voir `_fabric_metriques` plus haut.
**({"serveur_prometheus_cibles_supplementaires":
# `hotes` : chaque serie porte `hote="asgard"`. Sans lui, un tableau du
# materiel legende « 192.168.11.43:9100 » — et c'est a l'exploitant de se
# souvenir quelle machine chauffe.
[{"job": "fabric", "cibles": _fabric_metriques, "hotes": _fabric_nommes}]
+ ([{"job": "frontiere", "cibles": sorted(_frontiere_metriques),
"hotes": _frontiere_metriques}] if _frontiere_metriques else [])}
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 {}),
}
# Nom public de chaque service porte par cet hote, tel que le plan l'expose.
for _groupe_expose in sorted(_groupes_de(nom)):
_fqdn = _hostname_par_hote_groupe.get((nom, _groupe_expose))
if _fqdn:
hostvars[nom].setdefault(f"{_groupe_expose}_hostname", _fqdn)
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 _racine_backup_du_site() -> str:
"""La racine que `serveur_backup_site` passe a sa dependance `serveur_backup`."""
meta = Path(__file__).resolve().parent.parent / "roles" / "serveur_backup_site" / "meta" / "main.yml"
for dep in (yaml.safe_load(meta.read_text(encoding="utf-8")) or {}).get("dependencies") or []:
if isinstance(dep, dict) and dep.get("role") == "serveur_backup" and dep.get("serveur_backup_racine"):
return str(dep["serveur_backup_racine"])
raise SystemExit("serveur_backup_site/meta/main.yml ne passe plus `serveur_backup_racine` a sa dependance.")
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())