diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f16f53..183f6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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://:` 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`. diff --git a/Makefile b/Makefile index 820c85d..b839705 100644 --- a/Makefile +++ b/Makefile @@ -370,11 +370,21 @@ inventaire-verifier: ansible-runtime ansible-inventory -i $(INVENTAIRE_PRODUCTION) --list > /dev/null python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) verifier-playbooks --dossier-playbooks $(DOSSIER_PLAYBOOKS_GROUPES) python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances --dossier-playbooks $(DOSSIER_PLAYBOOKS_GROUPES) + python3 scripts/serveurs.py verifier python3 scripts/applications.py verifier python3 scripts/bases_donnees.py verifier python3 scripts/domaines.py verifier -.PHONY: bases bases-verifier domaines domaines-verifier applications applications-verifier +.PHONY: bases bases-verifier domaines domaines-verifier applications applications-verifier serveurs serveurs-verifier serveurs-bootstrap +serveurs: + python3 scripts/serveurs.py lister + +serveurs-verifier: + python3 scripts/serveurs.py verifier + +serveurs-bootstrap: + python3 scripts/serveurs.py bootstrap + bases: python3 scripts/bases_donnees.py lister diff --git a/docs/serveurs.yml b/docs/serveurs.yml new file mode 100644 index 0000000..ec72026 --- /dev/null +++ b/docs/serveurs.yml @@ -0,0 +1,98 @@ +# Registre des serveurs (VM) du plan Set-OPS. +# Bootstrape depuis l'inventaire (make serveurs-bootstrap). L'inventaire +# reste autorite en Phase 2 ; VMID/IP/VLAN/passerelle sont DERIVES de la +# fonction via docs/nomenclature.yml (non stockes ici). +--- +serveurs: + collab-01: + fonction: collab + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + data-01: + fonction: data + etat: planifie + noeud: asgard + stockage: TrueNAS + disque: 160G + memoire: 2048 + coeurs: 4 + forge-01: + fonction: forge + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + idm-01: + fonction: idm + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + infra-dns-01: + fonction: infra-dns + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + infra-edge-01: + fonction: infra-edge + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + infra-mail-01: + fonction: infra-mail + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + infra-pki-01: + fonction: infra-pki + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + mon-01: + fonction: mon + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + obs-01: + fonction: obs + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 4 + web-dorsal-01: + fonction: web-dorsal + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + web-frontal-01: + fonction: web-frontal + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 + web-frontal-02: + fonction: web-frontal + etat: planifie + stockage: TrueNAS + disque: 16G + memoire: 2048 + coeurs: 2 diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 925e021..f66c906 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -27,8 +27,10 @@ from inventory_rules import ( charger_dependances, charger_domaines, charger_nomenclature, + charger_serveurs, est_groupe_operationnel, groupes_operationnels_connus, + reconcilier_serveur, valider_applications, valider_bases, ) @@ -42,6 +44,7 @@ FICHIER_NOMENCLATURE = RACINE / "docs/nomenclature.yml" FICHIER_BASES = RACINE / "docs/bases-donnees.yml" FICHIER_APPLICATIONS = RACINE / "docs/applications.yml" FICHIER_DOMAINES = RACINE / "docs/domaines.yml" +FICHIER_SERVEURS = RACINE / "docs/serveurs.yml" # Garde-fous des executions (verifier / deployer) depuis l'interface. JETON = secrets.token_urlsafe(18) @@ -169,6 +172,26 @@ def liste_hotes(data: dict) -> list[dict]: return hotes +def serveurs_reconcilies(data: dict) -> list: + """Serveurs du plan + valeurs derivees + statut de reconciliation (lecture).""" + registre = charger_serveurs(FICHIER_SERVEURS) + nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE) + hotes = {h["nom"]: h for h in liste_hotes(data) if h.get("nom")} + resultat = [] + for nom, srv in (registre.get("serveurs") or {}).items(): + rec = reconcilier_serveur(nom, srv, hotes.get(nom), nomenclature) + d = rec["derive"] + statut = "absent" if rec["absent_inventaire"] else ("divergence" if rec["divergences"] else "reconcilie") + resultat.append({ + "nom": nom, "fonction": srv.get("fonction", ""), "etat": srv.get("etat", ""), + "noeud": srv.get("noeud", ""), "stockage": srv.get("stockage", ""), + "disque": srv.get("disque", ""), "memoire": srv.get("memoire", ""), "coeurs": srv.get("coeurs", ""), + "vmid": d.get("vmid", ""), "adresse_ip": d.get("adresse_ip", ""), "vlan": d.get("vlan", ""), + "statut": statut, "divergences": rec["divergences"], + }) + return resultat + + def inventaire_api(path: Path) -> dict: data = charger_yaml(path) dependencies = charger_dependances(FICHIER_DEPENDANCES) @@ -182,6 +205,7 @@ def inventaire_api(path: Path) -> dict: "bases": charger_bases_donnees(FICHIER_BASES), "applications": charger_applications(FICHIER_APPLICATIONS), "domaines": charger_domaines(FICHIER_DOMAINES), + "serveurs": serveurs_reconcilies(data), "hotes": liste_hotes(data), } @@ -553,6 +577,7 @@ HTML = r"""
+ @@ -612,6 +637,7 @@ HTML = r""" let basesModifie = false; let applications = []; let applicationsModifie = false; + let serveurs = []; let selection = -1; let selectionNom = null; let ongletActif = 'reseau'; @@ -656,6 +682,7 @@ HTML = r""" chaine = data.chaine || {}; chargerBases(data); chargerApplications(data); + serveurs = data.serveurs || []; hotes = data.hotes; estProduction = !!data.production; modifie = false; verifie = {}; @@ -791,6 +818,7 @@ HTML = r""" function setVue(v) { vuePrincipale = v; document.getElementById('btn-vue-cartes').classList.toggle('on', v === 'cartes'); + document.getElementById('btn-vue-serveurs').classList.toggle('on', v === 'serveurs'); document.getElementById('btn-vue-chaine').classList.toggle('on', v === 'chaine'); document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications'); document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases'); @@ -799,6 +827,7 @@ HTML = r""" function dessinerGrilles() { document.getElementById('chips').style.display = vuePrincipale === 'cartes' ? '' : 'none'; + if (vuePrincipale === 'serveurs') { dessinerServeurs(); return; } if (vuePrincipale === 'chaine') { dessinerArbre(); return; } if (vuePrincipale === 'applications') { dessinerApplications(); return; } if (vuePrincipale === 'bases') { dessinerBases(); return; } @@ -1029,6 +1058,33 @@ HTML = r""" message('Applications sauvegardées.', 'ok'); } + function dessinerServeurs() { + const cible = document.getElementById('grilles'); + if (!serveurs.length) { cible.innerHTML = '
Aucun serveur dans le planLance « make serveurs-bootstrap » pour générer docs/serveurs.yml depuis l\\'inventaire.
'; return; } + const clsStatut = (s) => s === 'reconcilie' ? 'actif' : (s === 'divergence' ? 'bloque' : 'attente'); + const libStatut = (s) => s === 'reconcilie' ? 'réconcilié' : (s === 'divergence' ? 'divergence' : 'absent inv.'); + const lignes = serveurs.map(s => ` +
+ + + + + + + + + ${libStatut(s.statut)} + ${(s.divergences && s.divergences.length) ? `
${s.divergences.map(echapper).join(' ; ')}
` : ''} +
`).join(''); + const nbDiv = serveurs.filter(s => s.statut !== 'reconcilie').length; + cible.innerHTML = ` +
+
Serveurs du plan ${serveurs.length}${nbDiv ? ' · ' + nbDiv + ' non réconcilié(s)' : ' · tous réconciliés'}
+
${lignes}
+
Lecture seule (Phase 2). VMID / IP / VLAN sont dérivés de la fonction via la nomenclature ; « réconcilié » = identique à l'inventaire. La génération de l'inventaire depuis le plan viendra en Phase 3.
+
`; + } + function dessinerDependances() { const cible = document.getElementById('dependances'); const noms = Object.keys(dependances).sort(); diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 37266ce..666ac8b 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -3,6 +3,7 @@ from __future__ import annotations +import re from pathlib import Path import yaml @@ -293,6 +294,94 @@ def charger_nomenclature(path: Path | None) -> dict: return data +ETATS_SERVEUR = {"actif", "planifie"} + + +def fonction_seq(nom: str) -> tuple[str, int | None]: + """Decompose un hostname 'fonction-NN' en (fonction, NN).""" + m = re.match(r"^(.+)-(\d+)$", str(nom or "")) + if m: + return m.group(1), int(m.group(2)) + return str(nom or ""), None + + +def deriver_nomenclature(fonction: str, seq: int, nomenclature: dict) -> dict | None: + """Derive VMID / VLAN / IP / passerelle / CIDR depuis la fonction et le rang. + + Source unique de la derivation (miroir Python de l'auto-proposition du GUI). + """ + fonctions = nomenclature.get("fonctions") or {} + categories = nomenclature.get("categories") or {} + f = fonctions.get(fonction) + if not f or seq is None: + return None + cat = f.get("categorie") + svc = f.get("service") + c = categories.get(cat) or categories.get(str(cat)) + if not c: + return None + base3 = ".".join(str(c.get("sous_reseau", "")).split(".")[:3]) + return { + "vmid": f"9{cat}{svc}{int(seq):02d}", + "vlan": c.get("vlan"), + "adresse_ip": f"{base3}.{svc * 10 + int(seq)}", + "passerelle": c.get("passerelle"), + "cidr": nomenclature.get("cidr_hote", 24), + } + + +def charger_serveurs(path: Path | None) -> dict: + """Charge le registre des serveurs (VM) du plan.""" + if not path or not path.exists(): + return {"serveurs": {}} + with path.open("r", encoding="utf-8") as fichier: + data = yaml.safe_load(fichier) or {} + if not isinstance(data, dict): + raise ValueError(f"{path} ne contient pas une table YAML.") + data.setdefault("serveurs", {}) + return data + + +def valider_serveurs(registre: dict, nomenclature: dict | None = None) -> None: + """Valide la coherence du registre des serveurs.""" + serveurs = registre.get("serveurs") or {} + fonctions = (nomenclature or {}).get("fonctions") or {} + if not isinstance(serveurs, dict): + raise ValueError("serveurs doit etre une table.") + for nom, srv in serveurs.items(): + if not isinstance(srv, dict): + raise ValueError(f"Serveur '{nom}': table attendue.") + fonction = str(srv.get("fonction", "")).strip() + if not fonction: + raise ValueError(f"Serveur '{nom}': champ 'fonction' requis.") + if fonctions and fonction not in fonctions: + raise ValueError(f"Serveur '{nom}': fonction '{fonction}' inconnue de la nomenclature.") + 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))}).") + + +def reconcilier_serveur(nom: str, srv: dict, hote_inventaire: dict | None, nomenclature: dict) -> dict: + """Compare les valeurs derivees (fonction+rang) aux valeurs de l'inventaire. + + Renvoie {derive, inventaire, divergences} ; divergences vide = reconcilie. + """ + _, seq = fonction_seq(nom) + derive = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {} + inv = hote_inventaire or {} + champs = {"vmid": "vmid", "adresse_ip": "adresse_ip", "vlan": "vlan", "passerelle": "passerelle"} + divergences = [] + for cle_derivee, cle_inv in champs.items(): + if cle_derivee not in derive: + continue + attendu = str(derive[cle_derivee]) + present = str(inv.get(cle_inv, "")).strip() + if present and present != attendu: + divergences.append(f"{cle_derivee}: inventaire={present} != derive={attendu}") + return {"derive": derive, "inventaire": inv, "divergences": divergences, + "absent_inventaire": hote_inventaire is None} + + def charger_dependances(path: Path | None) -> dict: if not path: return {} diff --git a/scripts/serveurs.py b/scripts/serveurs.py new file mode 100644 index 0000000..d3716f1 --- /dev/null +++ b/scripts/serveurs.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Registre des serveurs (VM) du plan Set-OPS. + +Phase 2 : l'inventaire reste AUTORITE. Ce registre est bootstrape depuis lui +(reconciliation en lecture). La generation de l'inventaire depuis le plan +viendra en Phase 3 (make instancier). +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import yaml + +from inventory_gui import charger_yaml, liste_hotes +from inventory_rules import ( + charger_nomenclature, + charger_serveurs, + fonction_seq, + reconcilier_serveur, + valider_serveurs, +) + +RACINE = Path(__file__).resolve().parents[1] +FICHIER = RACINE / "docs/serveurs.yml" +FICHIER_NOMENCLATURE = RACINE / "docs/nomenclature.yml" +INVENTAIRE = RACINE / "inventories/production/hosts.yml" + +# Champs de placement / dimensionnement NON derivables (proviennent du plan). +CHAMPS_PLAN = [("noeud", "noeud"), ("stockage", "stockage"), + ("disque_taille", "disque"), ("memoire", "memoire"), ("coeurs", "coeurs")] + + +def ecrire(registre: dict) -> None: + entete = ( + "# Registre des serveurs (VM) du plan Set-OPS.\n" + "# Bootstrape depuis l'inventaire (make serveurs-bootstrap). L'inventaire\n" + "# reste autorite en Phase 2 ; VMID/IP/VLAN/passerelle sont DERIVES de la\n" + "# fonction via docs/nomenclature.yml (non stockes ici).\n" + "---\n" + ) + with FICHIER.open("w", encoding="utf-8") as fichier: + fichier.write(entete) + yaml.safe_dump({"serveurs": registre.get("serveurs", {}) or {}}, + fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) + + +def hotes_inventaire() -> dict: + return {h["nom"]: h for h in liste_hotes(charger_yaml(INVENTAIRE)) if h.get("nom")} + + +def bootstrap() -> dict: + serveurs = {} + for nom, h in hotes_inventaire().items(): + fonction, _ = fonction_seq(nom) + srv = {"fonction": fonction, "etat": h.get("etat", "planifie")} + for cle_src, cle_dst in CHAMPS_PLAN: + v = str(h.get(cle_src, "")).strip() + if v: + srv[cle_dst] = int(v) if v.isdigit() else v + serveurs[nom] = srv + return {"serveurs": serveurs} + + +def lister() -> None: + registre = charger_serveurs(FICHIER) + serveurs = registre.get("serveurs", {}) + if not serveurs: + print("Aucun serveur. Lance: make serveurs-bootstrap") + return + nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE) + hotes = hotes_inventaire() + for nom, srv in serveurs.items(): + rec = reconcilier_serveur(nom, srv, hotes.get(nom), nomenclature) + d = rec["derive"] + if rec["absent_inventaire"]: + statut = "ABSENT de l'inventaire" + elif rec["divergences"]: + statut = "DIVERGENCE" + else: + statut = "reconcilie" + print(f"{nom} [fonction {srv.get('fonction')}, etat {srv.get('etat', '?')}]" + f" derive: vmid {d.get('vmid', '?')}, ip {d.get('adresse_ip', '?')}, vlan {d.get('vlan', '?')} -> {statut}") + for dv in rec["divergences"]: + print(f" ! {dv}") + + +def verifier() -> int: + registre = charger_serveurs(FICHIER) + nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE) + valider_serveurs(registre, nomenclature) + hotes = hotes_inventaire() + divergents = [] + for nom, srv in registre.get("serveurs", {}).items(): + rec = reconcilier_serveur(nom, srv, hotes.get(nom), nomenclature) + if rec["divergences"] or rec["absent_inventaire"]: + divergents.append(nom) + if divergents: + print(f"Registre valide, mais {len(divergents)} serveur(s) non reconcilie(s): {', '.join(divergents)}") + else: + print("Registre des serveurs valide et reconcilie avec l'inventaire.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Registre des serveurs (VM) du plan Set-OPS.") + sub = parser.add_subparsers(dest="commande", required=True) + sub.add_parser("lister", help="Affiche les serveurs et leur reconciliation avec l'inventaire.") + sub.add_parser("verifier", help="Valide le registre et signale les divergences.") + sub.add_parser("bootstrap", help="(Re)genere docs/serveurs.yml depuis l'inventaire actuel.") + + args = parser.parse_args() + try: + if args.commande == "lister": + lister() + elif args.commande == "verifier": + return verifier() + elif args.commande == "bootstrap": + registre = bootstrap() + valider_serveurs(registre, charger_nomenclature(FICHIER_NOMENCLATURE)) + ecrire(registre) + print(f"docs/serveurs.yml genere depuis l'inventaire ({len(registre['serveurs'])} serveurs).") + except Exception as exc: + print(f"erreur: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())