Phase 3 : generateur d'inventaire depuis le plan (diff vide prouve)
make instancier genere hosts.genere.yml (gitignore) depuis le plan (serveurs.yml + applications.yml + nomenclature.yml) et compare SEMANTIQUEMENT a l'inventaire actuel via ansible-inventory --list. - scripts/instancier.py : generer + comparer (host vars derives, groupes derives = socle + services (applications) + integrations (clients_*) + etat). - serveurs.py bootstrap capture les clients_* comme 'integrations' par VM. - inventory_rules : valide 'integrations'. Resultat : DIFF VIDE -- le plan reproduit exactement les 13 hotes. La bascule (hosts.yml genere + UI editant le plan) reste NON effectuee : elle attend une validation explicite. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
501ec38ab0
commit
69dd730b70
7 changed files with 177 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,4 +1,6 @@
|
|||
*.retry
|
||||
# Inventaire genere depuis le plan (artefact, make instancier ; pas une source).
|
||||
inventories/*/hosts.genere.yml
|
||||
.vault-pass
|
||||
facts_cache/
|
||||
.ansible/
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
- **Refonte « application-hub », Phase 1 — les liens deviennent explicites (inventaire inchangé, zéro régression).** L'application porte désormais tous ses liens : `groupe` (capacité/rôle), `hote` (VM), `port`, **`requiert`** (autres applications dont elle dépend) et **`expose`** (FQDN publics qui la publient). L'**exposition DNS quitte `docs/domaines.yml`** (le bloc `exposition` est retiré) et vit sur l'application via `expose` ; `docs/domaines.yml` ne décrit plus que les zones (autorité, edge, secondaires, dnssec, mail). `serveurs_nginx` dérive ses vhosts des **applications** (`expose` + edge du domaine), résolvant `application → hôte → IP` (rôle + `expositions.conf.j2` réécrits). Nouvelle règle partagée `expositions_des_applications` (+ `domaine_parent`) dans `inventory_rules.py`, exposée à Ansible par le filter plugin ; `valider_applications` valide `requiert` (applis connues), `expose` (domaine parent déclaré) et `port`. CLI (`applications.py --port/--requiert/--expose`, `domaines.py` affiche les expositions dérivées) et **GUI** (champs Port/Requiert/Expose sur la fiche Application, persistés) suivent ; l'API expose aussi le registre des domaines. Le groupe n'est plus une cible de liaison, seulement une capacité. *(Renommage `nomenclature.domaines:` → `fonctions:` reporté en Phase 1b ; génération de l'inventaire depuis le plan en Phase 3.)*
|
||||
|
||||
### Ajouté
|
||||
- **Phase 3 — générateur d'inventaire depuis le plan (non destructif ; diff vide prouvé).** `make instancier` (`scripts/instancier.py`) génère `inventories/production/hosts.genere.yml` (gitignoré, artefact) depuis le plan — `docs/serveurs.yml` + `docs/applications.yml` + `docs/nomenclature.yml` — puis **compare sémantiquement** (via `ansible-inventory --list`, formatage ignoré) avec l'inventaire actuel : host vars dérivés (IP/VMID/VLAN/passerelle de la nomenclature, placement/taille du registre serveurs), groupes dérivés (socle `debian`/`durcis` + services depuis les applications + intégrations `clients_*` + état). Prérequis posés : `make applications-bootstrap` (services `serveurs_*` → applications) et `serveurs.yml` enrichi d'`integrations` (les `clients_*` par VM, capturés au bootstrap). **Résultat : DIFF VIDE — le plan reproduit exactement les 13 hôtes de l'inventaire.** La bascule (rendre `hosts.yml` généré et faire éditer le plan par l'UI) reste **non effectuée** : elle attend une validation explicite.
|
||||
- **Phase 2 — le serveur (VM) entre dans le plan (lecture seule, l'inventaire reste autorité).** Nouveau registre `docs/serveurs.yml` (VM = `fonction` + `etat` + placement/dimensionnement Proxmox), **bootstrapé depuis l'inventaire** (`make serveurs-bootstrap`). La dérivation nomenclature (fonction + rang → VMID/IP/VLAN/passerelle) est portée en Python (`deriver_nomenclature`, source unique, miroir de l'auto-proposition du GUI) et la **réconciliation** compare le dérivé aux valeurs de l'inventaire (`reconcilier_serveur`). CLI `scripts/serveurs.py` (`lister`/`verifier`/`bootstrap`) + cibles `make serveurs` / `serveurs-verifier` / `serveurs-bootstrap`, intégré à `make inventaire-verifier`. GUI : vue **Serveurs** (lecture seule) affichant le plan + valeurs dérivées + statut de réconciliation. Bootstrap initial : **13 serveurs, tous réconciliés** (le dérivé reproduit exactement l'inventaire → la génération de Phase 3 pourra viser un diff vide).
|
||||
- **Application = entité de première classe** (une VM peut porter plusieurs applications). Nouveau registre `docs/applications.yml` (`application → groupe, hote`). Les bases se lient via `consommateur` + **`portee`** (`application` | `groupe` | `hote`, défaut `groupe`) dans `docs/bases-donnees.yml` : une application `A` (groupe `G`, hôte `H`) reçoit les bases où `(portee=application ET consommateur=A) OU (portee=groupe ET consommateur=G) OU (portee=hote ET consommateur=H)` — plusieurs DSN par application. Résolution + validation partagées dans `inventory_rules.py` (`charger_applications`, `valider_applications`, `applications_de_hote`, `bases_de_application`, `PORTEES_BD` ; `valider_bases` croise désormais `portee=application` avec le registre des applications). CLI `scripts/applications.py` (`lister`/`verifier`/`ajouter`/`retirer`) + `scripts/bases_donnees.py --portee` ; cibles `make applications` / `applications-verifier` ; validation intégrée dans `make inventaire-verifier`. GUI : vue **Applications** (CRUD), sélecteur de **portée** + consommateur dynamique (groupe/hôte/application) dans la vue Bases, et **vue Chaîne par application** (DSN résolus par hôte). Filter plugin `filter_plugins/registres.py` exposant la **même** règle de résolution à Ansible (source unique) ; les playbooks `serveurs_web_dorsaux` / `serveurs_web_frontaux` itèrent désormais sur les applications de l'hôte et résolvent leurs bases (scaffold ; le déploiement applicatif réel s'y insère). Les services en grappe (keycloak/icingadb/forgejo) restent en `portee: groupe` (inchangés).
|
||||
- Vhosts `serveurs_nginx` **dérivés du registre `docs/domaines.yml`** (bloc `exposition`) : pour chaque exposition `web` ciblant cet edge, un vhost est généré (`expositions.conf.j2`) avec amont = `amont` explicite, sinon `http://<IP interne de la cible>:<port>` résolu depuis l'inventaire ; une exposition non résolvable (cible sans hôte actif, ou sans port) est listée mais non publiée. Variables `serveurs_nginx_groupe` (edge ciblé) et `serveurs_nginx_publier_expositions` (bascule). Les sites déclarés à la main continuent de coexister. Ajout du `port: 3000` à l'exposition `forge`.
|
||||
|
|
|
|||
5
Makefile
5
Makefile
|
|
@ -386,6 +386,11 @@ serveurs-verifier:
|
|||
serveurs-bootstrap:
|
||||
python3 scripts/serveurs.py bootstrap
|
||||
|
||||
.PHONY: instancier
|
||||
instancier:
|
||||
python3 scripts/instancier.py generer
|
||||
python3 scripts/instancier.py comparer
|
||||
|
||||
bases:
|
||||
python3 scripts/bases_donnees.py lister
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ serveurs:
|
|||
disque: 16G
|
||||
memoire: 2048
|
||||
coeurs: 2
|
||||
integrations:
|
||||
- clients_dns
|
||||
data-01:
|
||||
fonction: data
|
||||
etat: planifie
|
||||
|
|
@ -89,6 +91,9 @@ serveurs:
|
|||
disque: 16G
|
||||
memoire: 2048
|
||||
coeurs: 2
|
||||
integrations:
|
||||
- clients_dns
|
||||
- clients_supervision
|
||||
web-frontal-02:
|
||||
fonction: web-frontal
|
||||
etat: planifie
|
||||
|
|
|
|||
158
scripts/instancier.py
Normal file
158
scripts/instancier.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Generateur d'inventaire Set-OPS (Phase 3) : plan -> hosts.yml.
|
||||
|
||||
NON destructif : ecrit dans inventories/production/hosts.yml.genere et compare
|
||||
SEMANTIQUEMENT (via ansible-inventory --list) avec l'inventaire actuel. Aucune
|
||||
bascule tant que la comparaison n'est pas vide et validee.
|
||||
|
||||
Derivation :
|
||||
- host vars : ansible_host/ansible_user + proxmox_* (IP/VMID/VLAN/passerelle
|
||||
derives de la nomenclature ; placement/taille depuis docs/serveurs.yml) ;
|
||||
- groupes : socle (serveurs_debian/durcis) + groupes de service derives des
|
||||
applications de l'hote + groupe d'etat (hotes_actifs/hotes_planifies).
|
||||
- Les integrations clients_* ne sont PAS derivables du plan a ce stade : la
|
||||
comparaison les revele (lacune a combler en Phase 3b).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from inventory_rules import (
|
||||
charger_applications,
|
||||
charger_nomenclature,
|
||||
charger_serveurs,
|
||||
deriver_nomenclature,
|
||||
fonction_seq,
|
||||
)
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
INVENTAIRE = RACINE / "inventories/production/hosts.yml"
|
||||
GENERE = RACINE / "inventories/production/hosts.genere.yml"
|
||||
FICHIER_SERVEURS = RACINE / "docs/serveurs.yml"
|
||||
FICHIER_APPLICATIONS = RACINE / "docs/applications.yml"
|
||||
FICHIER_NOMENCLATURE = RACINE / "docs/nomenclature.yml"
|
||||
|
||||
GROUPES_SOCLE = ["serveurs_debian", "serveurs_durcis"]
|
||||
PLACEMENT = [("noeud", "proxmox_noeud"), ("stockage", "proxmox_stockage"),
|
||||
("disque", "proxmox_disque_taille"), ("memoire", "proxmox_memoire"),
|
||||
("coeurs", "proxmox_coeurs")]
|
||||
|
||||
|
||||
def generer() -> dict:
|
||||
serveurs = charger_serveurs(FICHIER_SERVEURS).get("serveurs", {})
|
||||
apps = charger_applications(FICHIER_APPLICATIONS).get("applications", {})
|
||||
nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE)
|
||||
|
||||
services_par_hote: dict = {}
|
||||
for app in apps.values():
|
||||
services_par_hote.setdefault(app.get("hote"), set()).add(app.get("groupe"))
|
||||
|
||||
children: dict = {
|
||||
"modeles_vm": {"hosts": {}},
|
||||
"hotes_actifs": {"hosts": {}},
|
||||
"hotes_planifies": {"hosts": {}},
|
||||
}
|
||||
for nom, srv in serveurs.items():
|
||||
_, seq = fonction_seq(nom)
|
||||
d = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {}
|
||||
hostvars = {
|
||||
"ansible_host": d.get("adresse_ip"),
|
||||
"ansible_user": "ansible",
|
||||
"proxmox_cidr": d.get("cidr"),
|
||||
"proxmox_passerelle": d.get("passerelle"),
|
||||
"proxmox_vlan": d.get("vlan"),
|
||||
"proxmox_vmid": int(d["vmid"]) if d.get("vmid") else None,
|
||||
}
|
||||
for cle_srv, cle_var in PLACEMENT:
|
||||
if str(srv.get(cle_srv, "")).strip():
|
||||
hostvars[cle_var] = srv[cle_srv]
|
||||
etat = "hotes_actifs" if srv.get("etat") == "actif" else "hotes_planifies"
|
||||
children[etat]["hosts"][nom] = hostvars
|
||||
groupes = set(GROUPES_SOCLE) | services_par_hote.get(nom, set()) | set(srv.get("integrations") or [])
|
||||
for groupe in sorted(groupes):
|
||||
children.setdefault(groupe, {"hosts": {}})["hosts"][nom] = None
|
||||
return {"all": {"children": children}}
|
||||
|
||||
|
||||
def ecrire() -> None:
|
||||
entete = ("# Inventaire GENERE depuis le plan (make instancier). NE PAS editer a la main.\n"
|
||||
"# Source : docs/serveurs.yml + docs/applications.yml + docs/nomenclature.yml.\n")
|
||||
with GENERE.open("w", encoding="utf-8") as fichier:
|
||||
fichier.write(entete)
|
||||
yaml.safe_dump(generer(), fichier, default_flow_style=False, sort_keys=True, allow_unicode=True)
|
||||
|
||||
|
||||
def _resolu(fichier: Path) -> tuple[dict, dict]:
|
||||
sortie = subprocess.run(["ansible-inventory", "-i", str(fichier), "--list"],
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
data = json.loads(sortie)
|
||||
hostvars = data.get("_meta", {}).get("hostvars", {})
|
||||
groupes: dict = {}
|
||||
for groupe, info in data.items():
|
||||
if groupe in ("_meta", "all", "ungrouped") or not isinstance(info, dict):
|
||||
continue
|
||||
for hote in (info.get("hosts") or []):
|
||||
groupes.setdefault(hote, set()).add(groupe)
|
||||
return hostvars, groupes
|
||||
|
||||
|
||||
def comparer() -> int:
|
||||
if not GENERE.exists():
|
||||
ecrire()
|
||||
hv_act, grp_act = _resolu(INVENTAIRE)
|
||||
hv_gen, grp_gen = _resolu(GENERE)
|
||||
hotes = sorted(set(hv_act) | set(hv_gen))
|
||||
ecarts = 0
|
||||
for hote in hotes:
|
||||
if hote not in hv_gen:
|
||||
print(f" - {hote} : ABSENT du genere"); ecarts += 1; continue
|
||||
if hote not in hv_act:
|
||||
print(f" - {hote} : EN TROP dans le genere"); ecarts += 1; continue
|
||||
g_manquants = grp_act.get(hote, set()) - grp_gen.get(hote, set())
|
||||
g_surplus = grp_gen.get(hote, set()) - grp_act.get(hote, set())
|
||||
vars_diff = [k for k in set(hv_act[hote]) | set(hv_gen[hote])
|
||||
if hv_act[hote].get(k) != hv_gen[hote].get(k)]
|
||||
if g_manquants or g_surplus or vars_diff:
|
||||
ecarts += 1
|
||||
print(f" - {hote} :")
|
||||
if g_manquants:
|
||||
print(f" groupes non reproduits : {', '.join(sorted(g_manquants))}")
|
||||
if g_surplus:
|
||||
print(f" groupes en trop : {', '.join(sorted(g_surplus))}")
|
||||
if vars_diff:
|
||||
details = ", ".join(f"{k} (inv={hv_act[hote].get(k)!r} gen={hv_gen[hote].get(k)!r})" for k in sorted(vars_diff))
|
||||
print(f" vars differentes : {details}")
|
||||
if ecarts == 0:
|
||||
print("DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible.")
|
||||
else:
|
||||
print(f"\n{ecarts} hote(s) avec ecart. Bascule INTERDITE tant que ce n'est pas vide.")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Generateur d'inventaire Set-OPS (Phase 3).")
|
||||
sub = parser.add_subparsers(dest="commande", required=True)
|
||||
sub.add_parser("generer", help="Ecrit hosts.yml.genere depuis le plan.")
|
||||
sub.add_parser("comparer", help="Compare le genere a l'inventaire actuel (semantique).")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.commande == "generer":
|
||||
ecrire()
|
||||
print(f"{GENERE.relative_to(RACINE)} genere depuis le plan.")
|
||||
elif args.commande == "comparer":
|
||||
return comparer()
|
||||
except Exception as exc:
|
||||
print(f"erreur: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -359,6 +359,9 @@ def valider_serveurs(registre: dict, nomenclature: dict | None = None) -> None:
|
|||
etat = str(srv.get("etat", "")).strip()
|
||||
if etat and etat not in ETATS_SERVEUR:
|
||||
raise ValueError(f"Serveur '{nom}': etat '{etat}' inconnu (attendu: {', '.join(sorted(ETATS_SERVEUR))}).")
|
||||
integrations = srv.get("integrations")
|
||||
if integrations is not None and not isinstance(integrations, list):
|
||||
raise ValueError(f"Serveur '{nom}': 'integrations' doit etre une liste.")
|
||||
|
||||
|
||||
def reconcilier_serveur(nom: str, srv: dict, hote_inventaire: dict | None, nomenclature: dict) -> dict:
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ def bootstrap() -> dict:
|
|||
v = str(h.get(cle_src, "")).strip()
|
||||
if v:
|
||||
srv[cle_dst] = int(v) if v.isdigit() else v
|
||||
integrations = sorted(g for g in (h.get("groupes") or []) if g.startswith("clients_"))
|
||||
if integrations:
|
||||
srv["integrations"] = integrations
|
||||
serveurs[nom] = srv
|
||||
return {"serveurs": serveurs}
|
||||
|
||||
|
|
|
|||
Reference in a new issue