Le site n'est plus un /24 plat. Une zone par nature d'autorite — pilotage, autorite,
genome, service — chacune son VLAN et sa patte sur la frontiere. L'inversion corrigee,
mesuree : les cinq VM du site n'avaient AUCUN filtrage est-ouest, contre policy_in=DROP
sur une machine de tenant. La plus autoritaire etait la moins protegee.
Filtrage nord-sud par choix de l'exploitant : un seul point de police, un seul devis.
90 -> 117 regles. Chaque flux `flotte` produit une regle par zone SOURCE, destination
nommee — ce qui etait gratuit devient police.
L'ORDRE FAIT PARTIE DE L'INTEGRATION. client_pki tournait en parallele sur tous les hotes ;
sur celui qui porte l'autorite il recharge step-ca, et les quatre autres echouaient dans
cette fenetre sur `TLS handshake timeout` — un message qui accuse le reseau. Chaque
integration declare desormais sa dependance dans meta/integration.yml, les playbooks en
sont le miroir genere, et P44 refuse l'ecart (4 controles negatifs). `serveur: ~` est une
reponse valable : client_metrique pose un exportateur qu'on vient LIRE.
Autres defauts du meme soir :
- le plancher /etc/hosts venait APRES le premier apt, qui vise le cache par son NOM :
boucle fermee des que les adresses changent. Il ne s'installe pas, il rend installable.
- dns_amorcage ecrit en dur a eu tort deux fois ; il se derive de serveur_resolveur.
- l'ACL du resolveur derivait d'un seul sous-reseau : trois zones refusees sur quatre.
ET UNE ERREUR A MOI : j'ai diagnostique un trou noir de MTU et declare 1450. Faux — pas de
VXLAN sur ce chemin, tout est a 1500 de bout en bout. Ma mesure etait reelle, mon
interpretation non : je venais de debrancher la carte a chaud, et chaque `ip link set mtu`
reconfigurait l'interface. C'est la reconfiguration qui debloquait, pas la valeur.
44 preuves vertes, ansible-lint profil production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
267 lines
13 KiB
Python
Executable file
267 lines
13 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.
|
|
GROUPE_SOCLE = "serveur_debian"
|
|
|
|
# LE PLANCHER `/etc/hosts` SE DERIVE DE CE GROUPE, et de nul autre.
|
|
#
|
|
# `hosts_statiques` construit sa liste depuis `hotes_actifs` + `hotes_planifies` — des
|
|
# groupes qu'un inventaire de tenant fabrique, et que celui du site ne fabriquait pas. Le
|
|
# role tournait donc sans erreur et ecrivait un plancher VIDE : `localhost`, et rien
|
|
# d'autre. Un succes qui n'a rien fait, la pire forme d'echec.
|
|
#
|
|
# Le site n'a pas d'hotes « planifies » — une machine y est declaree ou elle n'existe pas.
|
|
# Elles vont donc toutes dans `hotes_actifs`.
|
|
GROUPE_FLOTTE = "hotes_actifs"
|
|
|
|
|
|
def plan_dir() -> Path | None:
|
|
"""Le `plan/` du SITE, a cote de son `underlay.yml` — derive du symlink, jamais redit.
|
|
|
|
UN SITE A BIEN UN PLAN (2026-08-25). Ce qui le distingue d'un tenant n'est pas
|
|
l'absence de plan mais l'absence de DERIVATION : un tenant tire son adressage de son
|
|
`index`, un site le declare, parce qu'il EST le terrain. Il ne passe donc pas par
|
|
`instancier` — mais il a bien des machines, des services et des intrants a declarer.
|
|
|
|
Faute d'avoir fait cette distinction, ce plan a d'abord ete reconstruit morceau par
|
|
morceau dans `underlay.yml`, sans etre nomme : chaque manque est arrive par surprise
|
|
au lieu d'etre previsible. `underlay.yml` decrit ce qui est MESURE (la fabric) ; le
|
|
plan decrit ce qui est VOULU.
|
|
"""
|
|
c = U.chemin()
|
|
return (c.resolve().parent / "plan") if c else None
|
|
|
|
|
|
def _charger(nom: str) -> dict:
|
|
d = plan_dir()
|
|
f = (d / nom) if d else None
|
|
if not f or not f.is_file():
|
|
return {}
|
|
return yaml.safe_load(f.read_text(encoding="utf-8")) or {}
|
|
|
|
|
|
def inventaire() -> dict:
|
|
u = U.charger()
|
|
if u is None:
|
|
# Aucun underlay monte : un inventaire VIDE, pas une erreur. Ansible doit pouvoir
|
|
# etre lance sans site sans que ca ressemble a une panne.
|
|
return {"_meta": {"hostvars": {}}}
|
|
|
|
intrants = _charger("10-intrants.yml")
|
|
serveurs = (_charger("serveurs.yml") or {}).get("serveurs") or {}
|
|
applications = (_charger("applications.yml") or {}).get("applications") or {}
|
|
if not serveurs:
|
|
return {"_meta": {"hostvars": {}}}
|
|
|
|
par_reseau = {r.get("nom"): r for r in U.reseaux(u)}
|
|
# Les sous-reseaux ou vivent REELLEMENT des machines du site : l'etendue de
|
|
# l'ecosysteme, telle que le plan la dessine.
|
|
_zones_du_site = sorted({str((par_reseau.get(s.get("reseau")) or {}).get("sous_reseau"))
|
|
for s in serveurs.values()
|
|
if str(s.get("etat", "actif")) == "actif"
|
|
and (par_reseau.get(s.get("reseau")) or {}).get("sous_reseau")})
|
|
|
|
# LE RESOLVEUR D'AMORCAGE SE DERIVE, IL NE S'ECRIT PAS (2026-08-25).
|
|
#
|
|
# Il a ete ecrit en dur deux fois, et il a eu tort les deux fois : d'abord la frontiere
|
|
# (`10.0.3.1`) alors que le site avait son DNS, puis `10.0.3.51` — juste jusqu'a ce que
|
|
# le decoupage en zones deplace la machine en `10.0.34.11`.
|
|
#
|
|
# Une adresse ecrite a la main est une copie ; une copie se perime. Celle-ci se lit
|
|
# desormais la ou elle est vraie : l'hote qui porte `serveur_resolveur` dans ce plan.
|
|
# Le plan peut encore la surcharger — pour l'amorcage d'un site tout neuf, ou le
|
|
# resolveur n'existe pas encore — mais ce n'est plus le cas normal.
|
|
_resolveur = next((str(s.get("ip")) for nom, s in serveurs.items()
|
|
if any(a.get("hote") == nom and a.get("groupe") == "serveur_resolveur"
|
|
for a in applications.values())
|
|
and str(s.get("etat", "actif")) == "actif"), "")
|
|
|
|
# Ce que TOUTE machine du site recoit, des intrants du plan.
|
|
communes: dict = {}
|
|
if _resolveur:
|
|
communes["dns_amorcage"] = _resolveur
|
|
for cle in ("domaine_interne", "organisation", "dns_amorcage"):
|
|
if intrants.get(cle):
|
|
communes[cle] = str(intrants[cle])
|
|
if intrants.get("cle_ssh"):
|
|
communes["ansible_ssh_private_key_file"] = intrants["cle_ssh"]
|
|
if intrants.get("rebond"):
|
|
communes["ansible_ssh_common_args"] = (
|
|
f"-o ProxyJump={intrants['rebond']} -o StrictHostKeyChecking=no")
|
|
|
|
# Les integrations universelles, moins celles que le site ne peut pas honorer.
|
|
exemptees = dict(intrants.get("integrations_exemptes") or {})
|
|
universelles = [r for r in integrations_universelles() if r not in exemptees]
|
|
|
|
# LES EXPOSITIONS : le nom du SERVICE, pas celui de la machine. Un nom de service
|
|
# survit au demenagement du service ; un nom de machine, non.
|
|
#
|
|
# `edge` nomme le GROUPE qui sert ce FQDN — c'est ce qu'attend `hosts_statiques`.
|
|
# Chez un tenant c'est l'edge nginx, qui termine le TLS pour tout le monde. Le site
|
|
# n'a pas d'edge : chaque service se sert lui-meme, donc le groupe est le sien. La
|
|
# forme reste la meme, ce qui la remplit change.
|
|
expositions: list[dict] = []
|
|
for nom_app, app in applications.items():
|
|
for fqdn in (app.get("expose") or []):
|
|
expositions.append({"fqdn": str(fqdn), "edge": str(app.get("groupe") or ""),
|
|
"application": nom_app})
|
|
|
|
# --- LES GARDES DU PLAN, LA OU LE PLAN EST LU ----------------------------
|
|
#
|
|
# Elles vivaient dans `underlay.valider()` tant que les machines habitaient la carte.
|
|
# Le plan les emporte avec lui : une garde loin de ce qu'elle garde finit par garder
|
|
# autre chose. Elles refusent ici, avant que quoi que ce soit ne soit clone.
|
|
erreurs: list[str] = []
|
|
noeuds = {h.get("nom") for h in U.hotes(u) if h.get("role") == "hyperviseur"}
|
|
vus_ip: dict[str, str] = {str(h.get("ip")): str(h.get("nom")) for h in U.hotes(u)}
|
|
vus_vmid: dict[int, str] = {}
|
|
for nom, srv in serveurs.items():
|
|
if str(srv.get("etat", "actif")) != "actif":
|
|
continue
|
|
r = par_reseau.get(srv.get("reseau"))
|
|
if not r:
|
|
erreurs.append(f"serveur '{nom}': reseau '{srv.get('reseau')}' inconnu de "
|
|
f"`underlay.yml` — la fabric est la source de ces valeurs")
|
|
continue
|
|
if not str(r.get("pont", "") or "").strip():
|
|
erreurs.append(f"serveur '{nom}': le reseau '{r.get('nom')}' ne declare aucun "
|
|
f"`pont:` — aucun pont d'hyperviseur ne le porte, une VM y "
|
|
f"naitrait sourde")
|
|
if srv.get("noeud") not in noeuds:
|
|
erreurs.append(f"serveur '{nom}': noeud '{srv.get('noeud')}' n'est pas un "
|
|
f"hyperviseur declare dans `underlay.hotes`")
|
|
ip = str(srv.get("ip") or "")
|
|
if ip in vus_ip:
|
|
erreurs.append(f"serveur '{nom}': IP {ip} deja prise par '{vus_ip[ip]}'")
|
|
vus_ip[ip] = nom
|
|
vmid = srv.get("vmid")
|
|
if not isinstance(vmid, int):
|
|
erreurs.append(f"serveur '{nom}': `vmid` absent ou non entier")
|
|
elif vmid in vus_vmid:
|
|
erreurs.append(f"serveur '{nom}': vmid {vmid} en double avec "
|
|
f"'{vus_vmid[vmid]}'")
|
|
else:
|
|
vus_vmid[vmid] = nom
|
|
for nom_app, app in applications.items():
|
|
if app.get("hote") not in serveurs:
|
|
erreurs.append(f"application '{nom_app}': hote '{app.get('hote')}' n'est pas "
|
|
f"declare dans `plan/serveurs.yml`")
|
|
sans_service = [n for n in serveurs
|
|
if not any(a.get("hote") == n for a in applications.values())]
|
|
if sans_service:
|
|
erreurs.append("serveur(s) sans aucune application : " + ", ".join(sorted(sans_service))
|
|
+ " — une machine du site existe POUR un role")
|
|
if erreurs:
|
|
raise SystemExit("plan du site refuse :\n - " + "\n - ".join(erreurs))
|
|
|
|
hostvars: dict[str, dict] = {}
|
|
groupes: dict[str, list[str]] = {}
|
|
|
|
for nom, srv in serveurs.items():
|
|
if str(srv.get("etat", "actif")) != "actif":
|
|
continue
|
|
r = par_reseau.get(srv.get("reseau")) or {}
|
|
hostvars[nom] = {
|
|
"ansible_host": str(srv["ip"]),
|
|
"ansible_user": srv.get("utilisateur", UTILISATEUR_DEFAUT),
|
|
"site_reseau": srv.get("reseau"),
|
|
"site_pont": r.get("pont"),
|
|
"site_noeud": srv.get("noeud"),
|
|
"proxmox_vmid": srv.get("vmid"),
|
|
# L'espace d'adressage de cet ecosysteme. Un tenant le derive de son index ;
|
|
# le site le tient du reseau ou vivent ses machines. Meme sens, autre source.
|
|
# `serveur_resolveur` s'en sert pour savoir QUI a le droit de l'interroger.
|
|
"setops_supernet": str(r.get("sous_reseau") or ""),
|
|
# QUI A LE DROIT D'INTERROGER LE RESOLVEUR — TOUTES LES ZONES DU SITE.
|
|
#
|
|
# `serveur_resolveur` derive sa liste d'autorisation de `setops_supernet`, qui
|
|
# vaut le sous-reseau de la machine. C'etait juste tant que le site etait un
|
|
# `/24` plat. Decoupe en zones, le resolveur n'autorisait plus que la SIENNE :
|
|
# les trois autres se faisaient refuser, et la panne ressemble a un DNS mort
|
|
# alors que c'est une ACL.
|
|
#
|
|
# Un resolveur ouvert au monde est un relais d'amplification ; celui-ci est
|
|
# ouvert a son ecosysteme, et a rien d'autre.
|
|
"serveur_resolveur_reseaux_autorises": _zones_du_site,
|
|
# Un role partage peut avoir besoin de savoir de quel cote il tourne.
|
|
"setops_site": True,
|
|
# Le plan du site, pour les roles qui lisent des registres.
|
|
"setops_plan_dir": str(plan_dir() or ""),
|
|
"hosts_statiques_expositions": expositions,
|
|
**communes,
|
|
# En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le
|
|
# plan declare pour toutes.
|
|
**(srv.get("variables") or {}),
|
|
}
|
|
groupes.setdefault(GROUPE_SOCLE, []).append(nom)
|
|
groupes.setdefault(GROUPE_FLOTTE, []).append(nom)
|
|
for integ in universelles:
|
|
groupes.setdefault(integ, []).append(nom)
|
|
for integ in (srv.get("integrations") or []):
|
|
groupes.setdefault(str(integ), []).append(nom)
|
|
|
|
# LES GROUPES VIENNENT DU REGISTRE DES APPLICATIONS, comme chez un tenant.
|
|
for app in applications.values():
|
|
hote, groupe = app.get("hote"), app.get("groupe")
|
|
if hote in hostvars and groupe:
|
|
groupes.setdefault(str(groupe), []).append(hote)
|
|
|
|
out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()}
|
|
out["site"] = {"hosts": sorted(hostvars)}
|
|
out["_meta"] = {"hostvars": hostvars}
|
|
return out
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--list", action="store_true")
|
|
ap.add_argument("--host")
|
|
a = ap.parse_args()
|
|
if a.host:
|
|
print(json.dumps(inventaire().get("_meta", {}).get("hostvars", {}).get(a.host, {})))
|
|
else:
|
|
print(json.dumps(inventaire(), indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|