J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en
reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai,
c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et
ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan.
SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que
la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent
du registre des applications, les gardes ont suivi le plan (4 controles negatifs).
Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout :
- client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ;
- aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ;
- le certificat, PUBLIC par nature, restait en 0600 ;
- l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ;
- le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ;
- le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ;
- les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ;
- le flux declarait 3000 en dur.
Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une
permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher
s'ecrivait vide.
ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec
CAP_NET_BIND_SERVICE, et son URL n'a plus de port.
Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200.
42 preuves vertes, flux coherents (34 roles, 92 flux).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
11 KiB
Python
Executable file
233 lines
11 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)}
|
|
|
|
# Ce que TOUTE machine du site recoit, des intrants du plan.
|
|
communes: dict = {}
|
|
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 ""),
|
|
# 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())
|