Set-OPS-Public/scripts/site_inventaire.py
Daniel Allaire 5812e0c6ca depot de binaires : le site tient ce que les runners allaient chercher
Le cache du site couvrait apt ; quatre artefacts arrivaient autrement, parce qu ils
ne vivent dans aucun depot apt. Le controleur les tire puis les pousse par SSH.

Mesure du 2026-09-12 : le cache du runner du site est ABSENT. Un second locataire
monte depuis lui sortait chercher 570 Mo sur codeberg.org, github.com et
download.nextcloud.com, alors que le meme ecosysteme ne demandait plus un seul
paquet a Debian. Le poste du mainteneur les a depuis toujours : personne ne l avait vu.

Pas de relais transparent, et la mesure tranche : github.com redirige vers une URL
signee valable une heure, differente a chaque requete. Un cache qui la prend pour
cle ne fait jamais mouche. Le relais marcherait pour deux amonts sur quatre.

Donc un vrai depot, dans le service qui existe deja. LocalDirs d apt-cacher-ng publie
un repertoire du disque sous un prefixe, eprouve AVANT d ecrire le role. Aucun service,
aucun port, aucun certificat, aucun flux nouveaux : l ingress 3142 pair flotte couvre
exactement ce chemin.

Les versions ne sont pas recopiees : le role lit les defauts des quatre consommateurs.
Les quatre roles recoivent une tache AJOUTEE, placee avant leur stat de cache — si le
depot sert, le stat le voit et la tache amont se saute d elle-meme. Aucune tache
existante n a change.

P70 exige que tout dest ecrit sous un cache_local figure au depot. Une liste qui suit
une autre prend du retard ; celle-ci est nee avec sa garde.

Deux marches payees en chemin :
- failed_when: false REECRIT le verdict, donc la premiere garde de signature ne
  gardait rien. Elles mesurent le fichier desormais.
- file: state=directory cree les parents en 0750 : apt-cacher-ng, qui ne tourne pas
  en root, rendait 403 sur chaque fichier. Un chemin se traverse en entier.

Verifie sur l infrastructure : 6/6 artefacts servis (200/206) depuis le runner du site
ET depuis une machine du locataire a travers la frontiere ; les 6 empreintes SHA-256
sont identiques a celles qui ont construit Chezlepro ; second passage changed=0.

make prouver : 69 OK, 0 echec, 1 saute. ansible-lint : 0 failure, profil production.

Inclut aussi force: true sur cinq telechargements de cles : une reprise conditionnelle
ne reprend rien (304 Not Modified, size 0, attempts 5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-12 23:45:00 -04:00

706 lines
39 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 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"]
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"])
# 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())