L'exploitant a demande si l'adressage derive ne valait pas pour toute flotte, site compris. Oui — decision du 12 septembre, index 37 pour le site. J'avais affirme le contraire a l'entree (11) du CHANGELOG, en recopiant un commentaire perime de huit jours au lieu de mesurer. Un commentaire perime se propage : celui-la s'etait aussi loge dans la docstring de ConsoleSite. P20 s'appelle « Adressage 100% derive du seed (aucun stocke) » et sa portee etait « l'instance liee + les modeles » : elle ne regardait jamais le site, qui stockait sous_reseau et passerelle pour ses sept zones. Elle juge desormais les deux natures d'un underlay et refuse une entree qui ne declare pas la sienne — on ne peut pas juger ce qu'on ne sait pas lire. Controle negatif joue : une zone remise a 10.36.31.0/24 est refusee, en nommant l'index dont elle aurait du descendre. Le loader derive sous_reseau et passerelle a la lecture, pour que rien ne change chez les consommateurs. Mesure : 14 valeurs retirees du fichier, zero ecart sur ce qu'ils lisent, et make underlay, devis-sdn et devis-reseau passent. Valide : make test a 0 echec, P20 verte sur 2 nomenclatures et 7 zones de site. P02 et P60 restent, pour les raisons deja consignees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5012 lines
282 KiB
Python
5012 lines
282 KiB
Python
#!/usr/bin/env python3
|
||
"""Interface web locale pour gerer un inventaire Set-OPS."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import secrets
|
||
import socket
|
||
import subprocess
|
||
import tempfile
|
||
import time
|
||
import threading
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from pathlib import Path
|
||
from urllib.parse import urlparse
|
||
|
||
import yaml
|
||
|
||
import devis_reseau
|
||
|
||
from inventory_rules import instance_courante # noqa: E402
|
||
from inventory_rules import (
|
||
ecriture_atomique,
|
||
GROUPE_HOTES_ACTIFS,
|
||
GROUPE_HOTES_PLANIFIES,
|
||
GROUPES_ETAT_HOTE,
|
||
chaine_connexion,
|
||
charger_applications,
|
||
charger_bases_donnees,
|
||
charger_dependances,
|
||
charger_domaines,
|
||
charger_nomenclature,
|
||
charger_serveurs,
|
||
est_groupe_operationnel,
|
||
groupes_operationnels_connus,
|
||
integrations_universelles,
|
||
reconcilier_serveur,
|
||
valider_applications,
|
||
catalogue_liens,
|
||
valider_bases,
|
||
valider_domaines,
|
||
valider_serveurs,
|
||
)
|
||
|
||
RACINE = Path(__file__).resolve().parents[1]
|
||
INSTANCE = instance_courante()
|
||
|
||
|
||
def _inventaire(instance: Path, *noms: str) -> Path:
|
||
"""Inventaire de l'instance, détection rétro-compatible (premier existant)."""
|
||
for nom in noms:
|
||
p = instance / "inventories" / nom / "hosts.yml"
|
||
if p.exists():
|
||
return p
|
||
# Aucun hosts.yml encore généré : viser le répertoire d'inventaire déjà présent
|
||
# (ses group_vars y sont), sinon le premier nom par défaut.
|
||
for nom in noms:
|
||
if (instance / "inventories" / nom).is_dir():
|
||
return instance / "inventories" / nom / "hosts.yml"
|
||
return instance / "inventories" / noms[0] / "hosts.yml"
|
||
|
||
|
||
# Un seul inventaire par instance (modèle « séparation par instance ») ; on accepte
|
||
# l'ancien découpage lab/production en rétro-compatibilité. SETOPS_INVENTAIRE force.
|
||
_force = os.environ.get("SETOPS_INVENTAIRE")
|
||
INVENTAIRE_DEFAUT = Path(_force) if _force else _inventaire(INSTANCE, "principal", "production")
|
||
INVENTAIRE_PRODUCTION = INVENTAIRE_DEFAUT.resolve()
|
||
# Inventaire qui porte la config Proxmox / le clonage (modèle) : lab > principal > production.
|
||
INVENTAIRE_MODELE = _inventaire(INSTANCE, "lab", "principal", "production")
|
||
DOSSIER_PLAYBOOKS_GROUPES = RACINE / "playbooks/groupes"
|
||
FICHIER_DEPENDANCES = RACINE / "docs/dependances-groupes.yml"
|
||
FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml"
|
||
FICHIER_BASES = INSTANCE / "plan/bases-donnees.yml"
|
||
FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml"
|
||
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
|
||
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
|
||
|
||
# Intrants de base (panneau GUI). Cibles d'ecriture possedees par le GUI.
|
||
# Identite : group_vars/all/10-intrants.yml de l'inventaire. Selon l'instance c'est
|
||
# un fichier reel (modele « par instance ») ou un symlink vers une source partagee
|
||
# (ecrire suit le symlink). Proxmox : group_vars de l'inventaire modele. Les SECRETS
|
||
# n'y entrent jamais.
|
||
INTRANTS_IDENTITE = INVENTAIRE_DEFAUT.parent / "group_vars/all/10-intrants.yml"
|
||
INTRANTS_PROXMOX = INVENTAIRE_MODELE.parent / "group_vars/proxmox.yml"
|
||
# Le seed reseau (`index`) vit dans la nomenclature (le plan reseau) — d'ou tout
|
||
# l'adressage se derive. Le panneau Intrants le lit/ecrit la, chirurgicalement, sans
|
||
# reformater le fichier (cf. _ecrire_index_nomenclature).
|
||
# Frontiere nord/sud (OPNsense) : parametres NON sensibles du pare-feu de bordure.
|
||
# La cle/secret d'API n'y entrent JAMAIS — voute uniquement (cf. INTRANTS_CLES_INTERDITES).
|
||
# Fabric physique : `underlay.yml`, monte par symlink a la racine du moteur depuis le
|
||
# depot de l'HEBERGEUR (ses switches, ses cables). Distinct de l'instance : il ne suit
|
||
# pas `make instance-utiliser`. Imbrique sous `underlay:` et riche en commentaires :
|
||
# on l'ecrit chirurgicalement, jamais par un safe_dump qui les effacerait.
|
||
FICHIER_UNDERLAY = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml"))
|
||
|
||
|
||
def _depot_hebergeur() -> Path | None:
|
||
"""Depot de l'HEBERGEUR, derive du symlink `underlay.yml` qui le designe deja.
|
||
|
||
Un hebergeur sert PLUSIEURS tenants et n'a qu'une frontiere : ses intrants ne
|
||
peuvent pas vivre chez le tenant actif, sinon basculer sur un invite les ferait
|
||
disparaitre du panneau. None si aucun underlay n'est monte.
|
||
"""
|
||
if not FICHIER_UNDERLAY.exists():
|
||
return None
|
||
return FICHIER_UNDERLAY.resolve().parent
|
||
|
||
|
||
# --- CE QUE CETTE CONSOLE PEUT, DERIVE DE CE QUE SA MACHINE PORTE --------------------
|
||
#
|
||
# LE DEFAUT QUI A RENDU CECI NECESSAIRE (2026-09-16). Servie par le runner d'un SITE,
|
||
# cette console affichait ZERO serveur, ZERO application, ZERO base — sans une erreur.
|
||
# `serveur_ops` RETIRE le lien `instance` sur un runner d'hebergeur (a juste titre : un
|
||
# lien perime vers un tenant serait pire), `charger_yaml` rend `{"all": {"children": {}}}`
|
||
# sur un fichier absent, et la page dessinait ce vide comme un plan vide.
|
||
#
|
||
# C'est la forme exacte que P79 garde partout ailleurs : une derivation qui ne trouve rien
|
||
# ne se distingue pas d'une derivation qui n'a rien a trouver. Ici elle avait le pire
|
||
# visage possible — celui d'un ecosysteme qu'on croit sans machines.
|
||
#
|
||
# ON DERIVE, ON NE DECLARE PAS. Un `serveur_ops_gui_mode: site|tenant` au plan serait une
|
||
# SECONDE liste, et une liste qui suit une autre prend du retard : il suffirait de
|
||
# reconfigurer un runner sans y penser. Les deux symlinks, eux, SONT le pouvoir :
|
||
#
|
||
# instance/ -> je configure CET ecosysteme (j'ai sa voute)
|
||
# underlay.yml -> je materialise sur CETTE fabric (j'ai celle du site)
|
||
#
|
||
# C'est la meme coupure que `roles/serveur_ops` exige deja dans son assertion, et que
|
||
# `docs/responsabilites-locataire-hebergeur.md` nomme : calculer, configurer, materialiser.
|
||
|
||
def contexte() -> dict:
|
||
"""La portee de cette console — DELEGUEE a la classe qui la sert.
|
||
|
||
Ce que cette fonction faisait a la main vit desormais dans `Console` et ses deux
|
||
branches. Elle reste parce que le harnais et les preuves l'appellent par ce nom, et
|
||
parce qu'un appelant n'a pas a savoir laquelle des deux consoles lui repond.
|
||
"""
|
||
return console().contexte()
|
||
|
||
|
||
def inventaire_du_site() -> dict:
|
||
"""Les machines du SITE, mises dans la forme d'un `hosts.yml`.
|
||
|
||
L'inventaire d'un site est un SCRIPT (`site_inventaire.py`) : on l'EXECUTE — en
|
||
l'important, sans sous-processus a chaque page — et l'on traduit son resultat dans la
|
||
forme que le reste de ce fichier sait lire. Juger sa source reviendrait a relire le
|
||
raisonnement au lieu du resultat.
|
||
"""
|
||
import site_inventaire
|
||
brut = site_inventaire.inventaire() or {}
|
||
hostvars = ((brut.get("_meta") or {}).get("hostvars") or {})
|
||
enfants_site: dict = {}
|
||
for groupe, valeur in brut.items():
|
||
if groupe == "_meta" or not isinstance(valeur, dict):
|
||
continue
|
||
hotes = valeur.get("hosts") or []
|
||
if not hotes:
|
||
continue
|
||
enfants_site[groupe] = {"hosts": {h: dict(hostvars.get(h) or {}) for h in hotes}}
|
||
return {"all": {"children": enfants_site}}
|
||
|
||
|
||
def sonder_donnees(data: dict) -> dict:
|
||
"""Meme sonde que `sonder_hotes`, sur un inventaire DEJA charge (le site)."""
|
||
cibles = [(h.get("nom"), h.get("adresse_ip")) for h in liste_hotes(data)]
|
||
with ThreadPoolExecutor(max_workers=16) as executeur:
|
||
resultats = executeur.map(lambda c: (c[0], _joignable(c[1])), cibles)
|
||
return {nom: ok for nom, ok in resultats if nom}
|
||
|
||
|
||
# CE QUE CHAQUE POINT D'ECRITURE EXIGE. Toute route POST y figure — P81 refuse qu'une
|
||
# route nouvelle echappe a la garde en silence, parce qu'une garde qui suit une liste
|
||
# prend du retard sur elle.
|
||
POUVOIR_REQUIS = {
|
||
"/api/inventaire": "editer_le_plan",
|
||
"/api/bases": "editer_le_plan",
|
||
"/api/applications": "editer_le_plan",
|
||
"/api/serveurs": "editer_le_plan",
|
||
"/api/nomenclature": "editer_le_plan",
|
||
"/api/domaines": "editer_le_plan",
|
||
"/api/intrants": "editer_le_plan",
|
||
"/api/instancier": "editer_le_plan",
|
||
"/api/instance-creer": "editer_le_plan",
|
||
"/api/instance-utiliser": "editer_le_plan",
|
||
"/api/verifier": "configurer",
|
||
"/api/deployer": "configurer",
|
||
"/api/pousser": "configurer",
|
||
"/api/creer": "materialiser",
|
||
# LA GARDE DE CETTE ROUTE EST PLUS FINE QUE LES AUTRES, ET C'EST VOULU. Un runbook de
|
||
# site et un runbook de locataire n'exigent pas le meme pouvoir : un seul mot ici
|
||
# serait soit trop laxiste (un site lancerait un geste de locataire), soit trop
|
||
# severe (un locataire ne pourrait plus rien). La portee est DECLAREE au registre,
|
||
# et c'est elle qu'on resout — jamais celle que le navigateur annonce.
|
||
"/api/runbook-etape": "selon-le-runbook",
|
||
}
|
||
|
||
|
||
# CE QU'UNE PORTEE DE RUNBOOK EXIGE DE LA CONSOLE. Les deux pouvoirs de `contexte()`, et
|
||
# rien d'autre : `poste` porte les deux liens, il doit donc les deux.
|
||
POUVOIR_PAR_PORTEE = {
|
||
"tenant": ("configurer",),
|
||
"site": ("materialiser",),
|
||
"poste": ("configurer", "materialiser"),
|
||
"toute": (),
|
||
}
|
||
|
||
# Ce qu'une valeur d'etape a le droit d'etre. Les commandes partent en LISTE (jamais par
|
||
# un shell), donc l'injection n'est pas le risque ; ce qui l'est, c'est qu'une valeur se
|
||
# fasse lire par `make` comme une option. On refuse donc ce qui commence par un tiret.
|
||
MOTIF_VALEUR = re.compile(r"^[^-\s][\w .@:/+=,-]{0,199}$", re.UNICODE)
|
||
|
||
|
||
def _fichier_frontiere() -> Path:
|
||
"""Les intrants de la frontiere — a la RACINE du depot de site depuis le 2026-08-22.
|
||
|
||
La frontiere est un objet du MONDE PHYSIQUE : ses reglages vivent avec `underlay.yml`
|
||
et `proxmox-hebergeur.yml`, pas dans les group_vars d'un tenant. Range chez un
|
||
locataire, ce fichier a garde une adresse d'API perimee sans que personne la voie —
|
||
et l'applicateur restait suspendu dessus, sans message.
|
||
|
||
Les anciens emplacements restent lus, pour un site pas encore migre.
|
||
"""
|
||
base = _depot_hebergeur()
|
||
if base:
|
||
racine = base / "opnsense.yml"
|
||
if racine.is_file():
|
||
return racine
|
||
for nom in ("principal", "production", "lab"):
|
||
p = base / "inventories" / nom / "group_vars" / "opnsense.yml"
|
||
if p.is_file():
|
||
return p
|
||
return racine # a creer la, pas ailleurs
|
||
return INVENTAIRE_DEFAUT.parent / "group_vars/opnsense.yml"
|
||
|
||
|
||
def _fichier_proxmox_hebergeur() -> Path:
|
||
"""proxmox-hebergeur.yml de l'HEBERGEUR ; repli sur le proxmox.yml du tenant.
|
||
|
||
Meme raisonnement que la frontiere et l'underlay (D-14) : un cluster appartient a
|
||
l'hebergeur, pas au tenant qui y loge. Recopie chez chaque tenant, l'inventaire du
|
||
cluster avait deja diverge — deux listes de stockages contradictoires pour le meme
|
||
materiel. Le repli garde un depot sans underlay monte parfaitement utilisable.
|
||
"""
|
||
base = _depot_hebergeur()
|
||
if base:
|
||
return base / "proxmox-hebergeur.yml"
|
||
return INTRANTS_PROXMOX
|
||
|
||
|
||
INTRANTS_FRONTIERE = _fichier_frontiere()
|
||
INTRANTS_PROXMOX_HEBERGEUR = _fichier_proxmox_hebergeur()
|
||
FICHIERS_INTRANTS = {"identite": INTRANTS_IDENTITE, "proxmox": INTRANTS_PROXMOX,
|
||
"proxmox_hebergeur": INTRANTS_PROXMOX_HEBERGEUR,
|
||
"reseau": FICHIER_NOMENCLATURE, "frontiere": INTRANTS_FRONTIERE,
|
||
"fabric": FICHIER_UNDERLAY}
|
||
|
||
# Intrants de fabric -> chemin dans underlay.yml. Seules les valeurs PLATES sont ici :
|
||
# `reseaux` et `hotes` sont des listes de tables, hors de portee du panneau (cf. la vue
|
||
# dediee, a venir). Un intrant absent d'ici s'edite encore dans le YAML.
|
||
CHEMINS_UNDERLAY = {
|
||
"underlay_routeur": ("routeur",),
|
||
"underlay_dialecte": ("dialecte",),
|
||
"underlay_stp_mode": ("stp", "mode"),
|
||
"underlay_stp_topologie": ("stp", "topologie"),
|
||
}
|
||
|
||
# (variable, fichier, classe, section, libelle, type, proprietaire)
|
||
#
|
||
# `proprietaire` repond a une question que le panneau posait sans y repondre : en
|
||
# ouvrant l'ecran on trouvait, cote a cote et sans distinction, des valeurs du TENANT
|
||
# (son domaine, son realm, son modele) et des valeurs de l'HEBERGEUR (son cluster, sa
|
||
# frontiere, sa fabric). Ce sont deux proprietaires, deux depots, deux cycles de vie.
|
||
INTRANTS_SCHEMA = [
|
||
("index", "reseau", "constante", "Réseau", "Index d'instance dans la fédération (seed : dérive supernet 10.<index>, VLAN 1000+index×10+zone, VMID)", "int", "tenant"),
|
||
("domaine_interne", "identite", "constante", "Identité", "Domaine DNS interne", "str", "tenant"),
|
||
("fuseau_horaire", "identite", "defaut", "Identité", "Fuseau horaire", "str", "tenant"),
|
||
("organisation", "identite", "defaut", "Identité", "Organisation (annuaire LDAP, certificats)", "str", "tenant"),
|
||
("identite_realm", "identite", "defaut", "Identité", "Realm SSO (Keycloak)", "str", "tenant"),
|
||
("nftables_admin_ssh", "identite", "catalogue", "Sécurité", "Sources SSH d'administration toujours autorisées (garde anti-lockout du pare-feu)", "liste", "tenant"),
|
||
("proxmox_api_host", "proxmox_hebergeur", "constante", "Cluster", "Hôte API Proxmox", "str", "hebergeur"),
|
||
("proxmox_api_user", "proxmox_hebergeur", "constante", "Cluster", "Utilisateur API", "str", "hebergeur"),
|
||
("proxmox_api_port", "proxmox_hebergeur", "constante", "Cluster", "Port API", "str", "hebergeur"),
|
||
("proxmox_validate_certs", "proxmox_hebergeur", "constante", "Cluster", "Valider les certificats TLS", "bool", "hebergeur"),
|
||
("proxmox_clone_source_nom", "proxmox", "constante", "Proxmox", "Nom du golden template", "str", "tenant"),
|
||
("proxmox_clone_vmid_modele", "proxmox", "constante", "Proxmox", "VMID du golden template", "int", "tenant"),
|
||
("proxmox_clone_noeud", "proxmox", "defaut", "Proxmox", "Nœud Proxmox (défaut)", "str", "tenant"),
|
||
("proxmox_clone_stockage", "proxmox", "defaut", "Proxmox", "Stockage (défaut)", "str", "tenant"),
|
||
# REPLI, pas un reglage de la flotte. Chaque VM du plan recoit le VNet DERIVE de sa
|
||
# zone (`instancier` pose `proxmox_pont` par hote) ; cette valeur n'est consultee que
|
||
# si l'hote n'en porte pas — c'est-a-dire jamais dans le flux normal. L'intitule le
|
||
# dit, sans quoi on la corrige, on redeploie, et rien ne change : `vmbr3` y est reste
|
||
# faux dix jours sans que rien ne bronche (2026-08-13).
|
||
("proxmox_clone_pont", "proxmox", "defaut", "Proxmox",
|
||
"Pont réseau — clones manuels seulement (les VM du plan reçoivent le VNet de leur zone)",
|
||
"str", "tenant"),
|
||
("proxmox_noeuds", "proxmox_hebergeur", "catalogue", "Cluster", "Nœuds disponibles (liste)", "liste", "hebergeur"),
|
||
("proxmox_stockages", "proxmox_hebergeur", "catalogue", "Cluster", "Stockages disponibles (liste)", "liste", "hebergeur"),
|
||
("proxmox_ponts", "proxmox_hebergeur", "catalogue", "Cluster", "Ponts réseau disponibles (liste)", "liste", "hebergeur"),
|
||
("opnsense_api_url", "frontiere", "constante", "Frontière", "URL de gestion de la frontière OPNsense (API REST)", "str", "hebergeur"),
|
||
("opnsense_api_verifier_certs", "frontiere", "defaut", "Frontière", "Valider le certificat TLS de la frontière", "bool", "hebergeur"),
|
||
("opnsense_wan_ip", "frontiere", "constante", "Frontière", "Adresse publique du WAN de la frontière (face Internet de l'écosystème)", "str", "hebergeur"),
|
||
("opnsense_if_wan", "frontiere", "defaut", "Frontière", "Interface WAN — IDENTIFIANT OPNsense (wan), pas le libellé ni le périphérique", "str", "hebergeur"),
|
||
("opnsense_if_transit", "frontiere", "defaut", "Frontière", "Interface de transit — IDENTIFIANT OPNsense (opt1, opt2…), pas « igb1 » ni le libellé affiché", "str", "hebergeur"),
|
||
("opnsense_if_gestion", "frontiere", "defaut", "Frontière", "Interface où arrivent les plans d'administration attachés à la frontière — IDENTIFIANT OPNsense (lan). Une règle SSH posée ailleurs ne peut jamais correspondre", "str", "hebergeur"),
|
||
("underlay_routeur", "fabric", "constante", "Fabric", "Switch qui porte le routage (SVI, ACL, routes) — les autres restent en L2 pur", "str", "hebergeur"),
|
||
("underlay_dialecte", "fabric", "constante", "Fabric", "Dialecte de CLI du commutateur (cisco | binardat)", "str", "hebergeur"),
|
||
("underlay_stp_mode", "fabric", "defaut", "Fabric", "Mode de spanning-tree (rstp | mstp | pvst)", "str", "hebergeur"),
|
||
("underlay_stp_topologie", "fabric", "defaut", "Fabric", "Topologie de câblage (etoile | anneau | maille)", "str", "hebergeur"),
|
||
# `opnsense_prochain_saut` n'est PLUS un intrant : il derive du reseau de transit de
|
||
# l'underlay (`passerelle` du reseau portant `passerelle_sortie`). Le saisir ici en
|
||
# doublon ouvrirait la porte a deux valeurs contradictoires pour un seul lien.
|
||
]
|
||
# Cles secretes : interdites a l'ecriture par le GUI (garde-fou).
|
||
INTRANTS_CLES_INTERDITES = {
|
||
"proxmox_api_token_id", "proxmox_api_token_secret", "ansible_become_pass", "ansible_password",
|
||
# Frontiere : les identifiants d'API ne transitent jamais par le panneau.
|
||
"opnsense_api_key", "opnsense_api_secret",
|
||
}
|
||
# Rappel en lecture seule (jamais de valeur) : secrets a fournir via Ansible Vault.
|
||
def secrets_attendus() -> list[str]:
|
||
"""Noms des secrets a fournir par la voute — DERIVES, jamais tenus a la main.
|
||
|
||
`voute.secrets_exiges()` les recense depuis le plan, les roles des groupes actifs et
|
||
les group_vars de l'instance ; `voute.SECRETS_HORS_MOTIF` ajoute ceux qui ne portent
|
||
pas le prefixe `vault_` (les jetons Proxmox). C'est la MEME source que la preuve P18.
|
||
|
||
Une liste ecrite a la main vivait ici et avait diverge : elle annoncait
|
||
`vault_ldap_sssd`, qu'aucun role ne consomme. Un operateur qui suivait le panneau
|
||
creait donc un secret inutile. Le recensement par motif textuel a lui aussi sa
|
||
limite : il a un temps reclame `vault_step_ca_fingerprint` a cause d'un defaut mort
|
||
dans `client_pki`, qui derive en realite l'empreinte a chaud depuis l'AC.
|
||
|
||
Depot public nu (aucune instance) : liste vide plutot qu'une erreur d'affichage.
|
||
"""
|
||
try:
|
||
import voute
|
||
return sorted(set(voute.secrets_exiges()) | voute.SECRETS_HORS_MOTIF)
|
||
except Exception:
|
||
return []
|
||
|
||
# Garde-fous des executions (verifier / deployer) depuis l'interface.
|
||
JETON = secrets.token_urlsafe(18)
|
||
VERROU = threading.Lock()
|
||
VERIF_OK: dict[str, bool] = {}
|
||
MOTIF_HOTE = re.compile(r"^[A-Za-z0-9._-]{1,63}$")
|
||
|
||
|
||
# Champs de provisioning persistes comme variables d'hote dans l'inventaire.
|
||
# (cle_gui, variable_inventaire, type)
|
||
CHAMPS_PROVISION = [
|
||
("adresse_ip", "ansible_host", "str"),
|
||
("utilisateur_ansible", "ansible_user", "str"),
|
||
("cidr", "proxmox_cidr", "int"),
|
||
("passerelle", "proxmox_passerelle", "str"),
|
||
("vlan", "proxmox_vlan", "int"),
|
||
("pont", "proxmox_pont", "str"),
|
||
("dns", "proxmox_dns", "str"),
|
||
("vmid", "proxmox_vmid", "int"),
|
||
("noeud", "proxmox_noeud", "str"),
|
||
("stockage", "proxmox_stockage", "str"),
|
||
("disque_taille", "proxmox_disque_taille", "str"),
|
||
("memoire", "proxmox_memoire", "int"),
|
||
("coeurs", "proxmox_coeurs", "int"),
|
||
]
|
||
|
||
|
||
def charger_yaml(path: Path) -> dict:
|
||
if not path.exists():
|
||
return {"all": {"children": {}}}
|
||
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("all", {})
|
||
data["all"].setdefault("children", {})
|
||
return data
|
||
|
||
|
||
def ecrire_yaml(path: Path, data: dict) -> None:
|
||
with ecriture_atomique(path) as fichier:
|
||
yaml.safe_dump(data, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
||
|
||
|
||
def enfants(data: dict) -> dict:
|
||
return data.setdefault("all", {}).setdefault("children", {})
|
||
|
||
|
||
def groupes_disponibles(data: dict) -> list[str]:
|
||
return groupes_operationnels_connus(enfants(data), DOSSIER_PLAYBOOKS_GROUPES)
|
||
|
||
|
||
def roles_des_groupes() -> dict:
|
||
"""Pour chaque playbook de groupe, extrait les roles appliques (chaine groupe -> roles)."""
|
||
resultat: dict = {}
|
||
for chemin in sorted(DOSSIER_PLAYBOOKS_GROUPES.glob("*.yml")):
|
||
roles: list[str] = []
|
||
try:
|
||
with chemin.open("r", encoding="utf-8") as fichier:
|
||
docs = yaml.safe_load(fichier) or []
|
||
plays = docs if isinstance(docs, list) else [docs]
|
||
for play in plays:
|
||
if not isinstance(play, dict):
|
||
continue
|
||
for role in play.get("roles", []) or []:
|
||
if isinstance(role, str):
|
||
roles.append(role)
|
||
elif isinstance(role, dict):
|
||
nom = role.get("role") or role.get("name")
|
||
if nom:
|
||
roles.append(str(nom))
|
||
except (OSError, yaml.YAMLError):
|
||
pass
|
||
resultat[chemin.stem] = {"roles": roles, "stub": not roles}
|
||
return resultat
|
||
|
||
|
||
def hotes_actifs_fichier(path: Path) -> set[str]:
|
||
data = charger_yaml(path)
|
||
return set(enfants(data).get(GROUPE_HOTES_ACTIFS, {}).get("hosts", {}) or {})
|
||
|
||
|
||
def variables_hote(data: dict, hote: str) -> dict:
|
||
resultat: dict = {}
|
||
for groupe_data in enfants(data).values():
|
||
hosts = groupe_data.get("hosts", {})
|
||
valeurs = hosts.get(hote)
|
||
if isinstance(valeurs, dict):
|
||
resultat.update(valeurs)
|
||
return resultat
|
||
|
||
|
||
def liste_hotes(data: dict) -> list[dict]:
|
||
noms = set()
|
||
for groupe_data in enfants(data).values():
|
||
noms.update(groupe_data.get("hosts", {}))
|
||
|
||
hotes = []
|
||
for nom in sorted(noms):
|
||
groupes = []
|
||
etat = "planifie"
|
||
visible = False
|
||
for groupe, groupe_data in enfants(data).items():
|
||
if nom not in groupe_data.get("hosts", {}):
|
||
continue
|
||
if groupe == GROUPE_HOTES_ACTIFS:
|
||
etat = "actif"
|
||
visible = True
|
||
elif groupe in GROUPES_ETAT_HOTE:
|
||
visible = True
|
||
continue
|
||
elif est_groupe_operationnel(groupe):
|
||
groupes.append(groupe)
|
||
visible = True
|
||
if not visible:
|
||
continue
|
||
variables = variables_hote(data, nom)
|
||
hote = {"nom": nom, "etat": etat, "groupes": sorted(groupes)}
|
||
for cle, var, _type in CHAMPS_PROVISION:
|
||
valeur = variables.get(var, "")
|
||
hote[cle] = "" if valeur is None else valeur
|
||
if not str(hote.get("utilisateur_ansible", "")).strip():
|
||
hote["utilisateur_ansible"] = "ansible"
|
||
hotes.append(hote)
|
||
return hotes
|
||
|
||
|
||
def _joignable(ip: str, port: int = 22, timeout: float = 1.5) -> bool:
|
||
if not ip:
|
||
return False
|
||
try:
|
||
with socket.create_connection((ip, port), timeout=timeout):
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def sonder_hotes(path: Path) -> dict:
|
||
"""Etat REEL (vs plan declaratif) : la VM de chaque hote repond-elle sur SSH ?"""
|
||
cibles = [(h.get("nom"), h.get("adresse_ip")) for h in liste_hotes(charger_yaml(path))]
|
||
with ThreadPoolExecutor(max_workers=16) as executeur:
|
||
resultats = executeur.map(lambda c: (c[0], _joignable(c[1])), cibles)
|
||
return {nom: ok for nom, ok in resultats if nom}
|
||
|
||
|
||
def marquer_serveur_actif(hote: str) -> None:
|
||
"""Auto-actif : un hote materialise (clone/deploye) passe 'actif' dans le plan."""
|
||
registre = charger_serveurs(FICHIER_SERVEURS)
|
||
serveurs = registre.get("serveurs") or {}
|
||
if hote in serveurs and serveurs[hote].get("etat") != "actif":
|
||
serveurs[hote]["etat"] = "actif"
|
||
ecrire_serveurs(FICHIER_SERVEURS, registre)
|
||
|
||
|
||
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", ""),
|
||
"integrations": srv.get("integrations", []),
|
||
"vmid": d.get("vmid", ""), "adresse_ip": d.get("adresse_ip", ""), "vlan": d.get("vlan", ""),
|
||
"statut": statut, "divergences": rec["divergences"],
|
||
})
|
||
return resultat
|
||
|
||
|
||
def flux_api() -> list:
|
||
"""Matrice des flux réseau (meta/flux.yml de tous les rôles) — lecture seule."""
|
||
try:
|
||
import resoudre_flux
|
||
flux = resoudre_flux.charger_flux()
|
||
except Exception:
|
||
return []
|
||
lignes = []
|
||
for role in sorted(flux):
|
||
for fl in flux[role]:
|
||
pair = fl.get("pair")
|
||
pair = ", ".join(str(p) for p in pair) if isinstance(pair, list) else str(pair)
|
||
port = fl.get("port")
|
||
port = ", ".join(str(p) for p in port) if isinstance(port, list) else str(port)
|
||
lignes.append({
|
||
"role": role, "sens": fl.get("sens", ""), "port": port,
|
||
"protocole": fl.get("protocole", ""), "pair": pair,
|
||
"chiffrement": fl.get("chiffrement", ""), "raison": fl.get("raison", ""),
|
||
})
|
||
return lignes
|
||
|
||
|
||
def couches_api() -> list:
|
||
"""Ordre de déploiement en couches (couches-deploiement.yml + graphe) — lecture seule."""
|
||
try:
|
||
import orchestrer
|
||
return [{"nom": nom, "groupes": list(groupes)} for nom, groupes in orchestrer.ordre_deploiement()]
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
# ═══ LES DEUX CONSOLES, ET LE TRONC QU'ELLES PARTAGENT ═══════════════════════════════
|
||
#
|
||
# POURQUOI DEUX CLASSES, ET PAS DEUX FONCTIONS (2026-09-20).
|
||
#
|
||
# Il y avait deux fonctions : `inventaire_api` pour un locataire, `inventaire_api_du_site`
|
||
# pour un site. Elles assemblaient LA MEME charge, chacune de son cote — et elles ont
|
||
# derive, deux fois le meme jour :
|
||
#
|
||
# en FORME `serveurs` valait `[]` d'un cote et `{}` de l'autre. Les deux « vides »,
|
||
# et la page ne les lit pas pareil : `charger()` LEVAIT, et la console d'un
|
||
# site mourait avant sa premiere vue.
|
||
# en CONTENU cinq registres etaient servis vides « pour ne pas fabriquer un faux plan
|
||
# de tenant » — alors que le site a SON plan, au meme format, que les
|
||
# lecteurs du moteur lisent sans broncher : 9 serveurs, 21 applications.
|
||
#
|
||
# Deux fonctions qui doivent rendre la meme forme finissent toujours par ne plus la
|
||
# rendre. Un tronc qui assemble, et des branches qui ne decident que de CE QUI LEUR
|
||
# APPARTIENT, rendent cette derive impossible : il n'y a plus qu'un seul endroit ou la
|
||
# charge se compose.
|
||
#
|
||
# CE QUE CHAQUE BRANCHE EST, au sens de `docs/responsabilites-locataire-hebergeur.md` :
|
||
#
|
||
# ConsoleLocataire configure SES services — voute du tenant, plan derive d'un seed
|
||
# ConsoleSite materialise le terrain — voute du site, plan DECLARE (ip, vmid)
|
||
# ConsoleOrpheline ne pilote rien, et c'est ce qu'elle doit dire
|
||
#
|
||
# Aucune n'est omnipotente, et la classe le porte au lieu d'un `if portee == ...` seme
|
||
# dans le code.
|
||
|
||
|
||
class Console:
|
||
"""Le tronc : ce qu'une console EST, quelle que soit la machine qui la sert."""
|
||
|
||
# LE ROLE est une propriete de la CLASSE — ce pour quoi cette console existe.
|
||
# LA PORTEE se CALCULE — ce qu'elle peut vraiment, ici et maintenant. Les deux ne se
|
||
# confondent pas : un poste prive de la voute du site reste l'atelier du mainteneur,
|
||
# et n'engendre pourtant rien. Figer la portee sur la classe aurait rendu a ce
|
||
# poste-la un pouvoir que la mesure lui refuse.
|
||
role = "aucun"
|
||
|
||
@property
|
||
def portee(self) -> str:
|
||
p = self.pouvoirs()
|
||
return ("poste" if p["configurer"] and p["materialiser"] else
|
||
"tenant" if p["configurer"] else
|
||
"site" if p["materialiser"] else "orphelin")
|
||
|
||
@property
|
||
def titre(self) -> str:
|
||
return {
|
||
"poste": f"Poste du mainteneur — {self.ecosysteme()} sur {self.fabric()}",
|
||
"tenant": f"Console du locataire — {self.ecosysteme()}",
|
||
"site": f"Console du SITE — {self.fabric()}",
|
||
}.get(self.portee, "Console sans pouvoir — elle ne pilote rien")
|
||
|
||
# LES REGISTRES DU PLAN, declares UNE fois : leur nom a l'ecran, leur fichier, leur
|
||
# lecteur. Une branche qui n'a pas de plan les rend vides — de la BONNE forme, parce
|
||
# que c'est le tronc qui decide de la forme, pas la branche.
|
||
REGISTRES = (
|
||
("nomenclature", "nomenclature.yml", charger_nomenclature),
|
||
("bases", "bases-donnees.yml", charger_bases_donnees),
|
||
("applications", "applications.yml", charger_applications),
|
||
("domaines", "domaines.yml", charger_domaines),
|
||
)
|
||
|
||
# --- CE QUE CHAQUE BRANCHE DECIDE POUR ELLE ---------------------------------------
|
||
|
||
def racine_du_plan(self) -> Path | None:
|
||
"""Le dossier `plan/` que cette console lit. None = elle n'en a pas."""
|
||
return None
|
||
|
||
def inventaire_brut(self) -> dict:
|
||
"""L'inventaire, dans la forme d'un `hosts.yml`. Vide par defaut."""
|
||
return {"all": {"children": {}}}
|
||
|
||
def nom_inventaire(self) -> str:
|
||
return ""
|
||
|
||
def est_production(self) -> bool:
|
||
return False
|
||
|
||
def serveurs(self, data: dict) -> list:
|
||
"""Les serveurs, TOUJOURS en liste. D'ou ils viennent regarde la branche."""
|
||
return []
|
||
|
||
def intrants(self):
|
||
return None
|
||
|
||
def pouvoirs(self) -> dict:
|
||
return {"editer_le_plan": False, "configurer": False,
|
||
"materialiser": False, "fabric": False}
|
||
|
||
def raisons(self) -> dict:
|
||
return {
|
||
"configurer": "Cette console ne porte la voute d'aucun ecosysteme.",
|
||
"editer_le_plan": "Aucun plan n'est monte ici.",
|
||
"materialiser": "Cette console ne sait sur quelle fabric poser une VM.",
|
||
"fabric": "Aucune fabric n'est montee ici.",
|
||
}
|
||
|
||
def ecosysteme(self) -> str | None:
|
||
return None
|
||
|
||
def fabric(self) -> str | None:
|
||
return None
|
||
|
||
# --- LE TRONC : UN SEUL ENDROIT OU LA CHARGE SE COMPOSE ---------------------------
|
||
|
||
def contexte(self) -> dict:
|
||
return {
|
||
"portee": self.portee,
|
||
"role": self.role,
|
||
"titre": self.titre,
|
||
"ecosysteme": self.ecosysteme(),
|
||
"fabric": self.fabric(),
|
||
"peut": self.pouvoirs(),
|
||
"raison": self.raisons(),
|
||
# QUELLE SOURCE D'INVENTAIRE : un site n'a pas de `hosts.yml`, son inventaire
|
||
# est un SCRIPT. Une console qui a quelque chose a piloter le NOMME.
|
||
"inventaire": self.nom_inventaire() or None,
|
||
}
|
||
|
||
def peut_conduire(self, portee_etape: str) -> tuple[bool, str]:
|
||
"""Cette console peut-elle conduire une etape de CETTE portee — et sinon, pourquoi ?
|
||
|
||
LE JUGEMENT VIT ICI, PAS DANS LES ROUTES. Il etait ecrit deux fois : dans la route
|
||
qui LISTE les assistants et dans celle qui EXECUTE une etape. Deux copies d'une
|
||
meme regle finissent par ne plus dire la meme chose — et celle qui se trompe est
|
||
toujours celle qui execute, parce que c'est la moins relue.
|
||
"""
|
||
peut = self.pouvoirs()
|
||
manquants = [p for p in POUVOIR_PAR_PORTEE.get(portee_etape, ())
|
||
if not peut.get(p)]
|
||
if not manquants:
|
||
return True, ""
|
||
return False, self.raisons().get(manquants[0],
|
||
"Geste hors de la portee de cette console.")
|
||
|
||
def charge(self) -> dict:
|
||
"""LA charge servie a la page — la seule, pour les deux consoles.
|
||
|
||
Les clefs et leurs FORMES sont fixees ici. Une branche choisit ce qu'elle met
|
||
dedans, jamais la forme de ce qu'elle met : c'est exactement ce qui a manque.
|
||
"""
|
||
data = self.inventaire_brut()
|
||
plan = self.racine_du_plan()
|
||
registres = {}
|
||
for clef, fichier, lecteur in self.REGISTRES:
|
||
chemin = (plan / fichier) if plan else None
|
||
registres[clef] = lecteur(chemin) if (chemin and chemin.is_file()) else {}
|
||
return {
|
||
"flux": flux_api(),
|
||
"couches": couches_api(),
|
||
"inventaire": self.nom_inventaire(),
|
||
"production": self.est_production(),
|
||
"groupes": groupes_disponibles(data),
|
||
"dependances": charger_dependances(FICHIER_DEPENDANCES),
|
||
"chaine": roles_des_groupes(),
|
||
**registres,
|
||
"serveurs": self.serveurs(data),
|
||
"hotes": liste_hotes(data),
|
||
"intrants": self.intrants(),
|
||
"integrations_disponibles": integrations_disponibles(),
|
||
"integrations_universelles": integrations_universelles(RACINE),
|
||
"liens_acceptes": catalogue_liens(RACINE),
|
||
# LE SCHEMA VOYAGE AVEC LES DONNEES. Le navigateur genere ses formulaires
|
||
# depuis lui plutot que de les porter en dur. Genere par `make schema`, P61.
|
||
"schema": schema_du_plan(),
|
||
"contexte": self.contexte(),
|
||
}
|
||
|
||
|
||
class ConsoleLocataire(Console):
|
||
"""Elle CONFIGURE : des roles sur ses machines, avec la voute du tenant.
|
||
|
||
Son plan DERIVE tout d'un seed `index` — VMID, adresse, VLAN. Elle edite donc le
|
||
plan, et l'inventaire qu'elle lit est un artefact genere depuis lui.
|
||
"""
|
||
|
||
role = "locataire"
|
||
|
||
def __init__(self, instance: Path, inventaire: Path, a_voute: bool):
|
||
self._instance = instance
|
||
self._inventaire = inventaire
|
||
self._a_voute = a_voute
|
||
|
||
def ecosysteme(self):
|
||
return self._instance.resolve().name
|
||
|
||
def racine_du_plan(self):
|
||
return self._instance / "plan"
|
||
|
||
def inventaire_brut(self):
|
||
return charger_yaml(self._inventaire)
|
||
|
||
def nom_inventaire(self):
|
||
return os.path.relpath(self._inventaire, RACINE)
|
||
|
||
def est_production(self):
|
||
return instance_est_production(self._inventaire)
|
||
|
||
def serveurs(self, data):
|
||
return serveurs_reconcilies(data)
|
||
|
||
def intrants(self):
|
||
return intrants_api()
|
||
|
||
def pouvoirs(self):
|
||
return {"editer_le_plan": True, "configurer": self._a_voute,
|
||
"materialiser": False, "fabric": False}
|
||
|
||
def raisons(self):
|
||
r = super().raisons()
|
||
r["materialiser"] = ("Cette console configure un ecosysteme ; elle ne sait pas "
|
||
"sur quelle fabric poser une VM, et c'est voulu : exploiter "
|
||
"n'est pas engendrer.")
|
||
r["configurer"] = ("Un ecosysteme est monte, mais sa voute n'est pas sur cette "
|
||
"machine : calculer un inventaire ne demande aucun secret, "
|
||
"configurer un service si.")
|
||
r["fabric"] = ("La fabric appartient a l'hebergeur. Cette console n'en monte "
|
||
"aucune carte.")
|
||
return r
|
||
|
||
|
||
class ConsoleSite(Console):
|
||
"""Elle MATERIALISE : cree et detruit des VM, pose le reseau, avec la voute du site.
|
||
|
||
SES ZONES DERIVENT, SES MACHINES SONT ENCORE ECRITES. Un site a son propre index
|
||
depuis le 2026-09-12 — le 37 pour celui-ci — et ses zones en descendent comme celles
|
||
d'un locataire descendent du sien (P20 le garde). Ce qui reste ecrit dans son plan,
|
||
ce sont les `ip`, `vmid` et `noeud` de ses MACHINES : une dette nommee, pas une
|
||
doctrine. La docstring disait le contraire : elle recopiait un commentaire perime de
|
||
huit jours au lieu de mesurer. Son inventaire, lui, n'est pas un fichier mais un SCRIPT.
|
||
|
||
ELLE A UN PLAN, ET ON LE LUI SERT. La charge du site servait cinq registres vides
|
||
« pour ne pas fabriquer un faux plan de tenant ». Le site n'a pas de faux plan : il a
|
||
LE SIEN, au meme format, que les lecteurs du moteur lisent sans broncher — mesure du
|
||
2026-09-20 : 9 serveurs, 21 applications, 2 bases, 1 domaine, tous invisibles.
|
||
"""
|
||
|
||
role = "site"
|
||
|
||
def __init__(self, depot: Path, a_voute: bool):
|
||
self._depot = depot
|
||
self._a_voute = a_voute
|
||
|
||
def fabric(self):
|
||
return self._depot.name
|
||
|
||
def racine_du_plan(self):
|
||
return self._depot / "plan"
|
||
|
||
def inventaire_brut(self):
|
||
return inventaire_du_site()
|
||
|
||
def nom_inventaire(self):
|
||
return f"{self.fabric()} (inventaire dynamique)"
|
||
|
||
def est_production(self):
|
||
return True
|
||
|
||
def serveurs(self, data):
|
||
"""Les serveurs DECLARES du site, dans la forme que la page sait lire.
|
||
|
||
On ne les reconcilie pas contre une nomenclature : un site n'en a pas, ses
|
||
valeurs sont ecrites. Le statut se lit donc contre l'inventaire REEL — declare
|
||
ici, present la-bas, ou pas.
|
||
"""
|
||
chemin = self.racine_du_plan() / "serveurs.yml"
|
||
if not chemin.is_file():
|
||
return []
|
||
registre = (charger_serveurs(chemin) or {}).get("serveurs") or {}
|
||
vivants = {h.get("nom") for h in liste_hotes(data)}
|
||
sortie = []
|
||
for nom, srv in registre.items():
|
||
gabarit = srv.get("gabarit") or {}
|
||
sortie.append({
|
||
"nom": nom,
|
||
# Un site ne derive d'aucune fonction : il nomme le RESEAU qui le porte.
|
||
"fonction": srv.get("reseau", ""),
|
||
"etat": srv.get("etat", ""),
|
||
"noeud": srv.get("noeud", ""),
|
||
"stockage": srv.get("stockage", ""),
|
||
"disque": gabarit.get("disque", ""),
|
||
"memoire": gabarit.get("memoire", ""),
|
||
"coeurs": gabarit.get("vcpu", ""),
|
||
"integrations": srv.get("integrations", []),
|
||
"vmid": srv.get("vmid", ""),
|
||
"adresse_ip": srv.get("ip", ""),
|
||
"vlan": "",
|
||
"statut": "reconcilie" if nom in vivants else "absent",
|
||
"divergences": [],
|
||
})
|
||
return sortie
|
||
|
||
def pouvoirs(self):
|
||
# EDITER : pas encore. Les formulaires sont generes depuis le schema du plan d'un
|
||
# LOCATAIRE, ou l'adressage se derive ; le plan d'un site l'ECRIT. Offrir un
|
||
# formulaire qui ecrirait de travers serait pire que ne rien offrir. On LIT.
|
||
return {"editer_le_plan": False, "configurer": False,
|
||
"materialiser": self._a_voute, "fabric": True}
|
||
|
||
def raisons(self):
|
||
r = super().raisons()
|
||
r["configurer"] = ("Cette console materialise le terrain ; elle n'a la voute "
|
||
"d'aucun locataire, donc elle ne configure aucun de leurs "
|
||
"services.")
|
||
r["editer_le_plan"] = ("Le plan de ce site se LIT ici. Ses adresses sont ECRITES, "
|
||
"pas derivees d'un seed, et les formulaires sont generes "
|
||
"depuis le schema d'un plan de locataire : ils ecriraient "
|
||
"de travers.")
|
||
r["materialiser"] = ("Cette console LIT la carte d'une fabric dont elle n'a pas "
|
||
"la voute. Creer ou detruire une machine demande le secret "
|
||
"du site, et il n'est pas ici.")
|
||
r["fabric"] = ("La fabric appartient a l'hebergeur. Ce qui s'affiche ici vient "
|
||
"d'une copie locale, et une copie avait deja diverge.")
|
||
return r
|
||
|
||
|
||
class ConsolePoste(ConsoleLocataire):
|
||
"""L'atelier du mainteneur : les deux voutes, donc les deux pouvoirs.
|
||
|
||
Elle EST une console de locataire — meme plan, meme inventaire — qui sait en plus sur
|
||
quelle fabric poser une VM. L'heritage dit exactement ca, et evite une troisieme
|
||
copie de la meme charge.
|
||
"""
|
||
|
||
role = "poste"
|
||
|
||
def __init__(self, instance, inventaire, depot, a_voute_tenant, a_voute_site):
|
||
super().__init__(instance, inventaire, a_voute_tenant)
|
||
self._depot = depot
|
||
self._a_voute_site = a_voute_site
|
||
|
||
def fabric(self):
|
||
return self._depot.name
|
||
|
||
def pouvoirs(self):
|
||
p = super().pouvoirs()
|
||
p["materialiser"] = self._a_voute_site
|
||
p["fabric"] = True
|
||
return p
|
||
|
||
def raisons(self):
|
||
# CE POSTE SAIT SUR QUELLE FABRIC POSER — il en monte la carte. Ce qui lui manque,
|
||
# le cas echeant, c'est la VOUTE. Heriter du refus d'un locataire (« elle ne sait
|
||
# pas sur quelle fabric ») enverrait chercher une carte qui est pourtant la, et
|
||
# ferait perdre le temps qu'un message juste economise.
|
||
r = super().raisons()
|
||
r["materialiser"] = ("Cette console LIT la carte d'une fabric dont elle n'a pas "
|
||
"la voute. Creer ou detruire une machine demande le secret "
|
||
"du site, et il n'est pas ici.")
|
||
r["fabric"] = ("La fabric appartient a l'hebergeur. Ce qui s'affiche ici vient "
|
||
"d'une copie locale, et une copie avait deja diverge.")
|
||
return r
|
||
|
||
|
||
def console(inventaire: Path | None = None) -> Console:
|
||
"""QUELLE console sert cette machine — derive de ce qu'elle peut OUVRIR.
|
||
|
||
`docs/responsabilites-locataire-hebergeur.md` attache chaque pouvoir a une voute :
|
||
calculer n'en demande aucune, configurer celle du tenant, materialiser celle du site.
|
||
On ne prouve pas qu'une voute s'ouvre — le mot de passe se tape a l'execution — mais
|
||
son ABSENCE est decisive et se mesure sans rien ouvrir.
|
||
"""
|
||
a_instance = INSTANCE.exists()
|
||
depot = _depot_hebergeur()
|
||
inv = inventaire or INVENTAIRE_DEFAUT
|
||
|
||
voute_tenant = (INSTANCE / "inventories" / "principal" / "group_vars" / "all" /
|
||
"vault.yml") if a_instance else None
|
||
a_voute_tenant = bool(voute_tenant and voute_tenant.is_file())
|
||
a_voute_site = bool(depot and (depot / "underlay.vault.yml").is_file())
|
||
|
||
# LA BRANCHE SUIT LES LIENS — ce que cette machine est faite pour faire. La PORTEE,
|
||
# elle, suivra les voutes : une console de locataire sans sa voute reste une console
|
||
# de locataire (elle calcule son inventaire), et se dira « orpheline » parce qu'elle
|
||
# ne peut rien configurer. Le role et le pouvoir sont deux questions distinctes.
|
||
if a_instance and depot:
|
||
return ConsolePoste(INSTANCE, inv, depot, a_voute_tenant, a_voute_site)
|
||
if a_instance:
|
||
return ConsoleLocataire(INSTANCE, inv, a_voute_tenant)
|
||
if depot:
|
||
return ConsoleSite(depot, a_voute_site)
|
||
return Console()
|
||
|
||
|
||
def inventaire_api(path: Path) -> dict:
|
||
"""La charge d'une console de LOCATAIRE — deleguee au tronc.
|
||
|
||
Elle reste parce que le harnais et les tests l'appellent par ce nom. Elle n'assemble
|
||
plus rien : assembler deux fois est exactement ce qui a fait deriver la charge du
|
||
site de celle du locataire.
|
||
"""
|
||
return ConsoleLocataire(INSTANCE, path, True).charge()
|
||
|
||
|
||
def schema_du_plan() -> dict:
|
||
"""Le JSON Schema des registres, tel que `make schema` l'a ecrit.
|
||
|
||
Lu depuis le fichier plutot que recalcule : c'est l'artefact VERSIONNE qui fait foi,
|
||
et P61 refuse qu'il soit perime. Recalculer ici laisserait l'ecran afficher une forme
|
||
que personne n'a relue.
|
||
"""
|
||
f = RACINE / "docs" / "audit" / "schema-plan.json"
|
||
if not f.is_file():
|
||
return {}
|
||
try:
|
||
return json.loads(f.read_text(encoding="utf-8")).get("registres", {})
|
||
except (OSError, json.JSONDecodeError):
|
||
return {}
|
||
|
||
|
||
def _lire_yaml_dict(path: Path) -> dict:
|
||
if not path.exists():
|
||
return {}
|
||
with path.open("r", encoding="utf-8") as fichier:
|
||
data = yaml.safe_load(fichier) or {}
|
||
return data if isinstance(data, dict) else {}
|
||
|
||
|
||
def instance_est_production(path: Path) -> bool:
|
||
"""Cette instance est-elle la PRODUCTION ? (garde-fou de prudence, pas un blocage)
|
||
|
||
Le déploiement est permis sur toute instance (lab compris, vers son infra) ;
|
||
ce drapeau ne sert qu'à exiger une confirmation renforcée côté GUI quand c'est
|
||
la prod. Repère explicite : `setops_production` dans `<inventaire>/group_vars/all/`.
|
||
Rétro-compatibilité : à défaut, ancien repère (inventaire nommé « production »).
|
||
"""
|
||
base = path.parent / "group_vars" / "all"
|
||
fichiers = sorted(base.glob("*.yml")) if base.is_dir() else []
|
||
plat = path.parent / "group_vars" / "all.yml"
|
||
if plat.exists():
|
||
fichiers.append(plat)
|
||
for f in fichiers:
|
||
d = _lire_yaml_dict(f)
|
||
if isinstance(d, dict) and "setops_production" in d:
|
||
return bool(d["setops_production"])
|
||
return path.resolve() == INVENTAIRE_PRODUCTION
|
||
|
||
|
||
def _lire_fabric_a_plat() -> dict:
|
||
"""Les intrants PLATS de underlay.yml, aplatis dans l'espace de noms du panneau."""
|
||
data = _lire_yaml_dict(FICHIER_UNDERLAY).get("underlay") or {}
|
||
plat = {}
|
||
for variable, chemin in CHEMINS_UNDERLAY.items():
|
||
noeud = data
|
||
for clef in chemin:
|
||
noeud = (noeud or {}).get(clef) if isinstance(noeud, dict) else None
|
||
plat[variable] = noeud
|
||
return plat
|
||
|
||
|
||
def intrants_courants() -> dict:
|
||
cache: dict = {}
|
||
valeurs: dict = {}
|
||
for variable, fcle, _classe, _section, _libelle, typ, _prop in INTRANTS_SCHEMA:
|
||
if fcle not in cache:
|
||
cache[fcle] = (_lire_fabric_a_plat() if fcle == "fabric"
|
||
else _lire_yaml_dict(FICHIERS_INTRANTS[fcle]))
|
||
v = cache[fcle].get(variable)
|
||
if typ == "liste":
|
||
items = v if isinstance(v, list) else []
|
||
valeurs[variable] = [str(x).strip() for x in items if str(x).strip()]
|
||
else:
|
||
valeurs[variable] = "" if v is None else v
|
||
return valeurs
|
||
|
||
|
||
# --- Couverture du schema du plan par le GUI -------------------------------------
|
||
#
|
||
# Ce que les fonctions de sauvegarde JS ecrivent REELLEMENT, registre par registre.
|
||
# Source unique verifiee par `scripts/couverture_gui.py` : tout champ present dans
|
||
# un plan reel (instance ou modele) mais absent d'ici signale un trou de l'interface
|
||
# — l'operateur devrait editer le YAML a la main, ce que la doctrine refuse.
|
||
#
|
||
# A TENIR A JOUR avec sauvegarderServeurs / sauvegarderApplications /
|
||
# sauvegarderBases / sauvegarderDomaines. Le verificateur controle aussi que chaque
|
||
# champ declare ici apparait bien dans le source du GUI (garde anti-derive).
|
||
# CE QUE LE GUI SAIT ECRIRE — ET D'OU IL LE TIENT (2026-09-08).
|
||
#
|
||
# Cette table etait tenue A LA MAIN : quelqu'un devait penser a l'allonger quand un champ
|
||
# arrivait au plan, et P19 confrontait la liste au reel. Une copie gardee reste une copie.
|
||
#
|
||
# LA TABLE EST VIDE, ET C'EST LE BUT. Les SIX registres ont leur formulaire GENERE depuis
|
||
# `docs/audit/schema-plan.json` (`make schema`) : la liste se DEDUIT du schema au lieu
|
||
# d'etre recopiee ici. `bases_donnees` est sortie la premiere, la nomenclature ensuite,
|
||
# puis `serveurs_bd`, `domaines_publics`, et enfin `serveurs` et `applications`.
|
||
#
|
||
# On la garde plutot que de la supprimer : le mecanisme doit pouvoir accueillir un champ
|
||
# qu'un formulaire ecrirait sans que le schema le decrive — et le jour ou ca arrive, il
|
||
# faudra l'ECRIRE ICI, visiblement, plutot que de le laisser vivre en silence.
|
||
#
|
||
# P19 lit `champs_ecrits_par_gui()` et non cette constante : elle garde un mecanisme, pas
|
||
# une liste.
|
||
CHAMPS_ECRITS_A_LA_MAIN: dict[str, set[str]] = {}
|
||
|
||
# Les registres dont le formulaire est GENERE : leurs champs viennent du schema.
|
||
REGISTRES_GENERES = {"bases_donnees", "nomenclature", "serveurs_bd",
|
||
"domaines_publics", "serveurs", "applications"}
|
||
|
||
|
||
def champs_ecrits_par_gui() -> dict:
|
||
"""Ce que le GUI sait ecrire : declare a la main, plus derive du schema.
|
||
|
||
Une seule fonction repond a la question, pour que P19 n'ait pas a savoir lequel des
|
||
deux mecanismes couvre quel registre.
|
||
"""
|
||
def _noms(props: dict) -> set:
|
||
"""Les champs decrits, TABLES ET BLOCS IMBRIQUES COMPRIS.
|
||
|
||
Les plans portent `categorie` et `service` a plat, sous la table `fonctions`.
|
||
Ne rendre que les proprietes de premier niveau ferait dire au GUI qu'il ne sait
|
||
pas les ecrire, alors que le formulaire genere les edite.
|
||
"""
|
||
vus = set()
|
||
for nom, spec in (props or {}).items():
|
||
vus.add(nom)
|
||
for conteneur in (spec, spec.get("additionalProperties"), spec.get("items")):
|
||
if isinstance(conteneur, dict) and isinstance(conteneur.get("properties"), dict):
|
||
vus |= _noms(conteneur["properties"])
|
||
return vus
|
||
|
||
resultat = {nom: set(champs) for nom, champs in CHAMPS_ECRITS_A_LA_MAIN.items()}
|
||
for nom, reg in (schema_du_plan() or {}).items():
|
||
if nom in REGISTRES_GENERES:
|
||
resultat[nom] = _noms((reg.get("entite") or {}).get("properties") or {})
|
||
return resultat
|
||
|
||
|
||
def integrations_disponibles() -> list:
|
||
"""Roles client_* SELECTIONNABLES : ceux qui restent un choix.
|
||
|
||
Les universelles en sont exclues : on ne coche pas ce qui s'applique a tout le
|
||
monde. Le GUI les affiche quand meme, en lecture seule (voir /api/etat) — sinon
|
||
l'operateur lirait « aucune supervision » sur une flotte entierement supervisee.
|
||
"""
|
||
base = RACINE / "roles"
|
||
universelles = set(integrations_universelles(RACINE))
|
||
return sorted(p.name for p in base.glob("client_*")
|
||
if p.is_dir() and p.name not in universelles)
|
||
|
||
|
||
def intrants_api() -> dict:
|
||
schema = [{"variable": v, "fichier": f, "classe": c, "section": s,
|
||
"libelle": l, "type": t, "proprietaire": p} for (v, f, c, s, l, t, p) in INTRANTS_SCHEMA]
|
||
return {
|
||
"schema": schema,
|
||
"valeurs": intrants_courants(),
|
||
"secrets_attendus": secrets_attendus(),
|
||
"fichiers": {k: os.path.relpath(v, RACINE) for k, v in FICHIERS_INTRANTS.items()},
|
||
}
|
||
|
||
|
||
def _coercer_intrant(typ: str, brut: object) -> object:
|
||
if typ == "liste":
|
||
items = brut if isinstance(brut, list) else str(brut).replace("\n", ",").split(",")
|
||
# dedoublonne en preservant l'ordre
|
||
vus, sortie = set(), []
|
||
for x in items:
|
||
s = str(x).strip()
|
||
if s and s not in vus:
|
||
vus.add(s); sortie.append(s)
|
||
return sortie
|
||
s = str(brut).strip()
|
||
if typ == "bool":
|
||
return s.lower() in ("true", "1", "oui", "yes", "on")
|
||
if typ == "int":
|
||
return int(s) if s.lstrip("-").isdigit() else s
|
||
return s
|
||
|
||
|
||
_REFERENCE_VOUTE = re.compile(r"^\{\{\s*vault_[A-Za-z0-9_]+\s*\}\}$")
|
||
|
||
|
||
def _est_reference_voute(valeur: object) -> bool:
|
||
"""Vrai si la valeur est une simple REFERENCE de voute (`{{ vault_xxx }}`).
|
||
|
||
Un fichier d'intrants peut porter, a cote de ses parametres anodins, des renvois
|
||
par NOM vers la voute (cas de group_vars/opnsense.yml). Ce n'est pas un secret :
|
||
c'est un pointeur. La fusion « preserve les cles non gerees » les relit donc du
|
||
disque, et le garde-fou ne doit pas les confondre avec une valeur saisie.
|
||
"""
|
||
return bool(_REFERENCE_VOUTE.match(str(valeur).strip()))
|
||
|
||
|
||
_CLE_RACINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):")
|
||
|
||
|
||
def _rendre_cle(cle: str, valeur: object) -> list[str]:
|
||
"""Les lignes que PyYAML ecrirait pour cette seule clef (listes en bloc incluses)."""
|
||
texte = yaml.safe_dump({cle: valeur}, default_flow_style=False,
|
||
allow_unicode=True, sort_keys=False)
|
||
return texte.rstrip("\n").split("\n")
|
||
|
||
|
||
_ENTREE_TABLE = re.compile(r"^(\s+)([^\s#:][^:]*):(\s*)(.*)$")
|
||
|
||
|
||
def _fusion_table(bloc: list[str], entrees: dict) -> list[str]:
|
||
"""Met a jour une table clef->mapping LIGNE A LIGNE, sans la reecrire.
|
||
|
||
POURQUOI PAS `_fusion_chirurgicale` SEULE (mesure du 2026-09-08). Elle remplace le
|
||
BLOC entier des qu'une valeur change. Sur `fonctions:` de la nomenclature, ajouter
|
||
une seule entree a produit deux degats mesures :
|
||
|
||
- les quinze entrees compactes `{ categorie: 6, service: 2 }` sont devenues
|
||
quarante-deux lignes — le fichier cesse d'etre une table lisible d'un coup d'oeil ;
|
||
- le commentaire « LE POSTE D'EXPLOITATION », qui coiffait `ops:`, s'est retrouve
|
||
en TETE du bloc. Un commentaire deplace ne se contente pas d'etre laid : il
|
||
explique desormais la mauvaise ligne. Il affirmait que `collab` etait le poste
|
||
d'exploitation.
|
||
|
||
Ici on ne touche qu'aux LIGNES concernees. Une entree inchangee n'est pas relue, un
|
||
commentaire garde son voisin, et le style compact survit parce qu'on ne le refait pas.
|
||
"""
|
||
tete, corps = bloc[0], bloc[1:]
|
||
sortie, vues = [tete], set()
|
||
flow, colonne = 0, 0
|
||
i = 0
|
||
while i < len(corps):
|
||
ligne = corps[i]
|
||
m = _ENTREE_TABLE.match(ligne)
|
||
if not m:
|
||
sortie.append(ligne)
|
||
i += 1
|
||
continue
|
||
indent, cle, ecart, reste = m.group(1), m.group(2).strip(), m.group(3), m.group(4)
|
||
# L'entree s'etend sur les lignes PLUS indentees qui suivent immediatement.
|
||
fin = i
|
||
j = i + 1
|
||
while j < len(corps):
|
||
suite = corps[j]
|
||
if not suite.strip() or suite.strip().startswith("#"):
|
||
break
|
||
creux = len(suite) - len(suite.lstrip())
|
||
if creux <= len(indent):
|
||
break
|
||
fin = j
|
||
j += 1
|
||
vues.add(cle)
|
||
if reste.startswith("{"):
|
||
flow += 1
|
||
colonne = max(colonne, len(indent) + len(cle) + 1 + len(ecart))
|
||
try:
|
||
ancien = (yaml.safe_load("\n".join(l[len(indent):] for l in corps[i:fin + 1])) or {}).get(cle)
|
||
except yaml.YAMLError:
|
||
ancien = object() # illisible : on la considere changee
|
||
if cle not in entrees:
|
||
i = fin + 1 # entree retiree : ses lignes disparaissent, pas ses voisines
|
||
continue
|
||
if ancien == entrees[cle]:
|
||
sortie.extend(corps[i:fin + 1])
|
||
elif reste.startswith("{"):
|
||
sortie.append(f"{indent}{cle}:{ecart}{_rendre_flow(entrees[cle])}")
|
||
else:
|
||
# L'entree tenait sur plusieurs lignes : on la refait en bloc, indentee
|
||
# comme ses voisines.
|
||
sortie.extend(indent + l for l in _rendre_cle(cle, entrees[cle]))
|
||
i = fin + 1
|
||
# Les entrees NEUVES vont a la fin du bloc, dans le style dominant.
|
||
neuves = [c for c in entrees if c not in vues]
|
||
if neuves:
|
||
indent = " "
|
||
for ligne in corps:
|
||
m = _ENTREE_TABLE.match(ligne)
|
||
if m:
|
||
indent = m.group(1)
|
||
break
|
||
while sortie and not sortie[-1].strip():
|
||
sortie.pop()
|
||
for cle in neuves:
|
||
if flow:
|
||
ecart = " " * max(1, colonne - len(indent) - len(str(cle)) - 1)
|
||
sortie.append(f"{indent}{cle}:{ecart}{_rendre_flow(entrees[cle])}")
|
||
else:
|
||
sortie.extend(indent + l for l in _rendre_cle(cle, entrees[cle]))
|
||
return sortie
|
||
|
||
|
||
def _rendre_flow(valeur: object) -> str:
|
||
"""La forme compacte `{ a: 1, b: 2 }` — celle que le fichier employait deja."""
|
||
rendu = yaml.safe_dump(valeur, default_flow_style=True, allow_unicode=True,
|
||
sort_keys=False, width=10 ** 6).strip()
|
||
if rendu.startswith("{") and rendu.endswith("}"):
|
||
rendu = "{ " + rendu[1:-1].strip() + " }"
|
||
return rendu
|
||
|
||
|
||
def _fusion_chirurgicale(texte: str, valeurs: dict, tables: set | None = None) -> str:
|
||
"""Pose les valeurs dans le texte existant SANS reformater ce qui n'a pas change.
|
||
|
||
POURQUOI (mesure du 2026-08-18). Ces fichiers portent la memoire ECRITE des
|
||
decisions : pourquoi `dns_amorcage` pointe sur un resolveur public, pourquoi le
|
||
plan de gestion est reste en 10.0.0.0/24, pourquoi la frontiere ecoute sur `lan`.
|
||
Chaque enregistrement du panneau les repassait au `safe_dump` : clefs retriees,
|
||
et TOUS les commentaires effaces. Un enregistrement de 13:48 a ainsi emporte
|
||
94 lignes de commentaire dans quatre fichiers (129 -> 35, le reste etant l'entete
|
||
que le panneau reecrit) — dont celle qui disait pourquoi la valeur qu'on venait
|
||
de changer avait ete choisie.
|
||
|
||
Le depot connaissait deja le geste juste : `_ecrire_intrants_fabric` et
|
||
`_ecrire_index_nomenclature` remplacent la ligne sans toucher au reste. Ce qui
|
||
manquait ici, c'est de savoir remplacer une valeur de LISTE, qui tient sur
|
||
plusieurs lignes.
|
||
|
||
Trois regles :
|
||
- une clef dont la valeur ne change pas n'est PAS reecrite (zero bruit au diff) ;
|
||
- les commentaires INTERNES a un bloc remplace sont conserves, jamais juges : une
|
||
explication devenue fausse se corrige a la main, elle ne se supprime pas toute
|
||
seule ;
|
||
- une clef absente du fichier est ajoutee a la fin, jamais inseree au hasard.
|
||
"""
|
||
actuel = yaml.safe_load(texte)
|
||
actuel = actuel if isinstance(actuel, dict) else {}
|
||
lignes = texte.split("\n")
|
||
sortie: list[str] = []
|
||
vues: set[str] = set()
|
||
i = 0
|
||
while i < len(lignes):
|
||
m = _CLE_RACINE.match(lignes[i])
|
||
cle = m.group(1) if m else None
|
||
if cle is None or cle not in valeurs:
|
||
sortie.append(lignes[i])
|
||
i += 1
|
||
continue
|
||
# Le bloc de valeur : la ligne de clef, puis tout ce qui est indente ou item de
|
||
# liste. Les commentaires et lignes vides qui SUIVENT la derniere ligne de valeur
|
||
# appartiennent a la clef suivante — on les laisse ou ils sont.
|
||
fin = i
|
||
j = i + 1
|
||
while j < len(lignes):
|
||
l = lignes[j]
|
||
if l.startswith((" ", "\t", "- ")) or l == "-":
|
||
fin = j
|
||
elif l.strip().startswith("#") or not l.strip():
|
||
pass # peut etre interne au bloc : on tranche avec `fin`
|
||
else:
|
||
break
|
||
j += 1
|
||
vues.add(cle)
|
||
if cle in actuel and actuel[cle] == valeurs[cle]:
|
||
sortie.extend(lignes[i:fin + 1]) # inchangee : pas un octet touche
|
||
elif cle in (tables or set()) and isinstance(valeurs[cle], dict):
|
||
# Table clef->mapping : on edite les lignes concernees, pas le bloc.
|
||
sortie.extend(_fusion_table(lignes[i:fin + 1], valeurs[cle]))
|
||
else:
|
||
internes = [l for l in lignes[i + 1:fin + 1] if l.strip().startswith("#")]
|
||
rendu = _rendre_cle(cle, valeurs[cle])
|
||
sortie.extend([rendu[0]] + internes + rendu[1:])
|
||
i = fin + 1
|
||
ajouts = [c for c in valeurs if c not in vues]
|
||
if ajouts:
|
||
while sortie and not sortie[-1].strip():
|
||
sortie.pop()
|
||
for cle in ajouts:
|
||
sortie.extend(_rendre_cle(cle, valeurs[cle]))
|
||
sortie.append("")
|
||
return "\n".join(sortie)
|
||
|
||
|
||
def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None:
|
||
# Une cle sensible n'est toleree que sous forme de reference de voute preservee.
|
||
# Toute VALEUR reelle sur une de ces cles fait echouer l'ecriture.
|
||
interdits = sorted(cle for cle in set(valeurs) & INTRANTS_CLES_INTERDITES
|
||
if not _est_reference_voute(valeurs[cle]))
|
||
if interdits:
|
||
raise ValueError(f"Refus d'ecrire des cles sensibles via le GUI: {', '.join(interdits)}")
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
if path.exists():
|
||
# Fichier existant : on pose les valeurs DANS son texte (commentaires preserves).
|
||
path.write_text(_fusion_chirurgicale(path.read_text(encoding="utf-8"), valeurs),
|
||
encoding="utf-8")
|
||
return
|
||
with ecriture_atomique(path) as fichier:
|
||
fichier.write(entete)
|
||
yaml.safe_dump(valeurs, fichier, default_flow_style=False, sort_keys=True, allow_unicode=True)
|
||
|
||
|
||
def ecrire_intrants(payload: dict) -> None:
|
||
valeurs = payload.get("valeurs", {}) or {}
|
||
# Garde-fou en entree : le panneau n'affiche jamais ces cles, mais une requete
|
||
# forgee pourrait les soumettre. On refuse bruyamment plutot que d'ignorer.
|
||
soumis = sorted(set(valeurs) & INTRANTS_CLES_INTERDITES)
|
||
if soumis:
|
||
raise ValueError(f"Cles sensibles soumises au panneau (voute uniquement): {', '.join(soumis)}")
|
||
if not str(valeurs.get("domaine_interne", "")).strip():
|
||
raise ValueError("domaine_interne ne peut pas etre vide (cle de voute de l'ecosysteme).")
|
||
types = {v: t for (v, f, c, s, l, t, p) in INTRANTS_SCHEMA}
|
||
fichier_de = {v: f for (v, f, c, s, l, t, p) in INTRANTS_SCHEMA}
|
||
par_fichier: dict = {}
|
||
for variable, valeur in valeurs.items():
|
||
if variable not in fichier_de:
|
||
continue
|
||
par_fichier.setdefault(fichier_de[variable], {})[variable] = _coercer_intrant(types[variable], valeur)
|
||
|
||
if "identite" in par_fichier:
|
||
# Fusion, comme les trois autres fichiers. C'etait la SEULE branche a ecrire
|
||
# par-dessus le disque, et elle perdait donc en silence tout intrant absent du
|
||
# schema du panneau. Mesure du 2026-08-10, au pre-vol de l'epreuve de
|
||
# portabilite : un enregistrement du panneau a supprime `dns_amorcage` et
|
||
# `amorcage_acces_courriel` de Technolibre. Le premier laisse une VM naitre sans
|
||
# resolution — `apt` ne peut alors rien installer ; le second arrete le
|
||
# deploiement sur la garde de `amorcage_acces` (D-72).
|
||
#
|
||
# Le meme geste sur Chezlepro aurait mange les memes cles. Un panneau qui ne
|
||
# connait pas une valeur ne doit pas avoir le droit de la detruire.
|
||
fusion = _lire_yaml_dict(INTRANTS_IDENTITE) # preserve les cles non gerees
|
||
fusion.update(par_fichier["identite"])
|
||
_ecrire_intrants_fichier(
|
||
INTRANTS_IDENTITE, fusion,
|
||
"# Intrants d'IDENTITE de l'instance — SOURCE UNIQUE, partagee par tous les envs.\n"
|
||
"# Edite par le panneau « Intrants de base » du GUI (make inventaire-ui).\n"
|
||
"# Reference via symlink depuis inventories/<env>/group_vars/all/10-intrants.yml.\n---\n")
|
||
if "proxmox" in par_fichier:
|
||
fusion = _lire_yaml_dict(INTRANTS_PROXMOX) # preserve les cles non gerees
|
||
fusion.update(par_fichier["proxmox"])
|
||
_ecrire_intrants_fichier(
|
||
INTRANTS_PROXMOX, fusion,
|
||
"# Parametres Proxmox non sensibles. Les secrets (jetons d'API) vont dans la\n"
|
||
"# voute UNIQUE de l'instance : group_vars/all/vault.yml.\n"
|
||
"# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n---\n")
|
||
if "proxmox_hebergeur" in par_fichier:
|
||
fusion = _lire_yaml_dict(INTRANTS_PROXMOX_HEBERGEUR)
|
||
fusion.update(par_fichier["proxmox_hebergeur"])
|
||
_ecrire_intrants_fichier(
|
||
INTRANTS_PROXMOX_HEBERGEUR, fusion,
|
||
"# Le CLUSTER, vu par l'HEBERGEUR — pas par un tenant.\n"
|
||
"#\n"
|
||
"# API du cluster, noeuds, stockages, ponts : du materiel possede par\n"
|
||
"# l'hebergeur. Recopiees dans le group_vars de chaque tenant, ces valeurs\n"
|
||
"# avaient deja diverge — deux inventaires contradictoires du meme cluster.\n"
|
||
"#\n"
|
||
"# Ce fichier vit dans le depot de l'HEBERGEUR, a cote d'underlay.yml (D-14),\n"
|
||
"# et se trouve par derivation du symlink qui designe deja l'hebergeur (D-17).\n"
|
||
"#\n"
|
||
"# Chez le TENANT restent son golden template et ses defauts de placement :\n"
|
||
"# des CHOIX faits a l'interieur de ce que l'hebergeur offre ici.\n"
|
||
"#\n"
|
||
"# Les secrets (jeton d'API) n'entrent jamais ici : voute de l'instance.\n---\n")
|
||
if "frontiere" in par_fichier:
|
||
# Fusion (comme Proxmox) : preserve les cles non gerees par le panneau,
|
||
# en particulier les references de voute `{{ vault_opnsense_api_* }}`.
|
||
fusion = _lire_yaml_dict(INTRANTS_FRONTIERE)
|
||
fusion.update(par_fichier["frontiere"])
|
||
_ecrire_intrants_fichier(
|
||
INTRANTS_FRONTIERE, fusion,
|
||
"# Frontiere nord/sud (OPNsense) — parametres NON sensibles.\n"
|
||
"# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n"
|
||
"#\n"
|
||
"# Les identifiants d'API ne figurent ici que par REFERENCE de nom : leurs\n"
|
||
"# valeurs vivent dans la voute unique de l'instance (ansible-vault edit\n"
|
||
"# group_vars/all/vault.yml), sous vault_opnsense_api_key / _api_secret.\n"
|
||
"# Les obtenir dans OPNsense : System > Access > Users > <utilisateur> >\n"
|
||
"# API keys > « + ». Le secret n'est affiche qu'a la creation.\n"
|
||
"#\n"
|
||
"# Le boitier est HORS FLOTTE Ansible : on ne fait que piloter son API REST\n"
|
||
"# depuis le controleur. Cf. docs/frontiere-opnsense.md.\n"
|
||
"#\n"
|
||
"# Les interfaces se nomment par leur IDENTIFIANT OPNsense (wan, opt1...), celui\n"
|
||
"# que parle l'API REST — ni le peripherique FreeBSD (igb1), ni le libelle affiche.\n"
|
||
"# `opnsense_prochain_saut` n'est pas ici : il DERIVE du reseau de transit de\n"
|
||
"# l'underlay (underlay.yml, cle `passerelle_sortie`). Source unique.\n---\n")
|
||
if "fabric" in par_fichier:
|
||
_ecrire_intrants_fabric(par_fichier["fabric"])
|
||
if "reseau" in par_fichier and "index" in par_fichier["reseau"]:
|
||
_ecrire_index_nomenclature(int(par_fichier["reseau"]["index"]))
|
||
|
||
|
||
def _ecrire_intrants_fabric(valeurs: dict) -> None:
|
||
"""Met a jour les cles PLATES de underlay.yml SANS reformater le fichier.
|
||
|
||
Meme motif que `_ecrire_index_nomenclature` : ce fichier porte des commentaires qui
|
||
expliquent des decisions d'architecture (pourquoi un seul routeur, pourquoi le
|
||
transit vit dans l'underlay...). Un `safe_dump` les effacerait toutes. On remplace
|
||
donc la ligne existante, en respectant son indentation, et on n'invente rien : une
|
||
clef absente du fichier n'est PAS creee — elle n'a pas de place evidente ou aller.
|
||
"""
|
||
if not FICHIER_UNDERLAY.exists():
|
||
return
|
||
texte = FICHIER_UNDERLAY.read_text(encoding="utf-8")
|
||
for variable, chemin in CHEMINS_UNDERLAY.items():
|
||
if variable not in valeurs:
|
||
continue
|
||
val = str(valeurs[variable]).strip()
|
||
if not val:
|
||
continue
|
||
feuille = chemin[-1]
|
||
# Indentation attendue : 2 espaces a la racine du bloc `underlay`, 4 si imbrique.
|
||
indent = " " * len(chemin)
|
||
motif = re.compile(rf"(?m)^{indent}{re.escape(feuille)}:[ \t]*\S.*$")
|
||
remplacement = f"{indent}{feuille}: {val}"
|
||
texte, n = motif.subn(remplacement, texte, count=1)
|
||
if not n:
|
||
raise ValueError(f"underlay.yml : clef '{feuille}' introuvable — "
|
||
f"l'ajouter au fichier avant de la regler ici.")
|
||
FICHIER_UNDERLAY.write_text(texte, encoding="utf-8")
|
||
|
||
|
||
def valider_nomenclature(donnees: dict, serveurs: dict) -> None:
|
||
"""Refuse une nomenclature qui casserait la derivation, AVANT d'ecrire.
|
||
|
||
La nomenclature n'est pas un registre comme les autres : elle ne decrit pas des
|
||
objets, elle decrit la REGLE dont VMID, VLAN, adresse et passerelle se derivent.
|
||
Une fonction retiree ne laisse pas un trou local — elle prive de tout adressage
|
||
chaque VM qui la porte, et `deriver_nomenclature` rend `None` SANS RIEN DIRE.
|
||
|
||
Trois refus, dans l'ordre du degat :
|
||
- une fonction encore portee par une VM ne se retire pas ;
|
||
- une fonction ne peut designer qu'une zone declaree ;
|
||
- `index` ne s'ecrit pas ici (il est ALLOUE par le site, cf. l'entete du fichier).
|
||
"""
|
||
categories = donnees.get("categories") or {}
|
||
fonctions = donnees.get("fonctions") or {}
|
||
if not isinstance(categories, dict) or not isinstance(fonctions, dict):
|
||
raise ValueError("`categories` et `fonctions` doivent etre des tables.")
|
||
|
||
connues = {str(c) for c in categories}
|
||
for nom, f in fonctions.items():
|
||
if not isinstance(f, dict):
|
||
raise ValueError(f"Fonction '{nom}': entree invalide.")
|
||
cat = f.get("categorie")
|
||
if cat in (None, ""):
|
||
raise ValueError(f"Fonction '{nom}': 'categorie' requise.")
|
||
if str(cat) not in connues:
|
||
raise ValueError(f"Fonction '{nom}': zone {cat} non declaree dans `categories` "
|
||
f"— la VM n'aurait ni VLAN ni adresse.")
|
||
if f.get("service") in (None, ""):
|
||
raise ValueError(f"Fonction '{nom}': 'service' requis.")
|
||
|
||
portees = {}
|
||
for nom_vm, srv in (serveurs or {}).items():
|
||
if isinstance(srv, dict) and srv.get("fonction"):
|
||
portees.setdefault(str(srv["fonction"]), []).append(nom_vm)
|
||
for fonction, vms in sorted(portees.items()):
|
||
if fonction not in fonctions:
|
||
raise ValueError(f"Fonction '{fonction}' retiree, mais encore portee par "
|
||
f"{len(vms)} VM ({', '.join(sorted(vms)[:4])}) — "
|
||
f"deplacer ces VM d'abord.")
|
||
|
||
zones_utilisees = {str(f.get("categorie")) for f in fonctions.values() if isinstance(f, dict)}
|
||
for zone in sorted(zones_utilisees - connues):
|
||
raise ValueError(f"Zone {zone} utilisee mais non declaree.")
|
||
|
||
|
||
def ecrire_nomenclature(path: Path, donnees: dict) -> None:
|
||
"""Ecrit la nomenclature SANS effacer ses commentaires.
|
||
|
||
POURQUOI PAS `safe_dump`. Ce fichier porte dix-neuf lignes qui disent pourquoi
|
||
`index` EST RECU ET PAS DECIDE, et pourquoi le modele des zones est commun a toute
|
||
la flotte. Un `safe_dump` les emporterait toutes — c'est exactement l'incident du
|
||
2026-08-18 (94 lignes de commentaire perdues en un enregistrement). On repasse donc
|
||
par `_fusion_chirurgicale`, qui pose les valeurs dans le texte existant.
|
||
|
||
`index` n'est jamais du lot : il s'ecrit par `_ecrire_index_nomenclature`, depuis le
|
||
panneau Intrants, parce qu'il vient du site et non de cette vue.
|
||
"""
|
||
texte = path.read_text(encoding="utf-8") if path.exists() else "---\n"
|
||
valeurs = {cle: donnees[cle] for cle in ("cidr_hote", "reservations", "categories", "fonctions")
|
||
if cle in donnees}
|
||
with ecriture_atomique(path) as fichier:
|
||
fichier.write(_fusion_chirurgicale(texte, valeurs,
|
||
tables={"categories", "fonctions"}))
|
||
|
||
|
||
def _ecrire_index_nomenclature(index: int) -> None:
|
||
"""Met a jour `index:` dans plan/nomenclature.yml SANS reformater le reste.
|
||
|
||
Le fichier garde ses commentaires et son formatage compact (l'adressage se derive
|
||
de ce seul seed). Remplacement de la ligne existante, ou insertion en tete a defaut.
|
||
"""
|
||
path = FICHIER_NOMENCLATURE
|
||
texte = path.read_text(encoding="utf-8") if path.exists() else "---\n"
|
||
nouveau, remplace = re.subn(r"(?m)^index:.*$", f"index: {index}", texte)
|
||
if not remplace:
|
||
nouveau = re.sub(r"(?m)^(---\n)", rf"\g<1>index: {index}\n", texte, count=1) or texte
|
||
# Atomique comme les autres : cette ecriture-ci avait ete oubliee au passage du
|
||
# 2026-09-06, et une coupure ici laisserait le seed a moitie ecrit — c'est-a-dire
|
||
# tout l'adressage de l'ecosysteme.
|
||
with ecriture_atomique(path) as fichier:
|
||
fichier.write(nouveau)
|
||
|
||
|
||
def _ecrire_registre(path: Path, entete: str, contenu: dict) -> None:
|
||
"""Ecrit un registre du plan SANS effacer les commentaires qu'il porte.
|
||
|
||
MESURE DU 2026-09-08, sur les fichiers reels de cet ecosysteme :
|
||
|
||
domaines.yml 6 lignes de commentaire -> 3 (PERD 3)
|
||
applications.yml 27 -> 5 (PERD 22)
|
||
serveurs.yml 18 -> 3 (PERD 15)
|
||
bases-donnees.yml 4 -> 4 (intact, il n'en a pas)
|
||
|
||
Quarante lignes, detruites par n'importe quel « Sauvegarder » depuis les vues
|
||
Serveurs, Applications ou Domaines. Parmi elles, celle qui explique pourquoi
|
||
`backup-01` a ete RETIRE : « une supervision creuse est pire qu'aucune — elle est
|
||
verte ». Et celle qui dit dans quel ORDRE les deux roles du runner s'appliquent.
|
||
|
||
C'est l'incident du 2026-08-18 (94 lignes perdues dans les fichiers d'intrants),
|
||
jamais corrige pour les registres du plan : `_fusion_chirurgicale` avait ete ecrite
|
||
pour les intrants seuls, et les quatre `ecrire_*` sont restes au `safe_dump`.
|
||
|
||
Les registres du plan ont tous la meme forme — une clef racine, une table d'entites —
|
||
donc chaque clef racine est declaree comme TABLE : `_fusion_table` edite alors les
|
||
lignes concernees, une entite retiree disparait, et le reste n'est pas touche.
|
||
|
||
Un fichier qui n'existe pas encore recoit son entete et un `safe_dump` : il n'y a
|
||
rien a preserver.
|
||
"""
|
||
if path.exists():
|
||
texte = path.read_text(encoding="utf-8")
|
||
if texte.strip():
|
||
with ecriture_atomique(path) as fichier:
|
||
fichier.write(_fusion_chirurgicale(texte, contenu, tables=set(contenu)))
|
||
return
|
||
with ecriture_atomique(path) as fichier:
|
||
fichier.write(entete)
|
||
yaml.safe_dump(contenu, fichier, default_flow_style=False,
|
||
sort_keys=False, allow_unicode=True)
|
||
|
||
|
||
def ecrire_bases(path: Path, registre: dict) -> None:
|
||
entete = (
|
||
"# Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees).\n"
|
||
"# Edite par make inventaire-ui ou scripts/bases_donnees.py.\n"
|
||
"# 'portee' (application|groupe|hote) interprete 'consommateur' ; defaut groupe.\n"
|
||
"# 'secret' nomme une variable Ansible Vault (jamais le mot de passe).\n"
|
||
"---\n"
|
||
)
|
||
_ecrire_registre(path, entete, {
|
||
"serveurs_bd": registre.get("serveurs_bd", {}) or {},
|
||
"bases_donnees": registre.get("bases_donnees", {}) or {},
|
||
})
|
||
|
||
|
||
def ecrire_applications(path: Path, registre: dict) -> None:
|
||
entete = (
|
||
"# Registre des applications Set-OPS (application = entite pivot).\n"
|
||
"# Edite par make inventaire-ui ou scripts/applications.py.\n"
|
||
"# Une VM peut porter plusieurs applications ; une base se lie a une application.\n"
|
||
"# 'liens' = bindings declaratifs [{vers, role}] resolus par scripts/instancier.py ;\n"
|
||
"# les roles acceptes sont ceux de roles/<groupe>/meta/liens.yml.\n"
|
||
"---\n"
|
||
)
|
||
_ecrire_registre(path, entete, {"applications": registre.get("applications", {}) or {}})
|
||
|
||
|
||
def ecrire_serveurs(path: Path, registre: dict) -> None:
|
||
entete = (
|
||
"# Registre des serveurs (VM) du plan Set-OPS.\n"
|
||
"# Edite par make inventaire-ui ou scripts/serveurs.py.\n"
|
||
"# VMID/IP/VLAN/passerelle sont DERIVES de la fonction via instance/plan/nomenclature.yml.\n"
|
||
"---\n"
|
||
)
|
||
_ecrire_registre(path, entete, {"serveurs": registre.get("serveurs", {}) or {}})
|
||
|
||
|
||
def ecrire_domaines(path: Path, registre: dict) -> None:
|
||
entete = (
|
||
"# Registre des domaines publics (zones DNS de l'instance).\n"
|
||
"# Edite par make inventaire-ui ou scripts/domaines.py.\n"
|
||
"# autorite : primaire-cache | auto-heberge | delegue (label descriptif).\n"
|
||
"---\n"
|
||
)
|
||
_ecrire_registre(path, entete, {"domaines_publics": registre.get("domaines_publics", {}) or {}})
|
||
|
||
|
||
def _extraire_echec(ligne: str, tache: str) -> dict | None:
|
||
"""D'une ligne Ansible fatal/unreachable, tire un échec en LANGAGE CLAIR
|
||
(tâche · hôte · type · message) — pour communiquer entre humains."""
|
||
m = re.search(r'(?:fatal|failed):\s*\[([^\]]+)\]:\s*(FAILED!|UNREACHABLE!)?\s*=>\s*(\{.*\})', ligne)
|
||
if not m:
|
||
return None
|
||
hote = m.group(1)
|
||
typ = "injoignable (SSH)" if m.group(2) == "UNREACHABLE!" else "échec"
|
||
detail: dict = {}
|
||
try:
|
||
detail = json.loads(m.group(3))
|
||
except Exception:
|
||
pass
|
||
if detail.get("censored"):
|
||
message = "sortie masquée (no_log) — souvent une tâche qui manipule un secret"
|
||
else:
|
||
msg = str(detail.get("msg", "")).strip()
|
||
# Pour un échec de commande, msg est générique ; la vraie cause est dans stderr.
|
||
stderr = ""
|
||
for cle in ("stderr", "module_stderr", "stdout"):
|
||
valeur = str(detail.get(cle, "")).strip()
|
||
if valeur:
|
||
lignes = [x for x in valeur.splitlines() if x.strip()]
|
||
stderr = lignes[0] if lignes else valeur # l'erreur mène, l'indice suit
|
||
break
|
||
if msg and msg.lower() not in ("non-zero return code", "module failure", ""):
|
||
message = msg
|
||
elif stderr:
|
||
message = stderr
|
||
else:
|
||
message = msg or "échec sans message explicite (voir le journal complet)"
|
||
message = " ".join(str(message).split()) # une ligne propre
|
||
return {"tache": (tache or "?").strip(), "hote": hote, "type": typ, "message": message[:400]}
|
||
|
||
|
||
def donnees_hote(hote: dict) -> dict:
|
||
variables: dict = {}
|
||
for cle, var, typ in CHAMPS_PROVISION:
|
||
brut = str(hote.get(cle, "")).strip()
|
||
if not brut:
|
||
continue
|
||
if typ == "int" and brut.lstrip("-").isdigit():
|
||
variables[var] = int(brut)
|
||
else:
|
||
variables[var] = brut
|
||
return variables
|
||
|
||
|
||
def construire_inventaire(payload: dict, groupes_connus: list[str]) -> dict:
|
||
enfants_data: dict = {
|
||
"modeles_vm": {"hosts": {}},
|
||
GROUPE_HOTES_ACTIFS: {"hosts": {}},
|
||
GROUPE_HOTES_PLANIFIES: {"hosts": {}},
|
||
}
|
||
for groupe in groupes_connus:
|
||
enfants_data.setdefault(groupe, {"hosts": {}})
|
||
enfants_data.setdefault("hotes_proxmox", {"hosts": {}})
|
||
|
||
for hote in payload.get("hotes", []):
|
||
nom = str(hote.get("nom", "")).strip()
|
||
if not nom:
|
||
continue
|
||
variables = donnees_hote(hote)
|
||
groupe_etat = GROUPE_HOTES_ACTIFS if hote.get("etat") == "actif" else GROUPE_HOTES_PLANIFIES
|
||
enfants_data[groupe_etat]["hosts"][nom] = variables
|
||
for groupe in hote.get("groupes", []):
|
||
if groupe not in groupes_connus:
|
||
continue
|
||
enfants_data[groupe]["hosts"][nom] = {}
|
||
|
||
return {"all": {"children": enfants_data}}
|
||
|
||
|
||
def valider_dependances_payload(payload: dict, dependencies: dict) -> None:
|
||
hotes = payload.get("hotes", [])
|
||
active_groups: set[str] = set()
|
||
|
||
for hote in hotes:
|
||
if hote.get("etat") != "actif":
|
||
continue
|
||
active_groups.update(hote.get("groupes", []))
|
||
|
||
missing: list[str] = []
|
||
for hote in hotes:
|
||
if hote.get("etat") != "actif":
|
||
continue
|
||
nom = str(hote.get("nom", "")).strip()
|
||
for groupe in hote.get("groupes", []):
|
||
for required_group in dependencies.get(groupe, {}).get("requiert_groupes_actifs", []):
|
||
if required_group not in active_groups:
|
||
missing.append(f"{nom}: {groupe} requiert {required_group} actif")
|
||
|
||
if missing:
|
||
raise ValueError("Dependances manquantes: " + "; ".join(missing))
|
||
|
||
|
||
def valider_payload(payload: dict, groupes_connus: list[str], dependencies: dict) -> None:
|
||
noms: set[str] = set()
|
||
vmids: dict[str, str] = {}
|
||
for hote in payload.get("hotes", []):
|
||
nom = str(hote.get("nom", "")).strip()
|
||
if not nom:
|
||
raise ValueError("Un hote a un nom vide.")
|
||
if nom in noms:
|
||
raise ValueError(f"Hote en double: {nom}")
|
||
noms.add(nom)
|
||
if hote.get("etat") == "actif" and not str(hote.get("adresse_ip", "")).strip():
|
||
raise ValueError(f"Hote actif sans adresse IP: {nom}")
|
||
groupes = hote.get("groupes", [])
|
||
if not groupes:
|
||
raise ValueError(f"Aucun groupe operationnel pour: {nom}")
|
||
inconnus = sorted(set(groupes) - set(groupes_connus))
|
||
if inconnus:
|
||
raise ValueError(f"Groupes inconnus pour {nom}: {', '.join(inconnus)}")
|
||
|
||
vlan = str(hote.get("vlan", "")).strip()
|
||
if vlan and (not vlan.isdigit() or not 1 <= int(vlan) <= 4094):
|
||
raise ValueError(f"VLAN invalide pour {nom}: {vlan} (attendu 1-4094)")
|
||
cidr = str(hote.get("cidr", "")).strip()
|
||
if cidr and (not cidr.isdigit() or not 0 <= int(cidr) <= 32):
|
||
raise ValueError(f"CIDR invalide pour {nom}: {cidr} (attendu 0-32)")
|
||
for cle, libelle in (("memoire", "Memoire"), ("coeurs", "Coeurs")):
|
||
valeur = str(hote.get(cle, "")).strip()
|
||
if valeur and (not valeur.isdigit() or int(valeur) <= 0):
|
||
raise ValueError(f"{libelle} invalide pour {nom}: {valeur} (entier positif attendu)")
|
||
|
||
vmid = str(hote.get("vmid", "")).strip()
|
||
if not vmid:
|
||
continue
|
||
if vmid in vmids:
|
||
raise ValueError(f"VMID en double: {vmid} pour {vmids[vmid]} et {nom}")
|
||
vmids[vmid] = nom
|
||
|
||
valider_dependances_payload(payload, dependencies)
|
||
|
||
|
||
HTML = r"""<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Set-OPS · Votre artisan numérique</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--fond:#0c1119;
|
||
--fond-degrade:radial-gradient(1100px 540px at 100% -8%, #142033 0, transparent 55%), var(--fond);
|
||
--surface:#151d2b;
|
||
--surface-2:#1a2333;
|
||
--surface-3:#212d41;
|
||
--texte:#e8edf6;
|
||
--muted:#93a1bb;
|
||
--faint:#67738c;
|
||
--ligne:#27324a;
|
||
--ligne-forte:#36445f;
|
||
--primaire:#3b82f6;
|
||
--primaire-fort:#2f6fe0;
|
||
--teal:#2dd4bf;
|
||
--vert:#34d399; --vert-fond:rgba(52,211,153,.13);
|
||
--bleu:#60a5fa; --bleu-fond:rgba(96,165,250,.13);
|
||
--rouge:#f87171; --rouge-fond:rgba(248,113,113,.13);
|
||
--ambre:#fbbf24; --ambre-fond:rgba(251,191,36,.13);
|
||
--ombre:0 1px 2px rgba(0,0,0,.35), 0 10px 30px -14px rgba(0,0,0,.7);
|
||
--ombre-forte:0 18px 50px -18px rgba(0,0,0,.8);
|
||
--rayon:11px; --rayon-sm:8px;
|
||
}
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
margin: 0; background: var(--fond-degrade); color: var(--texte); min-height: 100vh;
|
||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
font-size: 14px; line-height: 1.5; -webkit-font-smoothing: antialiased;
|
||
}
|
||
::selection { background: color-mix(in srgb, var(--teal) 35%, transparent); }
|
||
* { scrollbar-width: thin; scrollbar-color: var(--ligne-forte) transparent; }
|
||
header {
|
||
position: sticky; top: 0; z-index: 20;
|
||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||
padding: 12px 22px; border-bottom: 1px solid var(--ligne);
|
||
background: color-mix(in srgb, var(--surface) 82%, transparent);
|
||
backdrop-filter: saturate(150%) blur(12px);
|
||
}
|
||
.marque { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||
.marque .pastille { width: 9px; height: 9px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px color-mix(in srgb, var(--teal) 18%, transparent); flex: none; }
|
||
.marque .titre { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
|
||
h1 { margin: 0; font-size: 15px; font-weight: 800; letter-spacing: -.01em; white-space: nowrap; }
|
||
.slogan { font-size: 10.5px; color: var(--muted); font-weight: 600; letter-spacing: .02em; white-space: nowrap; }
|
||
.chemin { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.grandit { flex: 1 1 auto; min-width: 0; }
|
||
.resume { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.chip { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--ligne); border-radius: 999px; padding: 3px 10px; background: var(--surface-2); color: var(--muted); font-size: 11.5px; font-weight: 650; }
|
||
.chip b { color: var(--texte); font-weight: 800; }
|
||
.pt { width: 7px; height: 7px; border-radius: 50%; flex: none; }
|
||
.pt.actif { background: var(--vert); } .pt.plan { background: var(--bleu); } .pt.bloq { background: var(--rouge); }
|
||
.barre { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||
.recherche { position: relative; }
|
||
.recherche input { padding-left: 30px; min-width: 180px; }
|
||
.recherche::before { content: "⌕"; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--faint); font-size: 16px; }
|
||
button {
|
||
border: 1px solid var(--ligne-forte); background: var(--surface-2); color: var(--texte);
|
||
padding: 8px 13px; border-radius: var(--rayon-sm); cursor: pointer; font: inherit; font-weight: 700;
|
||
transition: background .15s, border-color .15s, transform .05s, box-shadow .15s, opacity .15s, color .15s;
|
||
}
|
||
button:hover:not(:disabled) { border-color: var(--teal); }
|
||
button:active:not(:disabled) { transform: translateY(1px); }
|
||
button:disabled { opacity: .4; cursor: not-allowed; }
|
||
button.primaire { background: var(--primaire); color: #fff; border-color: var(--primaire); box-shadow: var(--ombre); }
|
||
button.primaire:hover:not(:disabled) { background: var(--primaire-fort); border-color: var(--primaire-fort); }
|
||
button.fantome { background: transparent; }
|
||
button.attention { border-color: var(--ambre); color: var(--ambre); }
|
||
button.danger { color: var(--rouge); border-color: transparent; background: transparent; }
|
||
button.danger:hover:not(:disabled) { background: var(--rouge-fond); }
|
||
input, select {
|
||
width: 100%; border: 1px solid var(--ligne-forte); border-radius: var(--rayon-sm);
|
||
padding: 8px 10px; background: var(--surface); color: var(--texte); font: inherit;
|
||
transition: border-color .15s, box-shadow .15s;
|
||
}
|
||
input::placeholder { color: var(--faint); }
|
||
input:focus, select:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px color-mix(in srgb, var(--teal) 22%, transparent); }
|
||
|
||
main { padding: 16px 22px 44px; display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(420px, 1fr); gap: 20px; align-items: start; max-width: 1760px; margin: 0 auto; }
|
||
.toasts { position: fixed; top: 14px; right: 14px; z-index: 60; display: flex; flex-direction: column; gap: 8px; max-width: min(380px, 92vw); pointer-events: none; }
|
||
.toast { pointer-events: auto; background: var(--surface-2); border: 1px solid var(--ligne-forte); border-left: 3px solid var(--bleu); border-radius: var(--rayon-sm); box-shadow: var(--ombre-forte); padding: 9px 12px; font-size: 13px; font-weight: 600; color: var(--texte); display: flex; gap: 9px; align-items: flex-start; animation: toast-in .18s ease-out; }
|
||
.toast.ok { border-left-color: var(--vert); } .toast.erreur { border-left-color: var(--rouge); }
|
||
.toast.sortant { opacity: 0; transform: translateX(12px); transition: opacity .25s, transform .25s; }
|
||
.toast .x { margin-left: auto; cursor: pointer; opacity: .5; font-weight: 800; background: none; border: 0; color: inherit; padding: 0 2px; font-size: 15px; line-height: 1; }
|
||
.toast .x:hover { opacity: 1; }
|
||
@keyframes toast-in { from { opacity: 0; transform: translateX(12px); } }
|
||
input.invalide, select.invalide { border-color: var(--rouge) !important; box-shadow: 0 0 0 1px var(--rouge); }
|
||
|
||
|
||
.section-grille { margin-bottom: 22px; }
|
||
.section-tete { display: flex; align-items: center; gap: 10px; margin: 0 0 11px; color: var(--faint); font-size: 11px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||
.section-tete .compte { color: var(--muted); }
|
||
.section-tete::after { content: ""; height: 1px; background: var(--ligne); flex: 1; }
|
||
.grille-cartes { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
|
||
.carte {
|
||
position: relative; text-align: left; display: flex; flex-direction: column; gap: 8px;
|
||
background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon);
|
||
padding: 12px 13px; cursor: pointer; font: inherit; color: var(--texte);
|
||
transition: border-color .14s, box-shadow .14s, transform .05s, background .14s;
|
||
}
|
||
.carte::before { content: ""; position: absolute; left: 0; top: 11px; bottom: 11px; width: 3px; border-radius: 0 3px 3px 0; background: var(--bleu); }
|
||
.carte.actif::before { background: var(--vert); }
|
||
.carte.bloque::before { background: var(--rouge); }
|
||
.carte:hover { border-color: var(--ligne-forte); box-shadow: var(--ombre); }
|
||
.carte.selectionnee { border-color: var(--teal); box-shadow: 0 0 0 1px var(--teal), var(--ombre); background: var(--surface-2); }
|
||
.carte-haut { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||
.carte-vmid { color: var(--faint); font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||
.carte-nom { font-weight: 800; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.carte-nom.sans { color: var(--faint); font-style: italic; font-weight: 600; }
|
||
.carte-ip { color: var(--muted); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||
.carte-grps { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 1px; }
|
||
.tag { font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 999px; background: var(--surface-3); color: var(--muted); white-space: nowrap; }
|
||
.tag.plus { background: transparent; color: var(--faint); }
|
||
.badge-statut { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 9px; font-size: 10.5px; font-weight: 800; }
|
||
.badge-statut.ok { color: var(--vert); background: var(--vert-fond); }
|
||
.badge-statut.attente { color: var(--bleu); background: var(--bleu-fond); }
|
||
.badge-statut.bloque { color: var(--rouge); background: var(--rouge-fond); }
|
||
|
||
aside { position: sticky; top: 70px; max-height: calc(100vh - 90px); overflow: auto; }
|
||
.detail { background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon); box-shadow: var(--ombre); overflow: hidden; }
|
||
.detail.bloque { border-color: color-mix(in srgb, var(--rouge) 50%, var(--ligne)); }
|
||
.detail-tete { display: flex; align-items: center; gap: 10px; padding: 14px 16px 12px; border-bottom: 1px solid var(--ligne); flex-wrap: wrap; }
|
||
.detail-nom { flex: 1 1 150px; min-width: 0; }
|
||
.zone-hint { display: inline-block; margin: 4px 0 0 8px; font-size: 11px; font-weight: 700; color: var(--teal); }
|
||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); gap: 8px; padding: 14px 16px; }
|
||
.stat { background: var(--surface-2); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 9px 10px; min-width: 0; }
|
||
.stat .v { font-size: 18px; font-weight: 800; line-height: 1.1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.stat .v.vide { color: var(--faint); }
|
||
.stat .l { font-size: 9.5px; text-transform: uppercase; letter-spacing: .06em; color: var(--faint); font-weight: 800; margin-top: 3px; }
|
||
.grille { display: grid; grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: 11px 12px; }
|
||
.champ { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||
.champ > span { color: var(--muted); font-size: 11px; font-weight: 700; }
|
||
.champ .unite { color: var(--faint); font-weight: 600; }
|
||
.champ > span.a-aide { cursor: help; border-bottom: 1px dotted var(--ligne-forte); width: fit-content; }
|
||
.aide-modele { margin: 0 2px 14px; background: var(--surface); border: 1px solid var(--ligne); border-left: 3px solid var(--bleu); border-radius: var(--rayon-sm); }
|
||
.aide-modele > summary { cursor: pointer; padding: 10px 13px; font-size: 12.5px; font-weight: 750; color: var(--bleu); list-style: none; }
|
||
.aide-modele > summary::-webkit-details-marker { display: none; }
|
||
.aide-modele > summary::before { content: "ⓘ "; }
|
||
.aide-corps { padding: 2px 15px 12px; color: var(--muted); font-size: 12.5px; line-height: 1.55; }
|
||
.aide-corps p { margin: 6px 0; } .aide-corps ul { margin: 6px 0; padding-left: 18px; } .aide-corps li { margin: 3px 0; }
|
||
.aide-corps b { color: var(--texte); }
|
||
.aide-ro { border-top: 1px solid var(--ligne); padding-top: 8px; }
|
||
.detail-nom-txt { font-weight: 800; font-size: 17px; padding: 4px 7px; display: inline-block; overflow-wrap: anywhere; }
|
||
#infobulle {
|
||
position: fixed; z-index: 200; max-width: 260px; pointer-events: none;
|
||
background: var(--surface-3); color: var(--texte);
|
||
border: 1px solid var(--ligne-forte); border-radius: var(--rayon-sm);
|
||
padding: 7px 10px; font-size: 12px; font-weight: 600; line-height: 1.4;
|
||
box-shadow: var(--ombre); opacity: 0; transform: translateY(2px);
|
||
transition: opacity .12s ease, transform .12s ease;
|
||
}
|
||
#infobulle.on { opacity: 1; transform: none; }
|
||
.pied { padding: 12px 16px; border-top: 1px solid var(--ligne); display: flex; gap: 8px; align-items: center; }
|
||
.pied .grandit { flex: 1; }
|
||
.pied .hint-dep { color: var(--faint); font-size: 12px; font-style: italic; }
|
||
.dep-bloc { margin-top: 4px; }
|
||
.dep-bloc > summary { list-style: none; cursor: pointer; padding: 10px 13px; display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 800; font-size: 12.5px; background: var(--surface); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); }
|
||
.dep-bloc > summary::-webkit-details-marker { display: none; }
|
||
.dep-bloc > summary::after { content: "▾"; color: var(--faint); }
|
||
.dep-bloc[open] > summary { border-radius: var(--rayon-sm) var(--rayon-sm) 0 0; }
|
||
.dep-corps { border: 1px solid var(--ligne); border-top: none; border-radius: 0 0 var(--rayon-sm) var(--rayon-sm); padding: 11px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
|
||
.carte-dep { border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 9px 10px; background: var(--surface-2); }
|
||
.carte-dep.ok { border-color: color-mix(in srgb, var(--vert) 45%, var(--ligne)); }
|
||
.carte-dep.attente { border-color: color-mix(in srgb, var(--ambre) 45%, var(--ligne)); }
|
||
.carte-dep.bloque { border-color: color-mix(in srgb, var(--rouge) 50%, var(--ligne)); }
|
||
.carte-dep .haut { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||
.carte-dep .nom { font-weight: 800; font-size: 12px; overflow-wrap: anywhere; }
|
||
.carte-dep .txt { color: var(--muted); font-size: 11px; line-height: 1.4; }
|
||
.vide { color: var(--muted); padding: 40px 24px; text-align: center; }
|
||
.vide b { color: var(--texte); display: block; margin-bottom: 4px; font-size: 15px; }
|
||
|
||
.vue-bascule { display: inline-flex; border: 1px solid var(--ligne-forte); border-radius: 999px; padding: 2px; background: var(--surface-3); gap: 2px; }
|
||
.vue-bascule button { border: none; background: transparent; border-radius: 999px; padding: 6px 13px; font-size: 12.5px; font-weight: 750; color: var(--muted); box-shadow: none; }
|
||
.vue-bascule button:hover { color: var(--texte); background: transparent; }
|
||
.vue-bascule button.on { background: var(--surface); color: var(--teal); box-shadow: var(--ombre); }
|
||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--teal); font-size: 12px; }
|
||
.ch-stub { color: var(--ambre); font-size: 11.5px; font-style: italic; margin-top: 5px; }
|
||
.ch-bases { display: grid; gap: 4px; margin: 6px 0 0 23px; }
|
||
.ch-base { font-size: 11.5px; color: var(--muted); overflow-wrap: anywhere; }
|
||
.badge-bd { display: inline-block; font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
|
||
|
||
.modale { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,.55); }
|
||
.modale[hidden] { display: none; }
|
||
.modale-boite { background: var(--surface); border: 1px solid var(--ligne-forte); border-radius: var(--rayon); box-shadow: var(--ombre-forte); padding: 18px 20px; width: min(440px, 92vw); display: grid; gap: 12px; }
|
||
.modale-titre { font-weight: 800; font-size: 15px; }
|
||
.modale-texte { color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||
.modale-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||
.modale-boite.large { width: min(760px, 94vw); max-height: 88vh; overflow: auto; }
|
||
.intrants-corps { display: grid; gap: 16px; }
|
||
.intr-section { display: grid; gap: 9px; }
|
||
.intr-tete { font-weight: 800; font-size: 13px; color: var(--bleu); border-bottom: 1px solid var(--ligne); padding-bottom: 5px; }
|
||
.intr-grille { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 11px; }
|
||
.badge-classe { display: inline-block; font-size: 9.5px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; padding: 1px 6px; border-radius: 999px; margin-left: 6px; vertical-align: middle; }
|
||
.badge-classe.const { background: color-mix(in srgb, var(--ambre) 20%, transparent); color: var(--ambre); }
|
||
.badge-classe.def { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
|
||
.badge-classe.cat { background: color-mix(in srgb, var(--teal) 20%, transparent); color: var(--teal); }
|
||
.badge-prod { display: inline-flex; align-items: center; font-size: 11px; font-weight: 800; letter-spacing: .06em; padding: 3px 9px; border-radius: 999px; background: var(--rouge); color: #fff; }
|
||
.badge-lab { display: inline-flex; align-items: center; font-size: 11px; font-weight: 800; letter-spacing: .04em; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--teal) 22%, transparent); color: var(--teal); }
|
||
.integ-cases { display: flex; flex-wrap: wrap; gap: 6px 12px; padding: 4px 2px; }
|
||
.integ-case { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: var(--texte); cursor: pointer; }
|
||
.integ-univ { color: var(--vert); font-weight: 700; cursor: help; }
|
||
.integ-exempt { color: var(--faint); text-decoration: line-through; cursor: help; }
|
||
.integ-case input { width: 14px; height: 14px; flex: none; accent-color: var(--teal); }
|
||
/* Matrice serveurs x integrations */
|
||
.mx-cadre { overflow: auto; max-height: calc(100vh - 210px); }
|
||
.mx { border-collapse: separate; border-spacing: 0; font-size: 12px; width: max-content; min-width: 100%; }
|
||
.mx th, .mx td { border-bottom: 1px solid var(--ligne); padding: 5px 8px; }
|
||
.mx thead th, .mx tfoot td, .mx tfoot th { position: sticky; background: var(--surface-2); z-index: 2; }
|
||
.mx thead th { top: 0; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: var(--muted); text-align: center; white-space: nowrap; }
|
||
.mx thead th.mx-t-univ { color: var(--vert); }
|
||
.mx tfoot td, .mx tfoot th { bottom: 0; font-variant-numeric: tabular-nums; text-align: center; color: var(--muted); font-weight: 700; }
|
||
.mx .mx-plein { color: var(--vert); }
|
||
.mx .mx-h { position: sticky; left: 0; background: var(--surface); text-align: left; font-weight: 700; color: var(--texte); white-space: nowrap; cursor: pointer; z-index: 1; }
|
||
.mx thead .mx-coin, .mx tfoot .mx-coin { left: 0; z-index: 3; }
|
||
.mx .mx-h:hover { color: var(--teal); }
|
||
.mx .mx-fn { display: block; font-size: 10px; font-weight: 500; color: var(--faint); }
|
||
.mx tr.mx-sel .mx-h { box-shadow: inset 3px 0 0 var(--teal); }
|
||
.mx .mx-c { text-align: center; }
|
||
.mx .mx-c input { width: 14px; height: 14px; accent-color: var(--teal); cursor: pointer; }
|
||
.mx .mx-univ { color: var(--vert); font-weight: 800; cursor: help; }
|
||
.mx .mx-exempt { color: var(--faint); cursor: help; }
|
||
.mx-leg { margin-top: 10px; }
|
||
.mx-leg code { font-size: 12px; }
|
||
.lien-objet { background: none; border: 0; padding: 0; color: var(--bleu); font: inherit; font-weight: 700; cursor: pointer; text-decoration: underline dotted; }
|
||
.lien-objet:hover { color: var(--teal); }
|
||
.intr-proprio { margin-left: 8px; font-size: 10px; font-weight: 800; letter-spacing: .06em;
|
||
text-transform: uppercase; padding: 2px 8px; border-radius: 999px; cursor: help; }
|
||
.intr-proprio.heb { background: color-mix(in srgb, var(--ambre) 20%, transparent); color: var(--ambre); }
|
||
.intr-proprio.ten { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
|
||
.intr-ro { color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||
.intr-ro b { color: var(--texte); font-weight: 700; }
|
||
.intr-secrets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||
.intr-secrets code { font-size: 11px; background: var(--surface-2); border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 1px 6px; color: var(--muted); }
|
||
.console { position: fixed; left: 0; right: 0; bottom: 0; height: 44vh; background: var(--surface); border-top: 1px solid var(--ligne-forte); box-shadow: 0 -16px 50px -18px rgba(0,0,0,.8); z-index: 40; display: flex; flex-direction: column; }
|
||
.console[hidden] { display: none; }
|
||
.console-tete { display: flex; align-items: center; gap: 12px; padding: 9px 16px; border-bottom: 1px solid var(--ligne); background: var(--surface-2); }
|
||
.console-titre { font-weight: 800; font-size: 13px; }
|
||
.console-etat { font-size: 12px; font-weight: 800; }
|
||
.console-etat.actif { color: var(--bleu); animation: pulse 1.2s ease-in-out infinite; }
|
||
.console-etat.ok { color: var(--vert); } .console-etat.echec { color: var(--rouge); }
|
||
@keyframes pulse { 50% { opacity: .4; } }
|
||
.console pre { margin: 0; padding: 12px 16px; overflow: auto; flex: 1; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; color: #cdd6e6; }
|
||
@media (max-width: 1000px) {
|
||
main { grid-template-columns: 1fr; }
|
||
aside { position: static; max-height: none; order: -1; }
|
||
}
|
||
/* Vues lecture seule : Flux + Couches */
|
||
.tbl-lecture { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||
.tbl-lecture th { text-align: left; padding: 7px 10px; color: var(--muted); font-weight: 750; border-bottom: 1px solid var(--ligne-forte); position: sticky; top: 0; background: var(--surface); }
|
||
.tbl-lecture td { padding: 6px 10px; border-bottom: 1px solid var(--ligne); vertical-align: top; }
|
||
.tbl-lecture tr:hover td { background: var(--surface-2); }
|
||
.flux-raison { color: var(--muted); max-width: 44ch; }
|
||
.sens { font-size: 11px; font-weight: 750; padding: 1px 7px; border-radius: 999px; }
|
||
.sens-ingress { background: color-mix(in srgb, var(--bleu) 16%, transparent); color: var(--bleu); }
|
||
.sens-egress { background: color-mix(in srgb, var(--ambre) 18%, transparent); color: var(--ambre); }
|
||
.chip.chi-tls-requis { background: color-mix(in srgb, var(--teal) 22%, transparent); color: var(--teal); }
|
||
.chip.chi-tls { background: color-mix(in srgb, var(--teal) 13%, transparent); color: var(--teal); }
|
||
.chip.chi-starttls { background: color-mix(in srgb, var(--ambre) 18%, transparent); color: var(--ambre); }
|
||
.chip.chi-ssh { background: color-mix(in srgb, var(--bleu) 18%, transparent); color: var(--bleu); }
|
||
.chip.chi-tls-cible { background: color-mix(in srgb, var(--ambre) 12%, transparent); color: var(--muted); }
|
||
.chip.chi-clair { background: var(--surface-3); color: var(--muted); }
|
||
.couches-pile { display: flex; flex-direction: column; gap: 0; padding: 6px; }
|
||
.couche { border: 1px solid var(--ligne-forte); border-radius: 10px; padding: 9px 12px; background: var(--surface-2); }
|
||
.couche-tete { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||
.couche-idx { display: inline-flex; width: 20px; height: 20px; align-items: center; justify-content: center; border-radius: 999px; background: var(--teal); color: var(--surface); font-size: 11px; font-weight: 800; }
|
||
.couche-groupes { display: flex; flex-wrap: wrap; gap: 5px; }
|
||
.couche-fleche { text-align: center; color: var(--muted); font-size: 14px; line-height: 1.5; }
|
||
.legende { display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--muted); }
|
||
.legende .chip { margin-right: 4px; }
|
||
/* Résumé d'erreur en langage clair (console) */
|
||
#console-resume { padding: 12px 16px; background: color-mix(in srgb, var(--rouge) 10%, var(--surface)); border-bottom: 1px solid var(--ligne); }
|
||
.re-tete { display: flex; align-items: center; gap: 10px; font-weight: 800; color: var(--rouge); font-size: 13px; margin-bottom: 6px; }
|
||
.re-tete button { margin-left: auto; }
|
||
.re-liste { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 7px; }
|
||
.re-liste li { font-size: 12.5px; }
|
||
.re-hote { color: var(--teal); font-weight: 700; }
|
||
.re-type { color: var(--muted); font-size: 11px; }
|
||
.re-msg { background: var(--surface-3); border-radius: 6px; padding: 4px 8px; margin-top: 3px; font-family: ui-monospace, monospace; font-size: 11.5px; white-space: pre-wrap; word-break: break-word; }
|
||
/* Devis réseau (config switch) */
|
||
.devis-reseau { margin: 8px 0 0; padding: 12px 14px; background: var(--surface-3); border-radius: 8px; font-family: ui-monospace, monospace; font-size: 11.5px; line-height: 1.5; white-space: pre; overflow: auto; max-height: calc(100vh - 210px); }
|
||
|
||
/* --- LES ASSISTANTS (runbooks) ------------------------------------------------ */
|
||
.rb-liste{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 14px}
|
||
.rb-puce{border:1px solid var(--bord,#2a3340);background:#131a22;color:inherit;border-radius:999px;
|
||
padding:6px 13px;font-size:12.5px;font-weight:700;cursor:pointer;display:flex;align-items:center;gap:7px}
|
||
.rb-puce.on{background:#1d2a38;border-color:#3d6ea8}
|
||
.rb-puce.hors{opacity:.55}
|
||
.rb-etape{border:1px solid var(--bord,#2a3340);border-radius:10px;padding:11px 13px;margin-bottom:9px;
|
||
display:grid;grid-template-columns:30px 1fr auto;gap:11px;align-items:start;background:#10161d}
|
||
.rb-etape.faite{border-color:#2f6b45}
|
||
.rb-etape.ratee{border-color:#8a3b3b}
|
||
.rb-etape.bloquee{opacity:.5}
|
||
.rb-num{font-weight:800;opacity:.55;text-align:right;padding-top:2px}
|
||
.rb-cible{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;font-weight:700}
|
||
.rb-libelle{font-size:12.5px;opacity:.9;margin-top:2px}
|
||
.rb-pourquoi{font-size:12px;opacity:.72;margin-top:5px;line-height:1.45}
|
||
.rb-nature{font-size:10.5px;font-weight:800;letter-spacing:.03em;padding:2px 7px;border-radius:999px;margin-left:7px}
|
||
.rb-nature.mesure{background:#1b2c3a;color:#8fc4f0}
|
||
.rb-nature.ecriture{background:#2e2716;color:#e6bf6a}
|
||
.rb-nature.destructif{background:#3a1c1c;color:#f08a8a}
|
||
.rb-meta{font-size:11.5px;opacity:.6;margin-top:4px}
|
||
.rb-but{font-size:12.5px;line-height:1.5;opacity:.88}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||
<header>
|
||
<div class="marque"><span class="pastille"></span><div class="titre"><h1>Set-OPS</h1><span class="slogan">Votre artisan numérique</span></div></div>
|
||
<span class="chemin" id="chemin"></span>
|
||
<span id="portee" style="font-size:11.5px;font-weight:800;letter-spacing:.02em;padding:3px 9px;border-radius:999px;white-space:nowrap"
|
||
title="Ce que cette console peut faire — dérivé de ce que sa machine porte, pas d'un réglage"></span>
|
||
<div class="grandit"></div>
|
||
<div class="resume" id="resume"></div>
|
||
<div class="barre" title="Raccourcis clavier — 1-3 : changer de vue · j/k : naviguer les serveurs · v : vérifier · d : déployer · / : filtrer · Ctrl+S : sauvegarder la vue">
|
||
<div class="vue-bascule">
|
||
<button type="button" id="btn-vue-serveurs" class="on" onclick="setVue('serveurs')">Serveurs</button>
|
||
<button type="button" id="btn-vue-applications" onclick="setVue('applications')">Applications</button>
|
||
<button type="button" id="btn-vue-bases" onclick="setVue('bases')">Bases</button>
|
||
<button type="button" id="btn-vue-domaines" onclick="setVue('domaines')">Domaines</button>
|
||
<button type="button" id="btn-vue-nomenclature" onclick="setVue('nomenclature')">Nomenclature</button>
|
||
<button type="button" id="btn-vue-integrations" onclick="setVue('integrations')">Intégrations</button>
|
||
<button type="button" id="btn-vue-flux" onclick="setVue('flux')">Flux</button>
|
||
<button type="button" id="btn-vue-couches" onclick="setVue('couches')">Couches</button>
|
||
<button type="button" id="btn-vue-reseau" onclick="setVue('reseau')">Réseau</button>
|
||
<button type="button" id="btn-vue-assistants" onclick="setVue('assistants')" title="Les runbooks de construction : l'ordre des gestes, et pourquoi celui-là">Assistants</button>
|
||
</div>
|
||
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
|
||
<button type="button" class="fantome" onclick="ouvrirIntrants()">⚙ Intrants</button>
|
||
<button type="button" class="fantome" onclick="charger()">Recharger</button>
|
||
<button type="button" class="primaire" id="btn-sauver" onclick="sauvegarderVue()">Sauvegarder</button>
|
||
</div>
|
||
</header>
|
||
<main>
|
||
<section>
|
||
<div id="grilles"></div>
|
||
<details class="dep-bloc" id="panneau-dep">
|
||
<summary>Dépendances causales <span id="dep-resume" class="chip"></span></summary>
|
||
<div class="dep-corps" id="dependances"></div>
|
||
</details>
|
||
</section>
|
||
<aside><div id="detail"></div></aside>
|
||
</main>
|
||
<div class="modale" id="modale" hidden>
|
||
<div class="modale-boite">
|
||
<div class="modale-titre" id="modale-titre"></div>
|
||
<div class="modale-texte" id="modale-texte"></div>
|
||
<label class="champ"><span>Mot de passe du vault Ansible (laisser vide si non requis)</span>
|
||
<input type="password" id="modale-vault" autocomplete="off" placeholder="••••••••" onkeydown="if(event.key==='Enter')confirmerModale()"></label>
|
||
<div class="modale-actions">
|
||
<button type="button" onclick="annulerModale()">Annuler</button>
|
||
<button type="button" class="primaire" onclick="confirmerModale()">Confirmer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modale" id="intrants" hidden>
|
||
<div class="modale-boite large">
|
||
<div class="modale-titre">⚙ Intrants de base de l'écosystème</div>
|
||
<div class="modale-texte">Saisis ici les valeurs communes à tout l'écosystème, depuis un endroit unique.
|
||
Les <b>constantes</b> sont une valeur unique non surchargeable ; les <b>défauts</b> resurgissent
|
||
comme valeurs proposées là où ils réapparaissent dans les instances déclarées.
|
||
Les secrets ne sont jamais saisis ici : ils restent dans le Vault Ansible (CLI).</div>
|
||
<div class="intrants-corps" id="intrants-corps"></div>
|
||
<div class="modale-actions">
|
||
<button type="button" onclick="fermerIntrants()">Fermer</button>
|
||
<button type="button" class="primaire" onclick="enregistrerIntrants()">Enregistrer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modale" id="etape" hidden>
|
||
<div class="modale-boite">
|
||
<div class="modale-titre" id="etape-titre"></div>
|
||
<div class="modale-texte" id="etape-texte"></div>
|
||
<div id="etape-champs"></div>
|
||
<label class="champ"><span>Mot de passe du vault Ansible (laisser vide si non requis)</span>
|
||
<input type="password" id="etape-vault" autocomplete="off" placeholder="••••••••"></label>
|
||
<div class="modale-actions">
|
||
<button type="button" onclick="annulerEtape()">Annuler</button>
|
||
<button type="button" class="primaire" id="etape-ok" onclick="confirmerEtape()">Lancer</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="console" id="console" hidden>
|
||
<div class="console-tete">
|
||
<span class="console-titre" id="console-titre"></span>
|
||
<span class="console-etat" id="console-etat"></span>
|
||
<span class="grandit"></span>
|
||
<button type="button" id="btn-fermer-console" onclick="fermerConsole()">Fermer</button>
|
||
</div>
|
||
<div id="console-resume" hidden></div>
|
||
<pre id="console-sortie"></pre>
|
||
</div>
|
||
<script>
|
||
const JETON = "__JETON__";
|
||
let groupes = [];
|
||
let dependances = {};
|
||
let nomenclature = {};
|
||
// LE SCHEMA DES REGISTRES, a portee globale. Les formulaires generes le lisaient dans
|
||
// `data.schema` — or il n'existe AUCUN `data` global ici : c'est une const locale de
|
||
// `charger()`. Le rendu levait donc `ReferenceError` dans un navigateur, et
|
||
// `sauvegarderBases` pire encore (zone morte : `const data` y est declare plus bas,
|
||
// dans la meme fonction). Eprouver le rendu sous node en LUI FOURNISSANT `data` ne
|
||
// pouvait pas le voir : le banc reproduisait la fonction, pas sa portee.
|
||
let schemaPlan = {};
|
||
let chaine = {};
|
||
let hotes = [];
|
||
let estProduction = false;
|
||
let filtre = '';
|
||
let vuePrincipale = 'serveurs';
|
||
let fluxRegistre = []; // matrice des flux réseau (lecture seule)
|
||
let couchesRegistre = []; // ordre de déploiement en couches (lecture seule)
|
||
let bdServeurs = [];
|
||
let bdApplis = [];
|
||
let basesModifie = false;
|
||
let selApp = null; // index de l'application sélectionnée (vue Applications)
|
||
let selServeur = null; // index du serveur sélectionné (vue Serveurs, maître-détail fusionné)
|
||
let selBase = null; // clé de l'item sélectionné (vue Bases) : "srv:<i>" ou "bd:<i>"
|
||
let hoteOpCourant = ''; // nom de l'hôte dont les boutons Vérifier/Déployer sont affichés
|
||
let applications = [];
|
||
let applicationsModifie = false;
|
||
let nomEdit = {}; // nomenclature en cours d'édition (copie de travail)
|
||
let selNom = null; // 'cat:<clef>' ou 'fon:<clef>' — l'entrée sélectionnée
|
||
let nomModifie = false;
|
||
let domaines = []; // registre des domaines (éditable)
|
||
let selDomaine = null; // index du domaine sélectionné
|
||
let domainesModifie = false;
|
||
const AUTORITES_DNS = ['primaire-cache', 'auto-heberge', 'delegue'];
|
||
let serveurs = [];
|
||
// La selection de la vue « machines du site » — distincte de `selServeur`, qui indexe un
|
||
// registre de plan que cette console n'a pas.
|
||
let selMachine = null;
|
||
let serveursModifie = false;
|
||
let ongletActif = 'reseau';
|
||
let occupe = false;
|
||
let verifie = {};
|
||
let motVault = '';
|
||
let modaleResolveur = null;
|
||
let intrants = null;
|
||
let integrationsDispo = [];
|
||
let integrationsUniv = {};
|
||
let liensAcceptes = {};
|
||
let sondes = {}; // état RÉEL par hôte : joignable (SSH) ou non — distinct du plan
|
||
let chronoConsole = null;
|
||
let consoleDebut = 0;
|
||
|
||
let toastSeq = 0;
|
||
function message(texte, type='', opts={}) {
|
||
const conteneur = document.getElementById('toasts');
|
||
if (!conteneur) return null;
|
||
const t = document.createElement('div');
|
||
t.className = 'toast' + (type ? ' ' + type : '');
|
||
t.id = 'toast-' + (++toastSeq);
|
||
const corps = document.createElement('span');
|
||
corps.textContent = texte;
|
||
t.appendChild(corps);
|
||
const x = document.createElement('button');
|
||
x.type = 'button'; x.className = 'x'; x.textContent = '×';
|
||
x.onclick = () => retirerToast(t);
|
||
t.appendChild(x);
|
||
conteneur.appendChild(t);
|
||
while (conteneur.children.length > 6) conteneur.removeChild(conteneur.firstChild);
|
||
const duree = opts.persistant ? 0 : (opts.duree != null ? opts.duree : (type === 'erreur' ? 7000 : 3500));
|
||
if (duree > 0) setTimeout(() => retirerToast(t), duree);
|
||
return t; // renvoie le nœud pour permettre de le mettre à jour (opérations longues)
|
||
}
|
||
function majToast(t, texte, type, dureeFin) {
|
||
if (!t) return;
|
||
if (type !== undefined) t.className = 'toast' + (type ? ' ' + type : '');
|
||
const corps = t.querySelector('span'); if (corps) corps.textContent = texte;
|
||
if (dureeFin) setTimeout(() => retirerToast(t), dureeFin);
|
||
}
|
||
function retirerToast(t) {
|
||
if (!t || !t.parentNode) return;
|
||
t.classList.add('sortant');
|
||
setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 260);
|
||
}
|
||
|
||
let contexte = null;
|
||
|
||
// LE MEME PARTAGE QUE `POUVOIR_REQUIS` COTE SERVEUR, ecrit une fois ici pour la page.
|
||
// Ce n'est pas la garde — la garde refuse au serveur — c'est ce qui evite d'aller au
|
||
// refus pour l'apprendre. P81 verifie que les quatre modes y figurent encore.
|
||
const POUVOIR_PAR_MODE = {creer: 'materialiser', verifier: 'configurer',
|
||
deployer: 'configurer', pousser: 'configurer'};
|
||
// SANS CONTEXTE CONNU, ON NE RETIRE RIEN. Une console qui griserait ses boutons parce
|
||
// qu'elle n'a pas su lire sa portée serait pire que le défaut qu'on corrige : elle
|
||
// empêcherait un geste légitime sans rien expliquer. Le serveur, lui, refuse quand
|
||
// même — c'est lui la garde.
|
||
function peut(pouvoir) { return !contexte || !contexte.peut || !!contexte.peut[pouvoir]; }
|
||
function pourquoiPas(pouvoir) {
|
||
return (contexte && contexte.raison && contexte.raison[pouvoir]) || '';
|
||
}
|
||
|
||
// LA CONSOLE DIT CE QU'ELLE EST. Trois consoles identiques à trois URL différentes
|
||
// sont un piège pour qui en ouvre deux : celle d'un site et celle d'un locataire ne
|
||
// peuvent pas les mêmes gestes, et rien ne le disait.
|
||
function appliquerContexte(ctx) {
|
||
// ON VERIFIE LA FORME AVANT DE L'ADOPTER (mesuré le 2026-09-16, par le banc de
|
||
// rendu). Ma première version prenait tout ce qu'on lui donnait : une réponse qui
|
||
// n'était pas un contexte — celle d'une autre route — passait, puis `peut()` lisait
|
||
// `contexte.peut[...]` sur `undefined` et la page mourait d'un TypeError à
|
||
// l'ouverture. `node --check` n'y voyait rien : la syntaxe était juste.
|
||
if (!ctx || typeof ctx.peut !== 'object' || typeof ctx.portee !== 'string') return;
|
||
contexte = ctx;
|
||
const badge = document.getElementById('portee');
|
||
if (!badge) return;
|
||
// LES COULEURS DU THEME, PAS LES MIENNES (corrige le 2026-09-16). Ma premiere
|
||
// version posait un texte sombre sur un fond clair — illisible sur une console qui
|
||
// est en thème sombre. Vu sur une capture, pas dans le code : une couleur ne se
|
||
// relit pas, elle se regarde.
|
||
const couleurs = {
|
||
site: ['#e0a83a', 'rgba(185, 134, 11, .18)'],
|
||
tenant: ['var(--teal)', 'var(--surface-3)'],
|
||
poste: ['var(--muted)', 'var(--surface-3)'],
|
||
orphelin: ['var(--rouge)', 'var(--surface-3)'],
|
||
};
|
||
const [fg, bg] = couleurs[ctx.portee] || couleurs.poste;
|
||
badge.textContent = ctx.titre;
|
||
badge.style.color = fg;
|
||
badge.style.background = bg;
|
||
document.body.dataset.portee = ctx.portee;
|
||
// Ce que la portée interdit ne se propose pas. Le serveur refuse de toute façon
|
||
// (POUVOIR_REQUIS) ; ceci évite d'aller au refus pour l'apprendre.
|
||
const verrou = (el, permis, pourquoi) => {
|
||
if (!el) return;
|
||
el.disabled = !permis;
|
||
el.title = permis ? (el.title || '') : pourquoi;
|
||
el.style.opacity = permis ? '' : '.45';
|
||
};
|
||
verrou(document.getElementById('btn-sauver'), ctx.peut.editer_le_plan,
|
||
ctx.raison.editer_le_plan);
|
||
document.querySelectorAll('button[onclick^="ouvrirIntrants"]').forEach(
|
||
b => verrou(b, ctx.peut.editer_le_plan, ctx.raison.editer_le_plan));
|
||
}
|
||
|
||
async function charger() {
|
||
try {
|
||
const ctxRep = await fetch('/api/contexte');
|
||
if (ctxRep.ok) appliquerContexte(await ctxRep.json());
|
||
} catch (e) { /* une console sans contexte reste utilisable, en aveugle */ }
|
||
const reponse = await fetch('/api/inventaire');
|
||
const data = await reponse.json();
|
||
if (data.contexte) appliquerContexte(data.contexte);
|
||
groupes = data.groupes;
|
||
dependances = data.dependances || {};
|
||
nomenclature = data.nomenclature || {};
|
||
schemaPlan = data.schema || {};
|
||
chaine = data.chaine || {};
|
||
fluxRegistre = data.flux || [];
|
||
couchesRegistre = data.couches || [];
|
||
intrants = data.intrants || null;
|
||
integrationsDispo = data.integrations_disponibles || [];
|
||
integrationsUniv = data.integrations_universelles || {};
|
||
liensAcceptes = data.liens_acceptes || {};
|
||
chargerBases(data);
|
||
chargerApplications(data);
|
||
chargerDomaines(data);
|
||
chargerNomEdit(data);
|
||
chargerServeurs(data);
|
||
hotes = data.hotes;
|
||
estProduction = !!data.production;
|
||
verifie = {};
|
||
document.getElementById('chemin').textContent = data.inventaire;
|
||
if (selServeur == null || selServeur >= serveurs.length) selServeur = serveurs.length ? 0 : null;
|
||
dessiner();
|
||
message('Inventaire chargé.', 'ok');
|
||
sonder(); // état réel (joignabilité) en arrière-plan, non bloquant
|
||
}
|
||
|
||
async function sonder() {
|
||
try {
|
||
const r = await fetch('/api/sondes');
|
||
if (r.ok) { sondes = await r.json(); dessiner(); }
|
||
} catch (e) { /* silencieux */ }
|
||
}
|
||
|
||
function setOnglet(o) { ongletActif = o; dessinerDetail(); }
|
||
function filtrer(valeur) { filtre = (valeur || '').trim().toLowerCase(); dessinerGrilles(); }
|
||
|
||
function groupesActifs() { const a = new Set(); hotes.forEach(h => { if (h.etat === 'actif') (h.groupes || []).forEach(g => a.add(g)); }); return a; }
|
||
function groupesPlanifies() { const p = new Set(); hotes.forEach(h => (h.groupes || []).forEach(g => p.add(g))); return p; }
|
||
function statutDependance(groupe) {
|
||
const requis = ((dependances[groupe] || {}).requiert_groupes_actifs || []);
|
||
if (!requis.length) return 'neutre';
|
||
const actifs = groupesActifs(), planifies = groupesPlanifies();
|
||
const manquants = requis.filter(i => !actifs.has(i));
|
||
if (!manquants.length) return 'ok';
|
||
if (manquants.every(i => planifies.has(i))) return 'attente';
|
||
return 'bloque';
|
||
}
|
||
function manquantsPourGroupe(groupe) { const a = groupesActifs(); return ((dependances[groupe] || {}).requiert_groupes_actifs || []).filter(i => !a.has(i)); }
|
||
function hoteBloque(h) { if (!h || h.etat !== 'actif') return false; return (h.groupes || []).some(g => manquantsPourGroupe(g).length); }
|
||
function libelleStatut(s) { return {ok:'actif', attente:'planifié', bloque:'bloqué', neutre:'libre'}[s] || s; }
|
||
|
||
function dessiner() { dessinerResume(); dessinerGrilles(); dessinerDependances(); dessinerDetail(); majIndicateurSauvegarde(); }
|
||
|
||
function majIndicateurSauvegarde() {
|
||
const b = document.getElementById('btn-sauver');
|
||
if (!b) return;
|
||
const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'integrations' || vuePrincipale === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines' || vuePrincipale === 'nomenclature';
|
||
const sale = ((vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') && serveursModifie)
|
||
|| (vuePrincipale === 'applications' && applicationsModifie)
|
||
|| (vuePrincipale === 'bases' && basesModifie)
|
||
|| (vuePrincipale === 'domaines' && domainesModifie)
|
||
|| (vuePrincipale === 'nomenclature' && nomModifie);
|
||
b.classList.toggle('attention', sale);
|
||
b.textContent = sale ? 'Sauvegarder •' : 'Sauvegarder';
|
||
b.disabled = !editable;
|
||
b.title = editable ? 'Sauvegarder cette vue (Ctrl+S)' : 'Lecture seule — édite dans Serveurs / Applications / Bases';
|
||
}
|
||
|
||
function dessinerResume() {
|
||
const actifs = hotes.filter(h => h.etat === 'actif').length;
|
||
const bloques = hotes.filter(h => hoteBloque(h)).length;
|
||
const vivantes = hotes.filter(h => sondes[h.nom] === true).length;
|
||
const sonde = Object.keys(sondes).length > 0;
|
||
document.getElementById('resume').innerHTML = `
|
||
${estProduction ? '<span class="badge-prod" title="Instance de PRODUCTION : déploiement sous confirmation renforcée">PROD</span>' : '<span class="badge-lab" title="Instance bac à sable (setops_production: false)">bac à sable</span>'}
|
||
<span class="chip"><b>${hotes.length}</b> hôtes</span>
|
||
<span class="chip"><span class="pt actif"></span><b>${actifs}</b> actifs</span>
|
||
<span class="chip"><span class="pt plan"></span><b>${hotes.length - actifs}</b> planifiés</span>
|
||
${bloques ? `<span class="chip"><span class="pt bloq"></span><b>${bloques}</b> bloqués</span>` : ''}
|
||
${sonde ? `<span class="chip" title="État RÉEL : VM joignables (sonde SSH), indépendamment du plan"><span class="pt actif"></span><b>${vivantes}</b> vivantes</span>` : ''}`;
|
||
}
|
||
|
||
function correspondServeur(s) {
|
||
if (!filtre) return true;
|
||
return [s.nom, s.fonction, s.adresse_ip, s.vmid, s.etat].join(' ').toLowerCase().includes(filtre);
|
||
}
|
||
|
||
function setVue(v) {
|
||
vuePrincipale = v;
|
||
if (v === 'serveurs' && selServeur == null && serveurs.length) selServeur = 0;
|
||
if (v === 'applications' && selApp == null && applications.length) selApp = 0;
|
||
if (v === 'bases' && selBase == null) selBase = bdServeurs.length ? 'srv:0' : (bdApplis.length ? 'bd:0' : null);
|
||
document.getElementById('btn-vue-serveurs').classList.toggle('on', v === 'serveurs');
|
||
document.getElementById('btn-vue-applications').classList.toggle('on', v === 'applications');
|
||
document.getElementById('btn-vue-bases').classList.toggle('on', v === 'bases');
|
||
document.getElementById('btn-vue-domaines').classList.toggle('on', v === 'domaines');
|
||
document.getElementById('btn-vue-integrations').classList.toggle('on', v === 'integrations');
|
||
document.getElementById('btn-vue-flux').classList.toggle('on', v === 'flux');
|
||
document.getElementById('btn-vue-couches').classList.toggle('on', v === 'couches');
|
||
document.getElementById('btn-vue-reseau').classList.toggle('on', v === 'reseau');
|
||
document.getElementById('btn-vue-assistants').classList.toggle('on', v === 'assistants');
|
||
dessinerGrilles();
|
||
dessinerDetail(); // le panneau droit reflète la sélection de la vue courante
|
||
majIndicateurSauvegarde();
|
||
}
|
||
|
||
const BANNIERE_MODELE = `<details class="aide-modele">
|
||
<summary>Comment lire ce parc — serveur · hôte · groupe · application · base</summary>
|
||
<div class="aide-corps">
|
||
<p><b>Serveur</b> (ce que tu écris ici) → <b>« Appliquer le plan »</b> → <b>Hôte</b> (la VM générée). C'est le même objet, à deux étapes.</p>
|
||
<ul>
|
||
<li><b>Serveur / Hôte</b> — une VM ; elle <b>appartient à des groupes</b>.</li>
|
||
<li><b>Groupe</b> — un rôle appliqué à l'hôte (ex. <span class="mono">serveur_nginx</span>, <span class="mono">client_dns</span>).</li>
|
||
<li><b>Application</b> — un service qui <b>tourne sur</b> un hôte, <b>via</b> un groupe, et <b>expose</b> un FQDN.</li>
|
||
<li><b>Base</b> — une donnée <b>hébergée sur</b> un hôte et <b>consommée par</b> une application.</li>
|
||
</ul>
|
||
<p class="aide-ro">Saisis à <b>gauche</b> (tuiles), édite à <b>droite</b> ; les dérivés (VMID/IP/VLAN/groupes) se calculent à « Appliquer le plan ».</p>
|
||
</div></details>`;
|
||
|
||
function dessinerGrilles() {
|
||
if (vuePrincipale === 'applications') { dessinerApplications(); return; }
|
||
if (vuePrincipale === 'bases') { dessinerBases(); return; }
|
||
if (vuePrincipale === 'domaines') { dessinerDomaines(); return; }
|
||
if (vuePrincipale === 'nomenclature') { dessinerNomenclature(); return; }
|
||
if (vuePrincipale === 'integrations') { dessinerIntegrations(); return; }
|
||
if (vuePrincipale === 'flux') { dessinerFlux(); return; }
|
||
if (vuePrincipale === 'couches') { dessinerCouches(); return; }
|
||
if (vuePrincipale === 'reseau') { dessinerReseau(); return; }
|
||
if (vuePrincipale === 'assistants') { dessinerAssistants(); return; }
|
||
dessinerServeurs();
|
||
}
|
||
|
||
function legendeLecture() {
|
||
const d = document.getElementById('detail');
|
||
if (vuePrincipale === 'flux') {
|
||
d.innerHTML = `<div class="detail"><div class="section-tete">Chiffrement</div>
|
||
<div class="legende">
|
||
<div><span class="chip chi-tls-requis">tls-requis</span> chiffré + pair vérifié</div>
|
||
<div><span class="chip chi-tls">tls</span> chiffré</div>
|
||
<div><span class="chip chi-starttls">starttls</span> mise à niveau opportuniste</div>
|
||
<div><span class="chip chi-ssh">ssh</span> transport SSH</div>
|
||
<div><span class="chip chi-tls-cible">tls-cible</span> TLS visé (roadmap)</div>
|
||
<div><span class="chip chi-clair">clair</span> non chiffré (local / edge)</div></div>
|
||
<div class="hint-dep" style="margin-top:10px">Lecture seule. Éditer les <code>meta/flux.yml</code> des rôles, puis <code>make flux</code>.</div></div>`;
|
||
} else if (vuePrincipale === 'reseau') {
|
||
d.innerHTML = `<div class="detail"><div class="section-tete">Réseau & fédération</div>
|
||
<div class="hint-dep">Chaque instance fédérée dérive son adressage de son seed <code>index</code> (VLAN/VNI = 1000 + index×10 + zone, unique dans la fédération). La vue liste la flotte, signale toute collision d'index, et génère les devis d'infrastructure : commutateurs (<code>make devis-reseau</code>), frontière nord/sud (<code>make devis-opnsense</code>), SDN EVPN (<code>make devis-sdn</code>) et pare-feu est-ouest Proxmox (<code>make devis-proxmox-fw</code>).</div>
|
||
<div class="hint-dep" style="margin-top:10px">Lecture seule, régénérés du plan — <code>make devis-reseau</code> / <code>make devis-opnsense</code>. Le <strong>dialecte de CLI</strong> et le <strong>mode de routage</strong> (commutateur ou SDN EVPN) se règlent dans la section <em>Fabric</em> du panneau « Intrants de base ». En mode SDN, les commutateurs ne portent ni VLAN tenant, ni SVI, ni ACL : le routage et le filtrage inter-zone vivent sur les hyperviseurs.</div>
|
||
<div class="hint-dep" style="margin-top:10px">Ce qui reste à nommer à la main : les <strong>ports physiques</strong> (<code><PORT-VERS-…></code>) et, en SDN, le <strong>nœud de sortie EVPN</strong>. Rien d'autre — adresses, VLAN et routes se dérivent.</div></div>`;
|
||
} else {
|
||
d.innerHTML = `<div class="detail"><div class="section-tete">Reconstruction</div>
|
||
<div class="hint-dep">L'ordre part du socle et remonte : chaque couche suppose la précédente debout. Le tri intra-couche vient du graphe des dépendances.</div>
|
||
<div class="hint-dep" style="margin-top:10px">Lecture seule. Éditer <code>docs/couches-deploiement.yml</code> ; généré dans <code>playbooks/site.yml</code> par <code>make site</code>.</div>
|
||
<div class="hint-dep" style="margin-top:10px"><strong>Vérifier ce qui a été remonté.</strong> Un déploiement vert ne dit pas que le service rend son service. Les <strong>devis de service</strong> lisent le système en marche et le comparent à ce que le plan dérive — ils n'écrivent rien et sortent en erreur s'il y a un écart :
|
||
<code>make identite-plan</code>, <code>make certificats-plan</code>, <code>make expositions-plan</code>, <code>make postgresql-plan</code>, <code>make courriel-plan</code>.
|
||
Détail dans <code>docs/devis-services.md</code>.</div></div>`;
|
||
}
|
||
}
|
||
|
||
function dessinerFlux() {
|
||
const cible = document.getElementById('grilles');
|
||
if (!fluxRegistre.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun flux (meta/flux.yml).</div>'; return; }
|
||
const lignes = fluxRegistre.map(f => `<tr>
|
||
<td class="mono">${echapper(f.role)}</td>
|
||
<td><span class="sens sens-${echapper(f.sens)}">${echapper(f.sens)}</span></td>
|
||
<td class="mono">${echapper(f.port)}/${echapper(f.protocole)}</td>
|
||
<td class="mono">${echapper(f.pair)}</td>
|
||
<td><span class="chip chi-${echapper(f.chiffrement)}">${echapper(f.chiffrement)}</span></td>
|
||
<td class="flux-raison">${echapper(f.raison)}</td></tr>`).join('');
|
||
cible.innerHTML = `<div class="section-grille">
|
||
<div class="section-tete">Flux réseau — matrice d'audit <span class="compte">${fluxRegistre.length}</span>
|
||
<span class="hint-dep" style="margin-left:auto">ingress = le rôle écoute · egress = il se connecte · source de nftables + audit</span></div>
|
||
<table class="tbl-lecture"><thead><tr><th>Rôle</th><th>Sens</th><th>Port</th><th>Pair</th><th>Chiffrement</th><th>Raison</th></tr></thead>
|
||
<tbody>${lignes}</tbody></table></div>`;
|
||
}
|
||
|
||
let devisReseauTexte = '';
|
||
let devisFrontiereTexte = '';
|
||
function tableauFlotte(inst, coll, fmodeles, findexPris) {
|
||
if (!inst.length) return '<div class="vide" style="padding:14px">Aucune instance découverte (dépôts frères avec plan/nomenclature.yml).</div>';
|
||
const banniere = Object.keys(coll || {}).length
|
||
? `<div class="hint-dep" style="color:var(--danger);border:1px solid var(--danger);border-radius:8px;padding:8px 10px;margin-bottom:8px">⚠ COLLISION d'index entre instances fédérées : ${Object.entries(coll).map(([i, n]) => 'index ' + i + ' → ' + n.map(echapper).join(', ')).join(' ; ')} — mêmes VLAN/VMID sur le trunk.</div>`
|
||
: '';
|
||
const lignes = inst.map(i => {
|
||
const vlan = i.vlans ? (i.vlans[0] + '-' + i.vlans[1]) : '—';
|
||
const fed = i.federe ? '<span class="chip">fédérée</span>' : '<span class="chip" style="opacity:.6">local</span>';
|
||
const prod = i.production ? '<span class="chip chi-tls-requis">prod</span>' : '';
|
||
const action = i.actif
|
||
? '<span class="chip chi-tls-requis">active</span>'
|
||
: `<button type="button" class="fantome"${peut('editer_le_plan') ? ''
|
||
: ` disabled title="${echapper(pourquoiPas('editer_le_plan'))}" style="opacity:.45"`} onclick="basculerInstance('${echapper(i.nom)}', ${i.production ? 'true' : 'false'})">Activer</button>`;
|
||
return `<tr>
|
||
<td class="mono">${i.actif ? '★ ' : ''}${echapper(i.nom)}</td>
|
||
<td class="mono">${i.index}</td>
|
||
<td class="mono">${vlan}</td>
|
||
<td>${fed} ${prod}</td>
|
||
<td>${action}</td></tr>`;
|
||
}).join('');
|
||
const optModeles = (fmodeles || []).map(m => `<option value="${echapper(m)}">${echapper(m)}</option>`).join('');
|
||
const creer = (fmodeles || []).length ? `
|
||
<div class="section-tete" style="margin-top:14px">Créer une instance depuis un modèle</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end">
|
||
<label class="champ" style="max-width:14rem"><span>Modèle</span><select id="ci-modele">${optModeles}</select></label>
|
||
<label class="champ" style="max-width:12rem"><span>Nom (dépôt frère)</span><input id="ci-nom" placeholder="OPS-ClientX"></label>
|
||
<label class="champ" style="max-width:9rem"><span>Index</span><input id="ci-index" type="number" placeholder="libre" title="Index unique ; ${(findexPris||[]).length ? 'déjà pris : ' + (findexPris||[]).join(', ') : 'aucun pris'}"></label>
|
||
<button type="button" class="primaire"${peut('editer_le_plan') ? ''
|
||
: ` disabled title="${echapper(pourquoiPas('editer_le_plan'))}" style="opacity:.45"`} onclick="creerInstance()">Créer</button>
|
||
</div>
|
||
<div class="hint-dep" style="margin-top:4px">Copie le modèle vers <code>../<nom></code> et fixe l'index. Ne bascule pas : reste à renseigner intrants + voûte, puis « Activer ».${(findexPris||[]).length ? ' Index déjà pris : ' + (findexPris||[]).join(', ') + '.' : ''}</div>` : '';
|
||
return `${banniere}<table class="tbl-lecture"><thead><tr><th>Instance</th><th>Index</th><th>VLAN</th><th>Statut</th><th>Active</th></tr></thead><tbody>${lignes}</tbody></table>
|
||
<div class="hint-dep" style="margin-top:6px">★ = active (symlink <code>instance</code>). « Activer » bascule toutes les vues et les déploiements vers cette instance.</div>
|
||
${creer}`;
|
||
}
|
||
async function creerInstance() {
|
||
const modele = (document.getElementById('ci-modele') || {}).value || '';
|
||
const nom = ((document.getElementById('ci-nom') || {}).value || '').trim();
|
||
const index = ((document.getElementById('ci-index') || {}).value || '').trim();
|
||
if (!nom) { message('Donne un nom de dépôt (ex. OPS-ClientX).', 'erreur'); return; }
|
||
if (!confirm(`Créer l'instance « ${nom} » depuis le modèle « ${modele} »${index ? ' (index ' + index + ')' : ''} ?\\n\\nCopie le modèle vers ../${nom}. L'instance active n'est PAS changée.`)) return;
|
||
try {
|
||
const rep = await fetch('/api/instance-creer', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
|
||
body: JSON.stringify({nom, modele, index})});
|
||
const data = await rep.json();
|
||
if (!rep.ok) { message(data.erreur || 'Création refusée.', 'erreur'); return; }
|
||
message('Instance « ' + nom + ' » créée. Renseigne intrants + voûte, puis « Activer ».', 'ok');
|
||
dessinerReseau();
|
||
} catch (e) { message('Erreur : ' + e.message, 'erreur'); }
|
||
}
|
||
async function basculerInstance(nom, estProd) {
|
||
const avert = estProd
|
||
? `\\n\\n⚠ « ${nom} » est une instance de PRODUCTION. Déployer y touchera l'infra réelle.`
|
||
: '';
|
||
if (!confirm(`Basculer l'instance active vers « ${nom} » ?\\n\\nToutes les vues et les déploiements viseront alors « ${nom} ».${avert}`)) return;
|
||
try {
|
||
const rep = await fetch('/api/instance-utiliser', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
|
||
body: JSON.stringify({nom})});
|
||
const data = await rep.json();
|
||
if (!rep.ok) { message(data.erreur || 'Bascule refusée.', 'erreur'); return; }
|
||
message('Instance active : ' + nom + ' — rechargement…', 'ok');
|
||
setTimeout(() => location.reload(), 500);
|
||
} catch (e) { message('Erreur : ' + e.message, 'erreur'); }
|
||
}
|
||
async function dessinerReseau() {
|
||
const cible = document.getElementById('grilles');
|
||
cible.innerHTML = '<div class="vide" style="padding:14px">Lecture de la fédération…</div>';
|
||
try {
|
||
const [ri, rd, rf] = await Promise.all([
|
||
fetch('/api/instances', {headers: {'X-Jeton': JETON}}),
|
||
fetch('/api/devis-reseau', {headers: {'X-Jeton': JETON}}),
|
||
fetch('/api/devis-opnsense', {headers: {'X-Jeton': JETON}}),
|
||
]);
|
||
const di = await ri.json(), data = await rd.json(), dfr = await rf.json();
|
||
if (!rd.ok) { cible.innerHTML = '<div class="vide" style="padding:14px">' + echapper(data.erreur || 'Indisponible.') + '</div>'; return; }
|
||
devisReseauTexte = data.config || '';
|
||
const flotte = `<div class="section-grille"><div class="section-tete">Flotte d'instances <span class="compte">${(di.instances || []).length}</span></div>${tableauFlotte(di.instances || [], di.collisions || {}, di.modeles || [], di.index_pris || [])}</div>`;
|
||
const devis = devisReseauTexte
|
||
? `<div class="section-grille" style="margin-top:12px"><div class="section-tete">Devis config réseau — switches (instances fédérées)
|
||
<button type="button" class="fantome" style="margin-left:auto" onclick="copierDevis()">Copier</button></div>
|
||
<pre class="devis-reseau">${echapper(devisReseauTexte)}</pre></div>`
|
||
: '<div class="hint-dep" style="margin-top:12px">Aucune instance fédérée : pas de devis convergé à générer.</div>';
|
||
devisFrontiereTexte = dfr.config || '';
|
||
const frontiere = devisFrontiereTexte
|
||
? `<div class="section-grille" style="margin-top:12px"><div class="section-tete">Devis config réseau — frontière nord/sud (OPNsense)
|
||
<button type="button" class="fantome" style="margin-left:auto" onclick="copierDevisFrontiere()">Copier</button></div>
|
||
<pre class="devis-reseau">${echapper(devisFrontiereTexte)}</pre></div>`
|
||
: `<div class="hint-dep" style="margin-top:12px">Devis frontière indisponible${dfr.erreur ? ' : ' + echapper(dfr.erreur) : ''}.</div>`;
|
||
// LA FABRIC APPARTIENT A L'HEBERGEUR, ET LE DIRE CHANGE CE QU'ON LIT. Chez un
|
||
// locataire, ces devis sont calcules depuis une COPIE locale de la carte du
|
||
// cluster — et une copie avait deja diverge : deux listes de stockages
|
||
// contradictoires pour le meme materiel. Le panneau reste visible (l'omettre
|
||
// ferait croire que la fabric n'existe pas) ; il cesse d'etre lu comme une source.
|
||
const avisFabric = peut('fabric') ? '' :
|
||
`<div class="note-fabric" style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #b9860b;background:color-mix(in srgb,#b9860b 12%,var(--surface));font-size:12.5px">
|
||
<b>Lecture seule — la fabric appartient à l'hébergeur.</b> ${echapper(pourquoiPas('fabric'))}
|
||
Cette console configure un écosystème ; elle ne pose ni VLAN, ni route, ni règle de frontière.</div>`;
|
||
cible.innerHTML = avisFabric + flotte + devis + frontiere;
|
||
} catch (e) {
|
||
cible.innerHTML = '<div class="vide" style="padding:14px">Erreur : ' + echapper(e.message) + '</div>';
|
||
}
|
||
}
|
||
function copierDevis() {
|
||
if (navigator.clipboard) navigator.clipboard.writeText(devisReseauTexte)
|
||
.then(() => message('Devis réseau copié — prêt à coller sur le switch.', 'ok'), () => {});
|
||
}
|
||
function copierDevisFrontiere() {
|
||
if (navigator.clipboard) navigator.clipboard.writeText(devisFrontiereTexte)
|
||
.then(() => message('Devis frontière copié.', 'ok'), () => {});
|
||
}
|
||
|
||
function dessinerCouches() {
|
||
const cible = document.getElementById('grilles');
|
||
if (!couchesRegistre.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Ordre de déploiement indisponible.</div>'; return; }
|
||
const blocs = couchesRegistre.map((c, i) => `<div class="couche">
|
||
<div class="couche-tete"><span class="couche-idx">${i}</span><b>${echapper(c.nom)}</b><span class="compte">${c.groupes.length}</span></div>
|
||
<div class="couche-groupes">${c.groupes.map(g => `<span class="chip mono">${echapper(g)}</span>`).join('')}</div></div>`
|
||
).join('<div class="couche-fleche">↓</div>');
|
||
cible.innerHTML = `<div class="section-grille">
|
||
<div class="section-tete">Ordre de déploiement — couches <span class="compte">${couchesRegistre.length}</span>
|
||
<span class="hint-dep" style="margin-left:auto">make deployer-tout / myDay déroule dans cet ordre · tri topo intra-couche</span></div>
|
||
<div class="couches-pile">${blocs}</div></div>`;
|
||
}
|
||
|
||
// --- Matrice serveurs x integrations -------------------------------------
|
||
// Pourquoi cette vue : la fiche de detail montre les integrations d'UN serveur.
|
||
// Savoir qui n'a pas de sauvegarde demandait donc d'ouvrir chaque fiche — et un
|
||
// trou (deux serveurs sans supervision) est reste invisible jusqu'a ce qu'un
|
||
// devis de pare-feu l'enumere. Ici il se lit dans une colonne.
|
||
function servicesRendus(nom) {
|
||
return new Set(applications.filter(a => a.hote === nom).map(a => a.groupe));
|
||
}
|
||
function estExempt(role, rendus) {
|
||
const p = integrationsUniv[role];
|
||
return !!(p && p.sauf_role && rendus.has(p.sauf_role));
|
||
}
|
||
function basculerIntegrationDe(i, role, actif) {
|
||
const s = serveurs[i]; if (!s) return;
|
||
const set = new Set(listeDepuisTexte(s.integrations));
|
||
if (actif) set.add(role); else set.delete(role);
|
||
s.integrations = Array.from(set).join(', ');
|
||
marquerServeursModifie(); dessinerIntegrations();
|
||
}
|
||
|
||
function dessinerIntegrations() {
|
||
const cible = document.getElementById('grilles');
|
||
const univ = Object.keys(integrationsUniv).sort();
|
||
// Colonnes facultatives : celles proposees + toute valeur deja presente dans le
|
||
// plan (un role retire de roles/ ne doit pas faire disparaitre sa colonne).
|
||
const dansPlan = new Set();
|
||
serveurs.forEach(s => listeDepuisTexte(s.integrations).forEach(r => dansPlan.add(r)));
|
||
const opt = [...new Set([...integrationsDispo, ...dansPlan])].filter(r => !integrationsUniv[r]).sort();
|
||
const colonnes = [...univ, ...opt];
|
||
const lignes = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s));
|
||
if (!colonnes.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun rôle client_* dans roles/.</div>'; return; }
|
||
if (!lignes.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun serveur ne correspond au filtre.</div>'; return; }
|
||
|
||
const couvert = {}; colonnes.forEach(r => couvert[r] = 0);
|
||
const corps = lignes.map(([s, i]) => {
|
||
const rendus = servicesRendus(s.nom);
|
||
const set = new Set(listeDepuisTexte(s.integrations));
|
||
const cellules = colonnes.map(role => {
|
||
if (integrationsUniv[role]) {
|
||
if (estExempt(role, rendus)) {
|
||
return `<td class="mx-c mx-exempt" title="Exempté : cet hôte rend ${echapper(integrationsUniv[role].sauf_role)} — il ne s'intègre pas à lui-même">—</td>`;
|
||
}
|
||
couvert[role]++;
|
||
return `<td class="mx-c mx-univ" title="${echapper(integrationsUniv[role].raison || '')}">✓</td>`;
|
||
}
|
||
if (set.has(role)) couvert[role]++;
|
||
return `<td class="mx-c"><input type="checkbox" ${set.has(role) ? 'checked' : ''}`
|
||
+ ` onchange="basculerIntegrationDe(${i}, '${echapper(role)}', this.checked)"`
|
||
+ ` title="${echapper(role)} sur ${echapper(s.nom)}"></td>`;
|
||
}).join('');
|
||
const sel = i === selServeur ? ' class="mx-sel"' : '';
|
||
return `<tr${sel}><th class="mx-h" onclick="setVue('serveurs'); choisirServeur(${i})" title="Ouvrir la fiche de ${echapper(s.nom)}">`
|
||
+ `${echapper(s.nom || '(sans nom)')}<span class="mx-fn">${echapper(s.fonction || '')}</span></th>${cellules}</tr>`;
|
||
}).join('');
|
||
|
||
const n = lignes.length;
|
||
const tetes = colonnes.map(role => {
|
||
const u = !!integrationsUniv[role];
|
||
return `<th class="mx-t${u ? ' mx-t-univ' : ''}" title="${u ? 'Politique du rôle — s\'applique à tout hôte' : 'Facultative — un choix, serveur par serveur'}">`
|
||
+ `<span>${echapper(role.replace(/^client_/, ''))}</span></th>`;
|
||
}).join('');
|
||
const pieds = colonnes.map(role => {
|
||
const c = couvert[role];
|
||
const cls = integrationsUniv[role] ? 'mx-p mx-univ' : (c === n ? 'mx-p mx-plein' : 'mx-p');
|
||
return `<td class="${cls}">${c}/${n}</td>`;
|
||
}).join('');
|
||
|
||
cible.innerHTML = `<div class="section-grille">
|
||
<div class="section-tete">Intégrations — matrice <span class="compte">${n} × ${colonnes.length}</span>
|
||
<span class="hint-dep" style="margin-left:auto">Les colonnes ✓ sont la politique des rôles ; les cases sont les choix, éditables ici</span></div>
|
||
<div class="mx-cadre"><table class="mx">
|
||
<thead><tr><th class="mx-t mx-coin">serveur</th>${tetes}</tr></thead>
|
||
<tbody>${corps}</tbody>
|
||
<tfoot><tr><th class="mx-h mx-coin">couverture</th>${pieds}</tr></tfoot>
|
||
</table></div></div>`;
|
||
}
|
||
|
||
function legendeIntegrations() {
|
||
const univ = Object.entries(integrationsUniv).sort();
|
||
const lignes = univ.map(([role, p]) => `<div class="mx-leg"><span class="mx-univ">✓</span> <code>${echapper(role)}</code>
|
||
<div class="hint-dep">${echapper(p.raison || '')}${p.sauf_role ? ` <b>Exemption</b> : l'hôte qui rend <code>${echapper(p.sauf_role)}</code>.` : ''}</div></div>`).join('');
|
||
document.getElementById('detail').innerHTML = `<div class="detail">
|
||
<div class="section-tete">Deux genres d'intégration</div>
|
||
<div class="hint-dep"><b>Universelle</b> — aucun choix de cible : un seul Prometheus, un seul Loki, une seule AC.
|
||
Déclarée <b>une fois par le rôle</b> (<code>roles/<rôle>/meta/integration.yml</code>), elle s'applique à tout hôte
|
||
et ne se décoche pas. Le plan <b>refuse</b> de la redire : deux sources finiraient par diverger.</div>
|
||
${lignes}
|
||
<div class="hint-dep" style="margin-top:12px"><b>Facultative</b> — un vrai choix, serveur par serveur, écrit dans
|
||
<code>plan/serveurs.yml</code>. Coche dans la matrice, puis <b>Sauvegarder</b>.</div>
|
||
<div class="hint-dep" style="margin-top:12px">La ligne <b>couverture</b> ne juge pas : une colonne à <code>7/14</code>
|
||
peut être exactement juste. Elle rend le motif <em>visible</em> — c'est au lecteur de savoir si les sept manquants
|
||
sont des décisions ou des oublis.</div></div>`;
|
||
}
|
||
|
||
function basesDeApp(app, hoteNom) {
|
||
return bdApplis.filter(a => {
|
||
const p = a.portee || 'groupe';
|
||
return (p === 'application' && a.consommateur === app.id)
|
||
|| (p === 'groupe' && a.consommateur === app.groupe)
|
||
|| (p === 'hote' && a.consommateur === hoteNom);
|
||
});
|
||
}
|
||
|
||
function chargerBases(data) {
|
||
const b = data.bases || {serveurs_bd: {}, bases_donnees: {}};
|
||
bdServeurs = Object.entries(b.serveurs_bd || {}).map(([nom, s]) => ({
|
||
nom, type: s.type || 'postgres', hote: s.hote || '', port: s.port || 5432, groupe: s.groupe || ''}));
|
||
bdApplis = Object.entries(b.bases_donnees || {}).map(([cle, x]) => ({
|
||
cle, serveur: x.serveur || '', base: x.base || '', proprietaire: x.proprietaire || '', secret: x.secret || '',
|
||
consommateur: x.consommateur || '', portee: x.portee || 'groupe', usage: x.usage || 'principale'}));
|
||
basesModifie = false;
|
||
}
|
||
|
||
function chargerDomaines(data) {
|
||
// Les valeurs restent DE LEUR TYPE (les listes restent des listes) : le formulaire
|
||
// est genere depuis le schema, qui sait deja qu'une liste se saisit par virgules.
|
||
// L'ancien chargement les aplatissait en texte, et la sauvegarde devait deviner
|
||
// comment les reconstruire.
|
||
const dp = ((data.domaines || {}).domaines_publics) || {};
|
||
domaines = Object.entries(dp).map(([nom, x]) => Object.assign({nom}, x));
|
||
domainesModifie = false;
|
||
}
|
||
function marquerDomainesModifie() { domainesModifie = true; majIndicateurSauvegarde(); }
|
||
function choisirDomaine(i) { selDomaine = i; dessiner(); }
|
||
function definirDomaine(champ, v) {
|
||
const d = domaines[selDomaine]; if (!d) return;
|
||
poserChemin(d, champ, v); marquerDomainesModifie();
|
||
if (champ === 'nom') { const t = document.querySelector('#tuile-dom-' + selDomaine + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
|
||
else dessinerDetail();
|
||
}
|
||
function ajouterDomaine() {
|
||
const edgeDefaut = groupes.includes('serveur_nginx') ? 'serveur_nginx' : (groupes[0] || '');
|
||
domaines.unshift({nom: '', autorite: 'auto-heberge', edge: edgeDefaut, dnssec: false, secondaires: []});
|
||
selDomaine = 0; marquerDomainesModifie(); dessiner();
|
||
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
|
||
}
|
||
function retirerDomaine() {
|
||
const d = domaines[selDomaine]; if (!d) return;
|
||
if (d.nom && !confirm('Retirer le domaine « ' + d.nom + ' » ?')) return;
|
||
domaines.splice(selDomaine, 1);
|
||
selDomaine = domaines.length ? Math.min(selDomaine, domaines.length - 1) : null;
|
||
marquerDomainesModifie(); dessiner();
|
||
}
|
||
function carteDomaine(d, i) {
|
||
return `<button type="button" class="carte ${i === selDomaine ? 'selectionnee' : ''}" id="tuile-dom-${i}" onclick="choisirDomaine(${i})">
|
||
<div class="carte-haut"><span class="badge-bd">zone</span>${d.dnssec ? '<span class="carte-vmid">dnssec</span>' : ''}</div>
|
||
<div class="carte-nom ${d.nom ? '' : 'sans'}">${echapper(d.nom || '(sans nom)')}</div>
|
||
<div class="carte-ip">${echapper(d.autorite || '—')}</div>
|
||
</button>`;
|
||
}
|
||
// ---- VUE NOMENCLATURE ---------------------------------------------------------
|
||
//
|
||
// Le dernier registre que le GUI ne savait pas ecrire du tout (2026-09-08). Il n'est
|
||
// pas un registre comme les autres : il ne decrit pas des objets, il decrit la REGLE
|
||
// dont VMID, VLAN, adresse et passerelle se derivent. D'ou deux partis pris :
|
||
//
|
||
// - `index` est montre mais PAS editable ici. Le fichier le dit lui-meme : il est
|
||
// RECU du site, pas decide par le tenant, et `make underlay` refuse tout ecart.
|
||
// Il s'ecrit par le panneau Intrants, qui est le chemin de l'intrant recu.
|
||
// - chaque fonction affiche CE QU'ELLE DERIVE (zone, VLAN, 3e octet) et combien de
|
||
// VM la portent : sans ca, changer un chiffre ici est un geste a l'aveugle.
|
||
|
||
function chargerNomEdit(data) {
|
||
// Copie de travail : editer `nomenclature` en place ferait suivre les indices
|
||
// derives de la vue Serveurs a des valeurs non encore enregistrees.
|
||
nomEdit = JSON.parse(JSON.stringify(data.nomenclature || {}));
|
||
nomModifie = false;
|
||
}
|
||
function marquerNomModifie() { nomModifie = true; majIndicateurSauvegarde(); }
|
||
function selNomInfo() { if (!selNom) return null; const i = selNom.indexOf(':'); return {kind: selNom.slice(0, i), cle: selNom.slice(i + 1)}; }
|
||
function choisirNom(k) { selNom = k; dessiner(); }
|
||
|
||
function definirNom(chemin, v) {
|
||
// UN SEUL setter pour toute la profondeur : le generateur transmet une clef
|
||
// POINTEE (`reservations.passerelle`, `fonctions.idm.service`).
|
||
const parts = String(chemin).split('.');
|
||
let o = nomEdit;
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
if (o[parts[i]] == null || typeof o[parts[i]] !== 'object') o[parts[i]] = {};
|
||
o = o[parts[i]];
|
||
}
|
||
const feuille = parts[parts.length - 1];
|
||
// Les entiers restent des ENTIERS : le plan est relu par le moteur, pas par un
|
||
// navigateur, et `categorie: "6"` ne derive pas la meme chose que `categorie: 6`.
|
||
o[feuille] = (v !== '' && v != null && String(v).match(/^-?\d+$/)) ? parseInt(v, 10) : v;
|
||
marquerNomModifie();
|
||
if (chemin.startsWith('fonctions.') || chemin.startsWith('categories.')) dessiner();
|
||
}
|
||
|
||
function renommerNom(table, ancienne, nouvelle) {
|
||
nouvelle = String(nouvelle || '').trim();
|
||
if (!nouvelle || nouvelle === ancienne) return;
|
||
const t = nomEdit[table] || {};
|
||
if (Object.prototype.hasOwnProperty.call(t, nouvelle)) { message('« ' + nouvelle + ' » existe déjà.', 'erreur'); return; }
|
||
// On preserve l'ORDRE : le fichier est lu par des humains, et un renommage ne doit
|
||
// pas remonter la clef en tete.
|
||
const refait = {};
|
||
Object.keys(t).forEach(k => { refait[k === ancienne ? nouvelle : k] = t[k]; });
|
||
nomEdit[table] = refait;
|
||
selNom = (table === 'categories' ? 'cat:' : 'fon:') + nouvelle;
|
||
marquerNomModifie(); dessiner();
|
||
}
|
||
|
||
function ajouterZone() {
|
||
const cats = nomEdit.categories || (nomEdit.categories = {});
|
||
// La prochaine zone LIBRE, pas « la derniere + 1 » : un trou se rebouche.
|
||
let n = 1; while (cats[n] || cats[String(n)]) n++;
|
||
cats[n] = {libelle: ''};
|
||
selNom = 'cat:' + n; marquerNomModifie(); dessiner();
|
||
}
|
||
function ajouterFonction() {
|
||
const f = nomEdit.fonctions || (nomEdit.fonctions = {});
|
||
let nom = 'nouvelle', i = 2; while (f[nom]) nom = 'nouvelle-' + (i++);
|
||
const premiereZone = Object.keys(nomEdit.categories || {})[0];
|
||
f[nom] = {categorie: premiereZone != null ? parseInt(premiereZone, 10) : 1, service: 1};
|
||
selNom = 'fon:' + nom; marquerNomModifie(); dessiner();
|
||
}
|
||
function retirerNom() {
|
||
const info = selNomInfo(); if (!info) return;
|
||
const table = info.kind === 'cat' ? 'categories' : 'fonctions';
|
||
if (info.kind === 'fon' && vmsPortant(info.cle).length) {
|
||
message('« ' + info.cle + ' » est encore portée par ' + vmsPortant(info.cle).length + ' VM.', 'erreur');
|
||
return;
|
||
}
|
||
if (info.kind === 'cat' && fonctionsDansZone(info.cle).length) {
|
||
message('Zone ' + info.cle + ' encore utilisée par ' + fonctionsDansZone(info.cle).length + ' fonction(s).', 'erreur');
|
||
return;
|
||
}
|
||
delete (nomEdit[table] || {})[info.cle];
|
||
selNom = null; marquerNomModifie(); dessiner();
|
||
}
|
||
|
||
function vmsPortant(fonction) { return hotes.filter(h => fonctionDe(h.nom).fonction === fonction); }
|
||
function fonctionsDansZone(zone) {
|
||
return Object.entries(nomEdit.fonctions || {}).filter(([, f]) => String(f.categorie) === String(zone)).map(([n]) => n);
|
||
}
|
||
function deriveDe(fonction) {
|
||
// Miroir de `inventory_rules.deriver_nomenclature`, cote ecran. Il ne DECIDE rien :
|
||
// il montre ce que le moteur derivera, pour que le chiffre saisi ait un sens visible.
|
||
const f = (nomEdit.fonctions || {})[fonction];
|
||
if (!f) return null;
|
||
const c = (nomEdit.categories || {})[f.categorie] || (nomEdit.categories || {})[String(f.categorie)];
|
||
const idx = (nomEdit.index != null) ? nomEdit.index : 9;
|
||
const cat = Number(f.categorie);
|
||
if (!c || isNaN(cat)) return null;
|
||
return {libelle: c.libelle || '?', vlan: 1000 + idx * 10 + cat,
|
||
base3: '10.' + idx + '.' + (15 + cat), bloc: (Number(f.service) * 10)};
|
||
}
|
||
|
||
function carteZone(cle) {
|
||
const c = (nomEdit.categories || {})[cle] || {};
|
||
const sel = selNom === 'cat:' + cle ? ' on' : '';
|
||
const n = fonctionsDansZone(cle).length;
|
||
return `<button type="button" class="carte${sel}" onclick="choisirNom('cat:${echapper(cle)}')">
|
||
<div class="carte-nom">${echapper(c.libelle || '(sans libellé)')}</div>
|
||
<div class="carte-ip">zone ${echapper(cle)} · ${n} fonction(s)</div></button>`;
|
||
}
|
||
function carteFonction(nom) {
|
||
const sel = selNom === 'fon:' + nom ? ' on' : '';
|
||
const d = deriveDe(nom);
|
||
const nb = vmsPortant(nom).length;
|
||
return `<button type="button" class="carte${sel}" onclick="choisirNom('fon:${echapper(nom)}')">
|
||
<div class="carte-nom">${echapper(nom)}</div>
|
||
<div class="carte-ip">${d ? echapper(d.libelle) + ' · VLAN ' + d.vlan : '<zone inconnue>'} · ${nb} VM</div></button>`;
|
||
}
|
||
|
||
function dessinerNomenclature() {
|
||
const cible = document.getElementById('grilles');
|
||
const reg = schemaPlan.nomenclature;
|
||
if (!reg) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucun schéma — lancer <code>make schema</code>.</div>'; return; }
|
||
const props = (reg.entite || {}).properties || {};
|
||
|
||
// Le seed, montre et NON editable ici (il vient du site).
|
||
const idx = nomEdit.index == null ? '—' : nomEdit.index;
|
||
const seed = `<label class="champ" title="${echapper((props.index || {}).description || '')}">
|
||
<span>${echapper((props.index || {}).title || 'Index')}</span>
|
||
<input value="${echapper(idx)}" disabled></label>
|
||
<div class="ch-stub" style="grid-column:1/-1">L'index est <b>alloué par le site</b>, pas décidé ici
|
||
(<code>make underlay</code> refuse tout écart). Il s'édite dans le panneau <b>Intrants</b>.</div>`;
|
||
const cidr = props.cidr_hote ? champDepuisSchema('cidr_hote', props.cidr_hote, nomEdit.cidr_hote, nomEdit, 'definirNom') : '';
|
||
const resv = props.reservations ? champDepuisSchema('reservations', props.reservations, nomEdit.reservations, nomEdit, 'definirNom') : '';
|
||
|
||
const zones = Object.keys(nomEdit.categories || {});
|
||
const fons = Object.keys(nomEdit.fonctions || {});
|
||
cible.innerHTML = `
|
||
<div class="section-grille">
|
||
<div class="section-tete">Modèle d'adressage</div>
|
||
<div class="grille">${seed}${cidr}${resv}</div>
|
||
</div>
|
||
<div class="section-grille">
|
||
<div class="section-tete">Zones de sécurité <span class="compte">${zones.length}</span>
|
||
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterZone()">+ Zone</button></div>
|
||
${zones.length ? `<div class="grille-cartes">${zones.map(carteZone).join('')}</div>`
|
||
: '<div class="vide" style="padding:14px">Aucune zone. Sans zone déclarée, aucune fonction ne dérive d\'adresse.</div>'}
|
||
</div>
|
||
<div class="section-grille">
|
||
<div class="section-tete">Fonctions <span class="compte">${fons.length}</span>
|
||
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterFonction()">+ Fonction</button></div>
|
||
${fons.length ? `<div class="grille-cartes">${fons.map(carteFonction).join('')}</div>`
|
||
: '<div class="vide" style="padding:14px">Aucune fonction déclarée.</div>'}
|
||
</div>`;
|
||
}
|
||
|
||
function inspecteurNomenclature() {
|
||
const cible = document.getElementById('detail');
|
||
const info = selNomInfo();
|
||
const reg = schemaPlan.nomenclature || {};
|
||
const props = (reg.entite || {}).properties || {};
|
||
if (!info) {
|
||
cible.innerHTML = '<div class="detail"><div class="vide"><b>Nomenclature</b>'
|
||
+ 'Le modèle dont tout l\'adressage dérive. Choisis une zone ou une fonction.</div></div>';
|
||
return;
|
||
}
|
||
const table = info.kind === 'cat' ? 'categories' : 'fonctions';
|
||
const entree = (nomEdit[table] || {})[info.cle];
|
||
if (!entree) { selNom = null; cible.innerHTML = ''; return; }
|
||
const specEntree = (props[table] || {}).additionalProperties || {};
|
||
const corps = sousFormulaireDepuisSchema(specEntree, entree, table + '.' + info.cle, 'definirNom');
|
||
|
||
let consequence = '';
|
||
if (info.kind === 'fon') {
|
||
const d = deriveDe(info.cle);
|
||
const vms = vmsPortant(info.cle);
|
||
consequence = `<div class="section-tete" style="margin:12px 2px 4px">Ce que cette fonction dérive</div>`
|
||
+ (d ? `<div class="intr-ro">Zone <b>${echapper(d.libelle)}</b> · VLAN <b>${d.vlan}</b>
|
||
· sous-réseau <b>${echapper(d.base3)}.0/${echapper(nomEdit.cidr_hote || 24)}</b>
|
||
· bloc d'hôtes <b>.${d.bloc}x</b></div>`
|
||
: '<div class="intr-ro">Zone inconnue — aucune adresse ne se dérive.</div>')
|
||
+ `<div class="section-tete" style="margin:12px 2px 4px">VM portant cette fonction <span class="compte">${vms.length}</span></div>`
|
||
+ (vms.length ? `<div class="ch-bases">${vms.map(h => `<div class="ch-base"><span class="mono">${echapper(h.nom)}</span></div>`).join('')}</div>`
|
||
: '<div class="ch-stub">aucune — cette fonction peut être retirée</div>');
|
||
} else {
|
||
const fs = fonctionsDansZone(info.cle);
|
||
consequence = `<div class="section-tete" style="margin:12px 2px 4px">Fonctions dans cette zone <span class="compte">${fs.length}</span></div>`
|
||
+ (fs.length ? `<div class="ch-bases">${fs.map(f => `<div class="ch-base"><span class="mono">${echapper(f)}</span></div>`).join('')}</div>`
|
||
: '<div class="ch-stub">aucune — cette zone peut être retirée</div>');
|
||
}
|
||
const libelleCle = info.kind === 'cat' ? 'Numéro de zone' : 'Nom de la fonction';
|
||
cible.innerHTML = `<article class="detail">
|
||
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(info.cle)}</span></div>
|
||
<span class="grandit"></span>
|
||
<button type="button" class="danger" title="Retirer" onclick="retirerNom()">✕</button></div>
|
||
<div class="grille">
|
||
<label class="champ" style="grid-column:1/-1"><span>${libelleCle}</span>
|
||
<input value="${echapper(info.cle)}" onchange="renommerNom('${table}', '${echapper(info.cle)}', this.value)"></label>
|
||
</div>
|
||
${corps}
|
||
${consequence}
|
||
</article>`;
|
||
}
|
||
|
||
async function sauvegarderNomenclature() {
|
||
const rep = await fetch('/api/nomenclature', {method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
|
||
body: JSON.stringify(nomEdit)});
|
||
const data2 = await rep.json();
|
||
if (!rep.ok) { message(data2.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
|
||
schemaPlan = data2.schema || schemaPlan;
|
||
nomenclature = data2.nomenclature || nomenclature;
|
||
chargerNomEdit(data2); dessiner();
|
||
message('Nomenclature sauvegardée.', 'ok');
|
||
}
|
||
|
||
function dessinerDomaines() {
|
||
const cible = document.getElementById('grilles');
|
||
const tuiles = domaines.length
|
||
? `<div class="grille-cartes">${domaines.map((d, i) => carteDomaine(d, i)).join('')}</div>`
|
||
: '<div class="vide" style="padding:14px">Aucun domaine. Une zone DNS = parent des expositions (FQDN) derrière l\'edge.</div>';
|
||
cible.innerHTML = `<div class="section-grille">
|
||
<div class="section-tete">Domaines <span class="compte">${domaines.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterDomaine()">+ Domaine</button></div>
|
||
${tuiles}</div>`;
|
||
}
|
||
function expositionsSous(zone) {
|
||
if (!zone) return [];
|
||
return applications.flatMap(a => listeDepuisTexte(a.expose)).filter(f => f === zone || f.endsWith('.' + zone));
|
||
}
|
||
function inspecteurDomaine() {
|
||
const cible = document.getElementById('detail');
|
||
if (selDomaine == null || !domaines[selDomaine]) {
|
||
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun domaine sélectionné</b>Choisis une tuile, ou ajoute un domaine.</div></div>';
|
||
return;
|
||
}
|
||
const d = domaines[selDomaine];
|
||
const fqdns = expositionsSous(d.nom);
|
||
const liens = fqdns.length
|
||
? `<div class="ch-bases">${fqdns.map(f => `<div class="ch-base"><span class="mono">${echapper(f)}</span></div>`).join('')}</div>`
|
||
: '<div class="ch-stub">aucune exposition sous cette zone</div>';
|
||
cible.innerHTML = `<article class="detail">
|
||
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(d.nom || '(sans nom)')}</span></div>
|
||
<span class="grandit"></span>
|
||
<button type="button" class="danger" title="Retirer ce domaine" onclick="retirerDomaine()">✕</button></div>
|
||
${formulaireDepuisSchema('domaines_publics', d, 'definirDomaine', d.nom, "definirDomaine.bind(null,'nom')")}
|
||
<div class="section-tete" style="margin:12px 2px 4px">Expositions sous cette zone</div>
|
||
${liens}
|
||
</article>`;
|
||
}
|
||
async function sauvegarderDomaines() {
|
||
// SAUVEGARDE DERIVEE DU SCHEMA : les champs ecrits sont ceux qu'il declare. Un
|
||
// champ ajoute au registre apparait au formulaire ET arrive au fichier — sans quoi
|
||
// il serait saisissable et perdu en silence, le pire des deux mondes.
|
||
const dp = {};
|
||
const propsD = ((schemaPlan.domaines_publics || {}).entite || {}).properties || {};
|
||
domaines.forEach(d => {
|
||
if (!String(d.nom).trim()) return;
|
||
const o = {};
|
||
Object.keys(propsD).forEach(cle => {
|
||
const v = d[cle];
|
||
if (v === undefined || v === null || v === '') {
|
||
if (propsD[cle].default !== undefined) o[cle] = propsD[cle].default;
|
||
return;
|
||
}
|
||
o[cle] = (propsD[cle].type === 'boolean') ? !!v : v;
|
||
});
|
||
dp[d.nom] = o;
|
||
});
|
||
const rep = await fetch('/api/domaines', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({domaines_publics: dp})
|
||
});
|
||
const data = await rep.json();
|
||
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
|
||
chargerDomaines(data); dessinerDomaines();
|
||
message('Domaines sauvegardés.', 'ok');
|
||
}
|
||
|
||
function chargerApplications(data) {
|
||
const a = (data.applications || {}).applications || {};
|
||
// Les valeurs gardent LEUR TYPE : le formulaire est genere depuis le schema, qui
|
||
// sait qu'une liste se saisit par virgules. Les aplatir ici obligeait la sauvegarde
|
||
// a deviner comment les reconstruire.
|
||
applications = Object.entries(a).map(([id, x]) => Object.assign({id}, x, {
|
||
liens: (x.liens || []).map(l => ({vers: l.vers || '', role: l.role || ''}))}));
|
||
applicationsModifie = false;
|
||
}
|
||
|
||
// ---- FORMULAIRES GENERES DEPUIS LE SCHEMA -------------------------------------
|
||
//
|
||
// Le schema (docs/audit/schema-plan.json, `make schema`) decrit la FORME des
|
||
// registres ; ces trois fonctions la rendent. Un champ ajoute au plan apparait donc
|
||
// a l'ecran sans qu'on touche a ce fichier — c'est tout l'objet.
|
||
//
|
||
// Ce qu'elles ne font PAS : valider. La coherence reste aux `valider_*` du serveur,
|
||
// qui refusent la sauvegarde. Un formulaire genere ne saurait pas dire qu'un
|
||
// consommateur designe une application inexistante.
|
||
|
||
function sourceDeValeurs(nom) {
|
||
// Les listes fermees, resolues a l'execution. C'est ce qui rend l'hote fantome
|
||
// IMPOSSIBLE A SAISIR, au lieu d'etre refuse apres coup.
|
||
if (nom === 'serveurs') return hotes.map(h => h.nom).filter(Boolean);
|
||
if (nom === 'applications') return applications.map(x => x.id).filter(Boolean);
|
||
if (nom === 'groupes_operationnels') return groupes;
|
||
if (nom === 'serveurs_bd') return bdServeurs.map(x => x.nom).filter(Boolean);
|
||
// `nomenclature`, PAS `data.nomenclature` : il n'existe aucun `data` global. Cette
|
||
// ligne dormait — la vue Serveurs, seule a employer cette source, avait un
|
||
// formulaire ecrit a la main. Elle a leve a la seconde ou le generateur l'a prise.
|
||
if (nom === 'nomenclature.fonctions') return Object.keys((nomEdit.fonctions || nomenclature.fonctions) || {});
|
||
// La zone se choisit dans les zones DECLAREES — et on la lit dans la copie en
|
||
// cours d'edition, pour qu'une zone ajoutee a l'instant soit offerte tout de suite.
|
||
// `{v, t}` : la valeur ecrite au plan reste le numero, l'ecran montre le libelle.
|
||
// `edge` designe un GROUPE Ansible : `instancier` compare cette valeur aux
|
||
// groupes d'un hote pour lui deriver ses SAN. Offrir des HOTES ici produirait un
|
||
// certificat correct sur un nom que personne ne peut appeler.
|
||
if (nom === 'groupes_edge') return groupes.filter(g => g.startsWith('serveur_nginx'));
|
||
// Les listes que le SITE a declarees dans les intrants (noeuds, stockages).
|
||
if (nom.startsWith('intrants.')) {
|
||
const v = ((intrants || {}).valeurs || {})[nom.slice(9)];
|
||
return Array.isArray(v) ? v : [];
|
||
}
|
||
if (nom === 'nomenclature.categories') {
|
||
const cats = (nomEdit.categories || {});
|
||
return Object.keys(cats).map(k => ({v: k, t: k + ' — ' + ((cats[k] || {}).libelle || '?')}));
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function champDepuisSchema(cle, spec, valeur, entite, surChangement) {
|
||
// UN CHAMP PEUT AVOIR SON PROPRE EDITEUR (`x-editeur`), plus riche que ce que le
|
||
// schema sait dire : la matrice des integrations montre aussi les universelles et
|
||
// leurs exemptions ; l'editeur de liens contraint le role a ce que le groupe
|
||
// porteur accepte. Le generateur s'efface — la vue les rend elle-meme.
|
||
if (spec['x-editeur']) return '';
|
||
const titre = echapper(spec.title || cle);
|
||
const aide = spec.description ? ` title="${echapper(spec.description)}"` : '';
|
||
// Marqueur discret : le schema SAIT ce qui est requis, le formulaire ecrit a la
|
||
// main ne le montrait pas. Une asterisque plutot que le mot « requis », qui se
|
||
// collait au libelle et se lisait « Base requis ».
|
||
const req = (spec['x-requis'] ? '<abbr class="dsn" title="Champ requis" style="text-decoration:none"> *</abbr>' : '');
|
||
const appel = (expr) => `${surChangement}('${cle}', ${expr})`;
|
||
// `data-champ` remplace l'ancien `data-v`, pose a la main sur trois champs de la
|
||
// vue Serveurs. La validation client (`serveurValeurValide`) le lit ; sans lui,
|
||
// elle aurait cherche des elements qui n'existent plus et serait passee au vert
|
||
// sur ZERO champ. Un controle qui ne trouve rien ne dit pas « tout va bien ».
|
||
const marque = ` data-champ="${echapper(cle)}"`;
|
||
|
||
// Liste fermee : soit fixe (enum), soit alimentee a l'execution, soit dependante
|
||
// d'un autre champ de la meme entite.
|
||
let options = null;
|
||
if (spec.enum) options = spec.enum;
|
||
else if (spec['x-source-valeurs']) options = sourceDeValeurs(spec['x-source-valeurs']);
|
||
else if (spec['x-source-selon']) {
|
||
const s = spec['x-source-selon'];
|
||
const pivot = (entite || {})[s.champ];
|
||
options = sourceDeValeurs((s.cas || {})[pivot] || s.defaut);
|
||
}
|
||
|
||
if (options) {
|
||
// L'option VIDE n'est pas « rien » : c'est « le defaut ». Quand le schema nomme
|
||
// l'intrant qui le porte (`x-defaut-intrant`), on l'affiche, plutot que de
|
||
// laisser l'operateur deviner ce qu'un champ vide produira.
|
||
const nomDef = spec['x-defaut-intrant'];
|
||
const valDef = nomDef ? ((intrants || {}).valeurs || {})[nomDef] : null;
|
||
const libVide = valDef ? `(défaut : ${valDef})` : '—';
|
||
const opts = [`<option value="">${echapper(libVide)}</option>`].concat(options.map(o => {
|
||
const v = (o && typeof o === 'object') ? o.v : o;
|
||
const txt = (o && typeof o === 'object') ? o.t : o;
|
||
return `<option value="${echapper(v)}"${String(v) === String(valeur == null ? '' : valeur) ? ' selected' : ''}>${echapper(txt)}</option>`;
|
||
})).join('');
|
||
return `<label class="champ"${aide}><span>${titre}${req}</span><select${marque} onchange="${appel('this.value')}">${opts}</select></label>`;
|
||
}
|
||
if (spec.type === 'object' && spec.properties) {
|
||
// BLOC FIXE (`sous_champs` au schema) : ses cases une fois, sans « ajouter ».
|
||
// La clef transmise au setter est POINTEE (`reservations.passerelle`) — c'est ce
|
||
// qui permet a un seul setter de servir toute la profondeur.
|
||
const sousReq = new Set(spec.required || []);
|
||
const cases = Object.keys(spec.properties).map(sc => champDepuisSchema(
|
||
cle + '.' + sc,
|
||
Object.assign({}, spec.properties[sc], {'x-requis': sousReq.has(sc)}),
|
||
(valeur || {})[sc], valeur, surChangement)).join('');
|
||
return `<div style="grid-column:1/-1"${aide}><div class="section-tete">${titre}</div>`
|
||
+ `<div class="grille">${cases}</div></div>`;
|
||
}
|
||
if (spec.type === 'boolean') {
|
||
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="checkbox"${marque}${valeur ? ' checked' : ''} onchange="${appel('this.checked')}"></label>`;
|
||
}
|
||
// Repere de saisie : la valeur REELLEMENT derivee pour cette entite, lue dans
|
||
// l'inventaire genere. Le formulaire ecrit a la main annoncait « 2048 » et « 2 » —
|
||
// des constantes inventees, alors que `deriver_ressources` calcule depuis les roles
|
||
// portes. Un repere faux fait croire qu'on connait la valeur.
|
||
const derive = spec['x-defaut-derive'];
|
||
let repere = '';
|
||
if (derive && entite && entite.nom) {
|
||
const h = hotes.find(x => x.nom === entite.nom);
|
||
if (h && h[derive] != null && h[derive] !== '') repere = ` placeholder="dérivé : ${echapper(h[derive])}"`;
|
||
}
|
||
if (spec.type === 'integer') {
|
||
return `<label class="champ"${aide}><span>${titre}${req}</span><input type="number"${marque}${repere} value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
|
||
}
|
||
if (spec.type === 'array' && (spec.items || {}).properties) {
|
||
return listeDepuisSchema(cle, spec, valeur, surChangement);
|
||
}
|
||
if (spec.type === 'array') {
|
||
// Une liste de textes se saisit separee par des virgules : le YAML reste une
|
||
// liste, la saisie reste lisible.
|
||
const v = Array.isArray(valeur) ? valeur.join(', ') : '';
|
||
return `<label class="champ"${aide}><span>${titre}${req}</span><input${marque} value="${echapper(v)}" placeholder="a, b" oninput="${appel('this.value.split(\',\').map(x=>x.trim()).filter(Boolean)')}"></label>`;
|
||
}
|
||
return `<label class="champ"${aide}><span>${titre}${req}</span><input${marque}${repere} value="${echapper(valeur == null ? '' : valeur)}" oninput="${appel('this.value')}"></label>`;
|
||
}
|
||
|
||
function formulaireDepuisSchema(nomRegistre, entite, surChangement, cleCourante, surChangementCle, nomClef) {
|
||
const reg = schemaPlan[nomRegistre] || null;
|
||
if (!reg) return '<div class="vide">Aucun schéma pour ce registre — lancer <code>make schema</code>.</div>';
|
||
const props = (reg.entite || {}).properties || {};
|
||
const requis = new Set((reg.entite || {}).required || []);
|
||
const morceaux = [];
|
||
if (reg['x-clef'] && surChangementCle) {
|
||
const k = reg['x-clef'];
|
||
morceaux.push(`<label class="champ" title="${echapper(k.description || '')}"><span>${echapper(k.title)}</span>` +
|
||
`<input data-champ="${echapper(nomClef || 'cle')}" value="${echapper(cleCourante == null ? '' : cleCourante)}" oninput="(${surChangementCle})(this.value)"></label>`);
|
||
}
|
||
Object.keys(props).forEach(cle => {
|
||
const spec = Object.assign({}, props[cle], {'x-requis': requis.has(cle)});
|
||
morceaux.push(champDepuisSchema(cle, spec, (entite || {})[cle], entite, surChangement));
|
||
});
|
||
return `<div class="grille">${morceaux.join('')}</div>`;
|
||
}
|
||
|
||
function poserChemin(objet, chemin, valeur) {
|
||
// `reservations.passerelle`, `exposition.0.cible` : un seul mecanisme pour toute
|
||
// la profondeur, tableaux compris. Les setters de chaque vue s'appuient dessus.
|
||
const parts = String(chemin).split('.');
|
||
let o = objet;
|
||
for (let i = 0; i < parts.length - 1; i++) {
|
||
const suivant = parts[i + 1];
|
||
if (o[parts[i]] == null || typeof o[parts[i]] !== 'object') {
|
||
o[parts[i]] = String(suivant).match(/^\d+$/) ? [] : {};
|
||
}
|
||
o = o[parts[i]];
|
||
}
|
||
o[parts[parts.length - 1]] = valeur;
|
||
}
|
||
|
||
function listeDepuisSchema(cle, spec, valeur, surChangement) {
|
||
// UNE LISTE D'OBJETS de forme connue (`entrees` au schema) : une sous-fiche par
|
||
// element, plus un bouton d'ajout. L'ajout et le retrait passent par le MEME
|
||
// setter que les champs — on lui remet le tableau entier, serialise dans
|
||
// l'attribut. Pas de fonction globale a resoudre au clic : ce qui marche sous le
|
||
// banc marche dans le navigateur.
|
||
const items = spec.items || {};
|
||
const arr = Array.isArray(valeur) ? valeur : [];
|
||
const defauts = {};
|
||
Object.keys(items.properties || {}).forEach(c => {
|
||
if (items.properties[c].default !== undefined) defauts[c] = items.properties[c].default;
|
||
});
|
||
const ajout = echapper(JSON.stringify(arr.concat([defauts])));
|
||
const lignes = arr.map((e, i) => {
|
||
const retrait = echapper(JSON.stringify(arr.filter((_, j) => j !== i)));
|
||
return `<div class="ch-base" style="display:block">
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
<span class="dsn">#${i + 1}</span><span class="grandit"></span>
|
||
<button type="button" class="danger" title="Retirer"
|
||
onclick="${surChangement}('${cle}', ${retrait})">✕</button></div>
|
||
${sousFormulaireDepuisSchema(items, e, cle + '.' + i, surChangement)}</div>`;
|
||
}).join('');
|
||
return `<div style="grid-column:1/-1">
|
||
<div class="section-tete">${echapper(spec.title || cle)} <span class="compte">${arr.length}</span>
|
||
<button type="button" class="fantome" style="margin-left:auto"
|
||
onclick="${surChangement}('${cle}', ${ajout})">+ Ajouter</button></div>
|
||
${arr.length ? lignes : '<div class="ch-stub">aucune entrée</div>'}</div>`;
|
||
}
|
||
|
||
function sousFormulaireDepuisSchema(specEntree, entree, prefixe, surChangement) {
|
||
// Une ligne de table (`entree` au schema) : les memes cases qu'une entite, mais
|
||
// sous une clef pointee, pour que le setter sache ou poser la valeur.
|
||
const props = (specEntree || {}).properties || {};
|
||
const requis = new Set((specEntree || {}).required || []);
|
||
const cases = Object.keys(props).map(c => champDepuisSchema(
|
||
prefixe + '.' + c, Object.assign({}, props[c], {'x-requis': requis.has(c)}),
|
||
(entree || {})[c], entree, surChangement)).join('');
|
||
return `<div class="grille">${cases}</div>`;
|
||
}
|
||
|
||
function dsnBase(a) {
|
||
const s = bdServeurs.find(x => x.nom === a.serveur);
|
||
if (!s) return '(serveur inconnu : ' + echapper(a.serveur || '—') + ')';
|
||
return `${s.type}://${a.proprietaire || '?'}:****@${s.hote || '?'}:${s.port || '?'}/${a.base || '?'}`;
|
||
}
|
||
|
||
function marquerBasesModifie() { basesModifie = true; majIndicateurSauvegarde(); }
|
||
function selBaseInfo() { if (!selBase) return null; const p = selBase.split(':'); return {kind: p[0], i: +p[1]}; }
|
||
function choisirBase(key) { selBase = key; dessiner(); }
|
||
function definirServeurBd(champ, v) {
|
||
const info = selBaseInfo(); if (!info || info.kind !== 'srv') return;
|
||
const s = bdServeurs[info.i]; if (!s) return;
|
||
s[champ] = v; marquerBasesModifie();
|
||
if (champ === 'nom') { const t = document.querySelector('#tuile-base-srv-' + info.i + ' .carte-nom'); if (t) t.textContent = v || '(sans nom)'; }
|
||
}
|
||
function definirBaseBd(champ, v) {
|
||
const info = selBaseInfo(); if (!info || info.kind !== 'bd') return;
|
||
const a = bdApplis[info.i]; if (!a) return;
|
||
a[champ] = v; marquerBasesModifie();
|
||
if (champ === 'cle') { const t = document.querySelector('#tuile-base-bd-' + info.i + ' .carte-nom'); if (t) t.textContent = v || '(sans id)'; }
|
||
else if (champ === 'serveur' || champ === 'portee') dessinerDetail();
|
||
}
|
||
function ajouterServeurBd() { bdServeurs.unshift({nom: '', type: 'postgres', hote: '', port: 5432, groupe: ''}); selBase = 'srv:0'; marquerBasesModifie(); dessiner(); }
|
||
function retirerServeurBd() { const info = selBaseInfo(); if (!info || info.kind !== 'srv') return; bdServeurs.splice(info.i, 1); selBase = null; marquerBasesModifie(); dessiner(); }
|
||
function ajouterBaseBd() { bdApplis.unshift({cle: '', serveur: (bdServeurs[0] || {}).nom || '', base: '', proprietaire: '', secret: '', consommateur: '', portee: 'groupe', usage: 'principale'}); selBase = 'bd:0'; marquerBasesModifie(); dessiner(); }
|
||
function retirerBaseBd() { const info = selBaseInfo(); if (!info || info.kind !== 'bd') return; bdApplis.splice(info.i, 1); selBase = null; marquerBasesModifie(); dessiner(); }
|
||
|
||
function carteBaseServeur(s, i) {
|
||
const key = 'srv:' + i;
|
||
return `<button type="button" class="carte ${selBase === key ? 'selectionnee' : ''}" id="tuile-base-srv-${i}" onclick="choisirBase('${key}')">
|
||
<div class="carte-haut"><span class="badge-bd">${echapper(s.type || 'bd')}</span>${s.port ? `<span class="carte-vmid">:${echapper(s.port)}</span>` : ''}</div>
|
||
<div class="carte-nom ${s.nom ? '' : 'sans'}">${echapper(s.nom || '(sans nom)')}</div>
|
||
<div class="carte-ip">${echapper(s.hote || '— hôte ?')}</div>
|
||
</button>`;
|
||
}
|
||
function carteBaseDonnee(a, i) {
|
||
const key = 'bd:' + i;
|
||
return `<button type="button" class="carte ${selBase === key ? 'selectionnee' : ''}" id="tuile-base-bd-${i}" onclick="choisirBase('${key}')">
|
||
<div class="carte-haut"><span class="badge-bd">${echapper(a.portee || 'groupe')}</span></div>
|
||
<div class="carte-nom ${a.cle ? '' : 'sans'}">${echapper(a.cle || '(sans id)')}</div>
|
||
<div class="carte-ip">${echapper(a.base || '—')} @ ${echapper(a.serveur || '?')}</div>
|
||
</button>`;
|
||
}
|
||
function dessinerBases() {
|
||
const cible = document.getElementById('grilles');
|
||
const srv = bdServeurs.length ? `<div class="grille-cartes">${bdServeurs.map((s, i) => carteBaseServeur(s, i)).join('')}</div>` : '<div class="vide" style="padding:12px">Aucun serveur de BD.</div>';
|
||
const bd = bdApplis.length ? `<div class="grille-cartes">${bdApplis.map((a, i) => carteBaseDonnee(a, i)).join('')}</div>` : '<div class="vide" style="padding:12px">Aucune base.</div>';
|
||
cible.innerHTML = `<div class="section-grille"><div class="section-tete">Serveurs de bases <span class="compte">${bdServeurs.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterServeurBd()">+ Serveur BD</button></div>${srv}</div>
|
||
<div class="section-grille"><div class="section-tete">Bases applicatives <span class="compte">${bdApplis.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterBaseBd()">+ Base</button></div>${bd}</div>`;
|
||
}
|
||
function inspecteurBase() {
|
||
const cible = document.getElementById('detail');
|
||
const info = selBaseInfo();
|
||
if (!info) { cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun élément sélectionné</b>Choisis un serveur de base ou une base à gauche.</div></div>'; return; }
|
||
if (info.kind === 'srv') {
|
||
const s = bdServeurs[info.i];
|
||
if (!s) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; }
|
||
const ici = bdApplis.filter(a => a.serveur === s.nom);
|
||
const iciHtml = ici.length ? `<div class="ch-bases">${ici.map(a => `<div class="ch-base"><span class="badge-bd">${echapper(a.portee || 'groupe')}</span> <span class="mono">${echapper(a.base || '?')}</span> (${echapper(a.cle)})</div>`).join('')}</div>` : '<div class="ch-stub">aucune base sur ce serveur</div>';
|
||
cible.innerHTML = `<article class="detail">
|
||
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(s.nom || '(sans nom)')}</span></div><span class="grandit"></span><span class="badge-bd">${echapper(s.type || 'bd')}</span><button type="button" class="danger" title="Retirer" onclick="retirerServeurBd()">✕</button></div>
|
||
${formulaireDepuisSchema('serveurs_bd', s, 'definirServeurBd', s.nom, "definirServeurBd.bind(null,'nom')")}
|
||
<div class="section-tete" style="margin:12px 2px 2px">Bases hébergées ici</div>${iciHtml}
|
||
${(() => {
|
||
const hS = hotes.find(h => h.nom === s.hote);
|
||
if (hS && hS.etat === 'actif') { const hiS = hotes.indexOf(hS);
|
||
return `<div class="pied"><span class="hint-dep">Déployer applique les rôles sur l'hôte « ${echapper(s.hote)} ».</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiS})" title="Déployer l'hôte du serveur de BD">Déployer</button></div>`; }
|
||
return `<div class="pied"><span class="hint-dep">${s.hote ? `Active + pousse d'abord l'hôte « ${echapper(s.hote)} ».` : 'Aucun hôte assigné.'}</span></div>`;
|
||
})()}
|
||
</article>`;
|
||
} else {
|
||
const a = bdApplis[info.i];
|
||
if (!a) { cible.innerHTML = '<div class="detail"><div class="vide">Introuvable.</div></div>'; return; }
|
||
// FORMULAIRE GENERE DEPUIS LE SCHEMA (2026-09-08) — plus aucun champ en dur.
|
||
// Les huit `<label>` qui vivaient ici sont derives de docs/audit/schema-plan.json :
|
||
// libelles, types, listes fermees, et la source CONDITIONNELLE du consommateur
|
||
// (elle depend de la portee) que le schema declare desormais au lieu de la laisser
|
||
// vivre dans ce fichier. Un champ ajoute au registre apparait sans toucher au JS,
|
||
// et P61 refuse qu'un champ du plan manque au schema.
|
||
cible.innerHTML = `<article class="detail">
|
||
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(a.cle || '(sans id)')}</span></div><span class="grandit"></span><button type="button" class="danger" title="Retirer" onclick="retirerBaseBd()">✕</button></div>
|
||
${formulaireDepuisSchema('bases_donnees', a, 'definirBaseBd', a.cle, "definirBaseBd.bind(null,'cle')")}
|
||
<div class="dsn mono" style="margin-top:8px">${echapper(dsnBase(a))}</div>
|
||
${(() => {
|
||
const srv = bdServeurs.find(x => x.nom === a.serveur);
|
||
const hB = srv ? hotes.find(h => h.nom === srv.hote) : null;
|
||
if (hB && hB.etat === 'actif') { const hiB = hotes.indexOf(hB);
|
||
return `<div class="pied"><span class="hint-dep">Déployer applique les rôles sur « ${echapper(srv.hote)} » (le serveur de BD qui héberge cette base).</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiB})" title="Déployer le serveur de BD (crée cette base)">Déployer</button></div>`; }
|
||
return `<div class="pied"><span class="hint-dep">${(srv && srv.hote) ? `Active + pousse d'abord l'hôte « ${echapper(srv.hote)} ».` : 'Serveur de BD sans hôte assigné.'}</span></div>`;
|
||
})()}
|
||
</article>`;
|
||
}
|
||
}
|
||
|
||
async function sauvegarderBases() {
|
||
const sd = {};
|
||
const propsSrv = ((schemaPlan.serveurs_bd || {}).entite || {}).properties || {};
|
||
bdServeurs.forEach(s => {
|
||
if (!String(s.nom).trim()) return;
|
||
const o = {};
|
||
Object.keys(propsSrv).forEach(cle => {
|
||
const v = s[cle];
|
||
if (v === undefined || v === null || v === '') return;
|
||
o[cle] = (propsSrv[cle].type === 'integer') ? (parseInt(v, 10) || v) : v;
|
||
});
|
||
sd[s.nom] = o;
|
||
});
|
||
const bd = {};
|
||
// SAUVEGARDE DERIVEE DU SCHEMA (2026-09-08). Les sept champs etaient enumeres ici
|
||
// en dur : un champ ajoute au registre serait apparu au formulaire genere et
|
||
// aurait disparu SILENCIEUSEMENT a l'enregistrement — le pire des deux mondes.
|
||
// Les valeurs par defaut viennent du schema (`default`), plus du JS.
|
||
const propsBd = ((schemaPlan.bases_donnees || {}).entite || {}).properties || {};
|
||
bdApplis.forEach(a => {
|
||
if (!String(a.cle).trim()) return;
|
||
const e = {};
|
||
Object.keys(propsBd).forEach(cle => {
|
||
const v = a[cle];
|
||
e[cle] = (v === undefined || v === '' || v === null) ? (propsBd[cle].default ?? v) : v;
|
||
});
|
||
bd[a.cle] = e;
|
||
});
|
||
const rep = await fetch('/api/bases', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs_bd: sd, bases_donnees: bd})
|
||
});
|
||
const data = await rep.json();
|
||
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
|
||
chargerBases(data);
|
||
dessinerBases();
|
||
message('Bases de données sauvegardées.', 'ok');
|
||
}
|
||
|
||
function marquerApplicationsModifie() { applicationsModifie = true; majIndicateurSauvegarde(); }
|
||
function choisirApp(i) { selApp = i; dessiner(); }
|
||
function definirApplication(champ, v) {
|
||
const a = applications[selApp]; if (!a) return;
|
||
poserChemin(a, champ, v); marquerApplicationsModifie();
|
||
if (champ === 'id') { const t = document.querySelector('#tuile-app-' + selApp + ' .carte-nom'); if (t) { t.textContent = v || '(sans id)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
|
||
else if (champ === 'groupe') {
|
||
const ok = Object.keys(liensAcceptes[a.groupe] || {});
|
||
(a.liens || []).forEach(l => { if (l.role && !ok.includes(l.role)) l.role = ''; });
|
||
dessinerDetail();
|
||
}
|
||
else if (champ === 'hote' || champ === 'port') dessinerDetail();
|
||
}
|
||
function ajouterApplication() {
|
||
applications.unshift({id: '', groupe: (groupes[0] || ''), hote: '', port: '', requiert: '', expose: '', websocket: false, liens: []});
|
||
selApp = 0; marquerApplicationsModifie(); dessiner();
|
||
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
|
||
}
|
||
function retirerApplication() {
|
||
const a = applications[selApp]; if (!a) return;
|
||
if (a.id && !confirm('Retirer l\'application « ' + a.id + ' » ?')) return;
|
||
applications.splice(selApp, 1);
|
||
selApp = applications.length ? Math.min(selApp, applications.length - 1) : null;
|
||
marquerApplicationsModifie(); dessiner();
|
||
}
|
||
|
||
function ajouterLien() {
|
||
const a = applications[selApp]; if (!a) return;
|
||
if (!a.liens) a.liens = [];
|
||
const roles = Object.keys(liensAcceptes[a.groupe] || {});
|
||
a.liens.push({vers: '', role: roles.length === 1 ? roles[0] : ''});
|
||
marquerApplicationsModifie(); dessinerDetail();
|
||
}
|
||
function retirerLien(i) {
|
||
const a = applications[selApp]; if (!a || !a.liens) return;
|
||
a.liens.splice(i, 1); marquerApplicationsModifie(); dessinerDetail();
|
||
}
|
||
function definirLien(i, champ, v) {
|
||
const a = applications[selApp]; if (!a || !a.liens || !a.liens[i]) return;
|
||
a.liens[i][champ] = v; marquerApplicationsModifie();
|
||
if (champ === 'role') dessinerDetail();
|
||
}
|
||
function blocLiens(a) {
|
||
const accepte = liensAcceptes[a.groupe] || {};
|
||
const roles = Object.keys(accepte);
|
||
if (!roles.length) {
|
||
return `<div class="ch-stub">Le rôle « ${echapper(a.groupe || '—')} » n'accepte aucun lien
|
||
(aucun <span class="mono">roles/${echapper(a.groupe || '?')}/meta/liens.yml</span>).</div>`;
|
||
}
|
||
const cibles = applications.map(x => x.id).filter(x => x && x !== a.id);
|
||
const lignes = (a.liens || []).map((l, i) => {
|
||
const spec = accepte[l.role] || {};
|
||
const vars = Object.keys(spec.variables || {});
|
||
const optV = '<option value="">— cible —</option>' + cibles.map(c =>
|
||
`<option value="${echapper(c)}" ${c === l.vers ? 'selected' : ''}>${echapper(c)}</option>`).join('');
|
||
const optR = '<option value="">— rôle —</option>' + roles.map(r =>
|
||
`<option value="${echapper(r)}" ${r === l.role ? 'selected' : ''}>${echapper(r)}</option>`).join('');
|
||
const inconnue = l.vers && !cibles.includes(l.vers);
|
||
return `<div class="ch-base" style="gap:8px;align-items:center">
|
||
<select style="max-width:12rem" onchange="definirLien(${i}, 'role', this.value)">${optR}</select>
|
||
<span class="mono">→</span>
|
||
<select style="max-width:12rem" onchange="definirLien(${i}, 'vers', this.value)">${optV}</select>
|
||
${inconnue ? `<span class="tag" style="color:#b45309">cible « ${echapper(l.vers)} » inconnue</span>` : ''}
|
||
${vars.length ? `<span class="tag" title="Variables injectées sur cette application">${echapper(vars.join(', '))}</span>` : ''}
|
||
<span class="grandit"></span>
|
||
<button type="button" class="danger" title="Retirer ce lien" onclick="retirerLien(${i})">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
return `${lignes || '<div class="ch-stub">aucun lien déclaré</div>'}
|
||
<div style="margin-top:6px"><button type="button" class="fantome" onclick="ajouterLien()">+ Lien</button></div>`;
|
||
}
|
||
|
||
function carteApp(a, i) {
|
||
const exp = listeDepuisTexte(a.expose);
|
||
return `<button type="button" class="carte ${i === selApp ? 'selectionnee' : ''}" id="tuile-app-${i}" onclick="choisirApp(${i})">
|
||
<div class="carte-haut"><span class="badge-bd">app</span>${a.port ? `<span class="carte-vmid">:${echapper(a.port)}</span>` : ''}</div>
|
||
<div class="carte-nom ${a.id ? '' : 'sans'}">${echapper(a.id || '(sans id)')}</div>
|
||
<div class="carte-ip">${echapper(a.hote || '— hôte ?')}</div>
|
||
${exp.length ? `<div class="carte-grps"><span class="tag">${echapper(exp[0])}</span>${exp.length > 1 ? `<span class="tag plus">+${exp.length - 1}</span>` : ''}</div>` : ''}
|
||
</button>`;
|
||
}
|
||
function dessinerApplications() {
|
||
const cible = document.getElementById('grilles');
|
||
const tuiles = applications.length
|
||
? `<div class="grille-cartes">${applications.map((a, i) => carteApp(a, i)).join('')}</div>`
|
||
: '<div class="vide" style="padding:14px">Aucune application. Une VM peut en porter plusieurs.</div>';
|
||
cible.innerHTML = `<div class="section-grille">
|
||
<div class="section-tete">Applications <span class="compte">${applications.length}</span><button type="button" class="fantome" style="margin-left:auto" onclick="ajouterApplication()">+ Application</button></div>
|
||
${tuiles}</div>`;
|
||
}
|
||
function inspecteurApplication() {
|
||
const cible = document.getElementById('detail');
|
||
if (selApp == null || !applications[selApp]) {
|
||
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucune application sélectionnée</b>Choisis une tuile à gauche, ou ajoute une application.</div></div>';
|
||
return;
|
||
}
|
||
const a = applications[selApp];
|
||
const dsns = basesDeApp(a, a.hote);
|
||
const liens = dsns.length
|
||
? `<div class="ch-bases">${dsns.map(b => `<div class="ch-base"><span class="badge-bd">${echapper(b.portee || 'groupe')}</span> <span class="mono">${echapper(dsnBase(b))}</span></div>`).join('')}</div>`
|
||
: '<div class="ch-stub">aucune base liée</div>';
|
||
cible.innerHTML = `<article class="detail">
|
||
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(a.id || '(sans id)')}</span></div>
|
||
<span class="grandit"></span>
|
||
<button type="button" class="danger" title="Retirer cette application" onclick="retirerApplication()">✕</button></div>
|
||
${formulaireDepuisSchema('applications', a, 'definirApplication', a.id, "definirApplication.bind(null,'id')", 'id')}
|
||
<div class="section-tete" style="margin:12px 2px 4px">Liens (bindings)
|
||
<span class="compte">${(a.liens || []).length}</span></div>
|
||
${blocLiens(a)}
|
||
<div class="section-tete" style="margin:12px 2px 4px">Bases consommées</div>
|
||
${liens}
|
||
${(() => {
|
||
const hA = hotes.find(x => x.nom === a.hote);
|
||
if (hA && hA.etat === 'actif') {
|
||
const hiA = hotes.indexOf(hA);
|
||
return `<div class="pied"><span class="hint-dep">Déployer applique les rôles sur l'hôte porteur « ${echapper(a.hote)} ».</span><span class="grandit"></span>${estProduction ? '<span class="badge-prod">PROD</span>' : ''}<button type="button" class="${estProduction ? 'danger' : 'primaire'}" onclick="lancer('pousser', ${hiA})" title="Déployer l'hôte porteur (rend l'application vivante)">Déployer</button></div>`;
|
||
}
|
||
return `<div class="pied"><span class="hint-dep">${a.hote ? `Active + pousse d'abord l'hôte « ${echapper(a.hote)} ».` : 'Aucun hôte assigné.'}</span></div>`;
|
||
})()}
|
||
</article>`;
|
||
}
|
||
|
||
function listeDepuisTexte(v) {
|
||
// Elle accepte desormais une VRAIE liste autant qu'un texte separe par des virgules.
|
||
// Les chargeurs aplatissaient `expose`, `requiert` et `integrations` en chaine ;
|
||
// le formulaire genere, lui, attend le type que le schema declare. Plutot que de
|
||
// toucher aux douze sites d'appel, la conversion vit ici, en un seul endroit.
|
||
if (Array.isArray(v)) return v.map(x => String(x).trim()).filter(Boolean);
|
||
return String(v || '').split(',').map(s => s.trim()).filter(Boolean);
|
||
}
|
||
|
||
async function sauvegarderApplications() {
|
||
const apps = {};
|
||
const propsA = ((schemaPlan.applications || {}).entite || {}).properties || {};
|
||
applications.forEach(a => {
|
||
if (!String(a.id).trim()) return;
|
||
const o = {};
|
||
Object.keys(propsA).forEach(cle => {
|
||
const spec = propsA[cle];
|
||
if (cle === 'liens') {
|
||
// Un lien incomplet n'est pas un lien : il ferait echouer le validateur du
|
||
// moteur a la sauvegarde suivante, sur une entree que personne n'a voulue.
|
||
const lns = (a.liens || []).filter(l => String(l.vers || '').trim() && String(l.role || '').trim())
|
||
.map(l => ({vers: l.vers, role: l.role}));
|
||
if (lns.length) o.liens = lns;
|
||
return;
|
||
}
|
||
if (spec.type === 'array') { const l = listeDepuisTexte(a[cle]); if (l.length) o[cle] = l; return; }
|
||
if (spec.type === 'boolean') { if (a[cle]) o[cle] = true; return; }
|
||
const v = a[cle];
|
||
if (v === undefined || v === null || String(v).trim() === '') return;
|
||
o[cle] = (spec.type === 'integer') ? (parseInt(v, 10) || v) : v;
|
||
});
|
||
// `groupe` et `hote` sont REQUIS : on les ecrit meme vides, pour que le
|
||
// validateur du moteur refuse clairement plutot que de recevoir un objet muet.
|
||
if (o.groupe === undefined) o.groupe = a.groupe || '';
|
||
if (o.hote === undefined) o.hote = a.hote || '';
|
||
apps[a.id] = o;
|
||
});
|
||
const rep = await fetch('/api/applications', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({applications: apps})
|
||
});
|
||
const data = await rep.json();
|
||
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
|
||
chargerApplications(data);
|
||
dessinerApplications();
|
||
message('Applications sauvegardées.', 'ok');
|
||
}
|
||
|
||
function chargerServeurs(data) {
|
||
serveurs = (data.serveurs || []).map(s => ({...s, integrations: s.integrations || []}));
|
||
serveursModifie = false;
|
||
}
|
||
|
||
|
||
function marquerServeursModifie() { serveursModifie = true; majIndicateurSauvegarde(); }
|
||
function choisirServeur(i) { selServeur = i; dessiner(); }
|
||
function definirServeur(champ, v) {
|
||
const s = serveurs[selServeur]; if (!s) return;
|
||
poserChemin(s, champ, v); marquerServeursModifie();
|
||
if (champ === 'nom') { const t = document.querySelector('#tuile-srv-' + selServeur + ' .carte-nom'); if (t) { t.textContent = v || '(sans nom)'; t.className = 'carte-nom' + (v ? '' : ' sans'); } }
|
||
else if (champ === 'fonction' || champ === 'etat') dessiner();
|
||
}
|
||
function basculerIntegration(role, actif) {
|
||
const s = serveurs[selServeur]; if (!s) return;
|
||
const set = new Set(listeDepuisTexte(s.integrations));
|
||
if (actif) set.add(role); else set.delete(role);
|
||
s.integrations = Array.from(set); marquerServeursModifie();
|
||
}
|
||
function ajouterServeur() {
|
||
serveurs.unshift({nom: '', fonction: (Object.keys(nomenclature.fonctions || {})[0] || ''), etat: 'planifie', noeud: '', stockage: '', disque: '', memoire: '', coeurs: '', integrations: '', statut: 'absent', vmid: '', adresse_ip: '', vlan: ''});
|
||
selServeur = 0; marquerServeursModifie(); dessiner();
|
||
const c = document.querySelector('#detail .grille input'); if (c) c.focus();
|
||
}
|
||
function retirerServeur() {
|
||
const s = serveurs[selServeur]; if (!s) return;
|
||
if (s.nom && !confirm('Retirer le serveur « ' + s.nom + ' » du plan ?')) return;
|
||
serveurs.splice(selServeur, 1);
|
||
selServeur = serveurs.length ? Math.min(selServeur, serveurs.length - 1) : null;
|
||
marquerServeursModifie(); dessiner();
|
||
}
|
||
function ouvrirApp(id) { const i = applications.findIndex(a => a.id === id); selApp = i >= 0 ? i : null; setVue('applications'); }
|
||
function ouvrirBaseServeur(nom) { const i = bdServeurs.findIndex(x => x.nom === nom); selBase = i >= 0 ? 'srv:' + i : null; setVue('bases'); }
|
||
|
||
function pointVie(nom) {
|
||
const v = sondes[nom];
|
||
return v === true ? '<span class="pt actif" title="VM joignable (SSH)"></span> '
|
||
: v === false ? '<span class="pt bloq" title="Injoignable"></span> ' : '';
|
||
}
|
||
function carteServeur(s, i) {
|
||
const actif = s.etat === 'actif';
|
||
const recon = s.statut === 'divergence' ? '<span class="tag plus" title="Le plan diverge de l\'inventaire généré">⚠ divergent</span>'
|
||
: s.statut === 'absent' ? '<span class="tag" title="Déclaré, pas encore instancié">○ non instancié</span>' : '';
|
||
return `<button type="button" class="carte ${actif ? 'actif' : 'attente'} ${i === selServeur ? 'selectionnee' : ''}" id="tuile-srv-${i}" onclick="choisirServeur(${i})">
|
||
<div class="carte-haut"><span class="badge-statut ${actif ? 'ok' : 'attente'}">${actif ? 'actif' : 'planifié'}</span>${s.vmid ? `<span class="carte-vmid">#${echapper(s.vmid)}</span>` : ''}</div>
|
||
<div class="carte-nom ${s.nom ? '' : 'sans'}">${echapper(s.nom || '(sans nom)')}</div>
|
||
<div class="carte-ip">${pointVie(s.nom)}${echapper(s.adresse_ip || '—')}</div>
|
||
${recon ? `<div class="carte-grps">${recon}</div>` : ''}
|
||
</button>`;
|
||
}
|
||
// LA CONSOLE D'UN SITE DESSINE SES MACHINES (2026-09-20).
|
||
//
|
||
// `inventaire_api_du_site` sert volontairement `serveurs: {}` — « on ne fabrique pas
|
||
// un faux plan de tenant pour remplir l'ecran ». C'est juste : un site declare ses
|
||
// machines dans SON plan, avec `ip`, `vmid` et `noeud` ECRITS, la ou un locataire les
|
||
// derive de son seed. Mais la page tirait de ce registre vide le message d'un
|
||
// locataire sans serveurs — « + Serveur », `make serveurs-bootstrap » — et les douze
|
||
// machines du site, servies dans `hotes`, n'etaient dessinees nulle part.
|
||
//
|
||
// Un site a douze machines et sa console n'en montrait aucune : c'est le defaut du
|
||
// 2026-09-16 qui revient par une autre porte. La premiere fois, l'inventaire etait
|
||
// vide ; cette fois il est plein et c'est la VUE qui regarde au mauvais endroit.
|
||
function dessinerMachinesDuSite() {
|
||
const cible = document.getElementById('grilles');
|
||
const nom = (contexte && contexte.fabric) || 'ce site';
|
||
const entete = `<div class="section-grille"><div class="section-tete">Machines de ${echapper(nom)}
|
||
<span class="compte">${hotes.length}</span></div>
|
||
<div class="hint-dep">Ces machines sont déclarées dans le plan du site — <code>ip</code>,
|
||
<code>vmid</code> et <code>noeud</code> y sont <b>écrits</b>, pas dérivés d'un seed :
|
||
un site est le terrain, il n'a rien dont les faire descendre. Cette console ne les édite
|
||
donc pas ; elle les <b>matérialise</b> et leur applique des rôles — voir la vue
|
||
<b>Assistants</b>.</div></div>`;
|
||
if (!hotes.length) {
|
||
cible.innerHTML = entete + '<div class="vide" style="padding:14px">Cet inventaire dynamique ne rend aucune machine. Ce n\'est pas un plan vide : c\'est une mesure vide, et elle mérite un regard — <span class="mono">make site-inventaire</span>.</div>';
|
||
return;
|
||
}
|
||
const vus = hotes.map((h, i) => [h, i]).filter(([h]) =>
|
||
!filtre || (h.nom || '').toLowerCase().includes(filtre));
|
||
const sections = [['Actives', vus.filter(([h]) => h.etat === 'actif')],
|
||
['Planifiées', vus.filter(([h]) => h.etat !== 'actif')]];
|
||
cible.innerHTML = entete + sections.filter(([, l]) => l.length).map(([t, l]) =>
|
||
`<div class="section-grille"><div class="section-tete">${t} <span class="compte">${l.length}</span></div>
|
||
<div class="grille-cartes">${l.map(([h, i]) => carteMachineDuSite(h, i)).join('')}</div></div>`).join('');
|
||
}
|
||
|
||
// LE MEME GABARIT QUE LES SERVEURS, et pas un second qui lui ressemblerait : deux
|
||
// cartes qui se ressemblent finissent par ne plus se ressembler.
|
||
function carteMachineDuSite(h, i) {
|
||
const actif = h.etat === 'actif';
|
||
const roles = (h.groupes || []).length;
|
||
return `<button type="button" class="carte ${actif ? 'actif' : 'attente'} ${i === selMachine ? 'selectionnee' : ''}" id="tuile-mach-${i}" onclick="choisirMachine(${i})">
|
||
<div class="carte-haut"><span class="badge-statut ${actif ? 'ok' : 'attente'}">${actif ? 'actif' : 'planifié'}</span>${h.vmid ? `<span class="carte-vmid">#${echapper(h.vmid)}</span>` : ''}</div>
|
||
<div class="carte-nom">${echapper(h.nom)}</div>
|
||
<div class="carte-ip">${pointVie(h.nom)}${echapper(h.adresse_ip || '—')}</div>
|
||
${roles ? `<div class="carte-grps"><span class="tag">${roles} rôle${roles > 1 ? 's' : ''}</span></div>` : ''}
|
||
</button>`;
|
||
}
|
||
|
||
function choisirMachine(i) { selMachine = i; dessinerMachinesDuSite(); dessinerDetail(); }
|
||
|
||
function inspecteurMachineDuSite() {
|
||
const cible = document.getElementById('detail');
|
||
const h = hotes[selMachine];
|
||
if (!h) {
|
||
cible.innerHTML = `<div class="detail"><div class="section-tete">Console du SITE</div>
|
||
<div class="hint-dep">Choisis une machine à gauche. Cette console <b>matérialise</b> le
|
||
terrain — créer et détruire des VM, poser le réseau et la frontière — et n'entre
|
||
chez aucun locataire : elle n'a la voûte d'aucun d'eux.</div>
|
||
<div class="hint-dep" style="margin-top:10px">Les séquences qui lui reviennent sont
|
||
dans la vue <b>Assistants</b>.</div></div>`;
|
||
return;
|
||
}
|
||
const ligne = (c, v) => v ? `<div><span class="aide-ro">${c}</span> <span class="mono">${echapper(String(v))}</span></div>` : '';
|
||
cible.innerHTML = `<div class="detail"><div class="section-tete">${echapper(h.nom)}</div>
|
||
${ligne('état', h.etat)}${ligne('adresse', h.adresse_ip)}${ligne('VMID', h.vmid)}
|
||
${ligne('VLAN', h.vlan)}${ligne('nœud', h.noeud)}
|
||
<div class="section-tete" style="margin-top:12px">Rôles</div>
|
||
<div>${(h.groupes || []).map(g => `<span class="chip">${echapper(g)}</span>`).join(' ') || '<span class="aide-ro">aucun</span>'}</div>
|
||
<div class="hint-dep" style="margin-top:12px">Lecture seule. Le plan de ce site vit dans
|
||
son dépôt ; cette console ne l'édite pas. Pour lui appliquer un rôle :
|
||
<code>make site-appliquer GROUPE=…</code>, ou la vue <b>Assistants</b>.</div></div>`;
|
||
}
|
||
|
||
function dessinerServeurs() {
|
||
// Une console qui n'edite aucun plan n'a pas de registre `serveurs` — et elle a
|
||
// pourtant des machines. On dessine CE QU'ELLE A.
|
||
if (!peut('editer_le_plan')) return dessinerMachinesDuSite();
|
||
const cible = document.getElementById('grilles');
|
||
const entete = BANNIERE_MODELE + `<div class="section-grille"><div class="section-tete">Serveurs <span class="compte">${serveurs.length}</span>
|
||
<button type="button" class="fantome" style="margin-left:auto" onclick="ajouterServeur()">+ Serveur</button>
|
||
<button type="button" class="fantome" onclick="appliquerPlan()">⚙ Appliquer le plan</button></div></div>`;
|
||
if (!serveurs.length) { cible.innerHTML = entete + '<div class="vide" style="padding:14px">Aucun serveur. « + Serveur » ou <span class="mono">make serveurs-bootstrap</span>.</div>'; return; }
|
||
const idx = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s));
|
||
const sections = [['Actifs', idx.filter(([s]) => s.etat === 'actif')], ['Planifiés', idx.filter(([s]) => s.etat !== 'actif')]];
|
||
cible.innerHTML = entete + sections.filter(([, l]) => l.length).map(([t, l]) =>
|
||
`<div class="section-grille"><div class="section-tete">${t} <span class="compte">${l.length}</span></div><div class="grille-cartes">${l.map(([s, i]) => carteServeur(s, i)).join('')}</div></div>`).join('');
|
||
}
|
||
function inspecteurServeur() {
|
||
if (!peut('editer_le_plan')) return inspecteurMachineDuSite();
|
||
const cible = document.getElementById('detail');
|
||
if (selServeur == null || !serveurs[selServeur]) {
|
||
hoteOpCourant = '';
|
||
cible.innerHTML = '<div class="detail"><div class="vide"><b>Aucun serveur sélectionné</b>Choisis une tuile à gauche, ou ajoute un serveur.</div></div>';
|
||
return;
|
||
}
|
||
const s = serveurs[selServeur];
|
||
const vi = (intrants && intrants.valeurs) || {};
|
||
const libDef = (d) => '(défaut' + (d ? ' : ' + d : '') + ')';
|
||
const optFonction = Object.keys(nomenclature.fonctions || {}).map(f => `<option value="${echapper(f)}" ${f === s.fonction ? 'selected' : ''}>${echapper(f)}</option>`).join('');
|
||
const optEtat = ['planifie', 'actif'].map(e => `<option value="${e}" ${e === s.etat ? 'selected' : ''}>${e}</option>`).join('');
|
||
const optDeListe = (liste, sel, lib) => `<option value="" ${sel ? '' : 'selected'}>${echapper(lib)}</option>`
|
||
+ [...new Set([...(liste || []), ...(sel ? [sel] : [])])].map(v => `<option value="${echapper(v)}" ${v === sel ? 'selected' : ''}>${echapper(v)}</option>`).join('');
|
||
const set = new Set(listeDepuisTexte(s.integrations));
|
||
const tousRoles = [...new Set([...integrationsDispo, ...set])];
|
||
const cases = tousRoles.length
|
||
? tousRoles.map(role => `<label class="integ-case" title="${echapper(role)}"><input type="checkbox" ${set.has(role) ? 'checked' : ''} onchange="basculerIntegration('${echapper(role)}', this.checked)"><span>${echapper(role.replace(/^client_/, ''))}</span></label>`).join('')
|
||
: '<span class="dsn">aucun rôle client_* disponible</span>';
|
||
// Integrations universelles : montrees, jamais cochables. Sans cet affichage,
|
||
// un plan silencieux se lirait « cet hote n'est pas supervise » — l'inverse
|
||
// exact de la politique. L'exemption `sauf_role` s'evalue ici comme au moteur.
|
||
const rendus = new Set(applications.filter(a => a.hote === s.nom).map(a => a.groupe));
|
||
const univ = Object.entries(integrationsUniv)
|
||
.filter(([role, p]) => !(p.sauf_role && rendus.has(p.sauf_role)))
|
||
.map(([role, p]) => `<span class="integ-case integ-univ" title="${echapper(p.raison || '')}">✓ ${echapper(role.replace(/^client_/, ''))}</span>`).join('');
|
||
const exempts = Object.entries(integrationsUniv)
|
||
.filter(([role, p]) => p.sauf_role && rendus.has(p.sauf_role))
|
||
.map(([role, p]) => `<span class="integ-case integ-exempt" title="Exempté : cet hôte rend le service ${echapper(p.sauf_role)}">— ${echapper(role.replace(/^client_/, ''))}</span>`).join('');
|
||
const info = infoFonction(s.fonction);
|
||
const zoneHint = info ? `<span class="zone-hint" title="Dérivé de la nomenclature">${echapper(info.libelle)} · VLAN ${info.vlan}</span>` : '';
|
||
const h = hotes.find(x => x.nom === s.nom);
|
||
const apps = applications.filter(a => a.hote === s.nom);
|
||
const appsHtml = apps.length
|
||
? `<div class="ch-bases">${apps.map(a => `<div class="ch-base"><span class="badge-bd">app</span> <button type="button" class="lien-objet" onclick="ouvrirApp('${echapper(a.id)}')">${echapper(a.id || '(sans id)')}</button>${a.expose ? ` <span class="mono">→ ${echapper(listeDepuisTexte(a.expose).join(', '))}</span>` : ''}</div>`).join('')}</div>`
|
||
: '<div class="ch-stub">aucune application sur cet hôte</div>';
|
||
const bds = bdServeurs.filter(x => x.hote === s.nom);
|
||
const bdHtml = bds.length
|
||
? `<div class="ch-bases">${bds.map(x => `<div class="ch-base"><span class="badge-bd">${echapper(x.type)}</span> <button type="button" class="lien-objet" onclick="ouvrirBaseServeur('${echapper(x.nom)}')">${echapper(x.nom)}</button> <span class="mono">:${echapper(x.port)}</span></div>`).join('')}</div>`
|
||
: '<div class="ch-stub">aucun serveur de base ici</div>';
|
||
hoteOpCourant = s.nom;
|
||
const hi = h ? hotes.indexOf(h) : -1;
|
||
let boutons = '';
|
||
// CE QUE LA PORTEE INTERDIT NE SE PROPOSE PAS COMME LE RESTE. Le bouton demeure —
|
||
// le retirer ferait croire que le geste n'existe pas — mais il est inerte et porte
|
||
// sa raison en infobulle. Meme geste que les integrations universelles, affichees
|
||
// en lecture seule plutot qu'omises.
|
||
const inerte = (pouvoir) => peut(pouvoir) ? ''
|
||
: ` disabled title="${echapper(pourquoiPas(pouvoir))}" style="opacity:.45"`;
|
||
if (h) boutons += `<button type="button"${peut('materialiser')
|
||
? ' title="Cloner la VM depuis le golden template (Proxmox)"' : inerte('materialiser')} onclick="lancer('creer', ${hi})">🖥 Créer la VM</button>`;
|
||
if (h && h.etat === 'actif' && !hoteBloque(h)) {
|
||
const clsDep = estProduction ? 'danger' : 'primaire';
|
||
boutons += `<button type="button" id="btn-verifier"${inerte('configurer')} onclick="lancer('verifier', ${hi})">Vérifier</button>`
|
||
+ `<button type="button" id="btn-deployer" class="${clsDep}"${inerte('configurer')} onclick="lancer('deployer', ${hi})">Déployer</button>`;
|
||
}
|
||
let pied;
|
||
if (boutons) {
|
||
const bloque = h && h.etat === 'actif' && hoteBloque(h);
|
||
const hint = bloque ? `<span class="hint-dep">Prérequis manquant : ${[...new Set((h.groupes || []).flatMap(g => manquantsPourGroupe(g)))].join(', ')}</span>` : '';
|
||
pied = `<div class="pied">${estProduction ? '<span class="badge-prod">PROD</span>' : ''}${hint}<span class="grandit"></span>${boutons}</div>`;
|
||
} else {
|
||
pied = `<div class="pied"><span class="hint-dep">Applique le plan pour instancier cet hôte (⚙ Appliquer le plan)</span></div>`;
|
||
}
|
||
const statBadge = s.statut === 'divergence' ? '<span class="badge-statut bloque">divergent</span>'
|
||
: s.statut === 'absent' ? '<span class="badge-statut attente">non instancié</span>'
|
||
: '<span class="badge-statut ok">réconcilié</span>';
|
||
cible.innerHTML = `<article class="detail">
|
||
<div class="detail-tete"><div class="detail-nom"><span class="detail-nom-txt">${echapper(s.nom || '(sans nom)')}</span>${zoneHint}</div>
|
||
<span class="grandit"></span>${statBadge}
|
||
<button type="button" class="danger" title="Retirer ce serveur" onclick="retirerServeur()">✕</button></div>
|
||
<div class="stats">${tuile(s.vmid, 'VMID')}${tuile(s.adresse_ip, 'IP')}${tuile(s.vlan, 'VLAN')}</div>
|
||
<div class="dsn" style="padding:5px 2px"><b>Plan</b> : ${echapper(s.etat)} · <b>Réel</b> : ${sondes[s.nom] === true ? '<span class="pt actif"></span> vivante' + (s.adresse_ip ? ' (' + echapper(s.adresse_ip) + ')' : '') : sondes[s.nom] === false ? '<span class="pt bloq"></span> injoignable' : '<span class="pt plan"></span> non sondé'}</div>
|
||
<div class="section-tete" style="margin:4px 2px 2px">Identité — saisie du plan (surcharge les défauts)</div>
|
||
${formulaireDepuisSchema('serveurs', s, 'definirServeur', s.nom, "definirServeur.bind(null,'nom')", 'nom')}
|
||
<div class="grille">
|
||
<label class="champ" style="grid-column:1/-1"><span>Intégrations (rôles client)</span><div class="integ-cases">${univ}${exempts}${cases}</div><span class="dsn" style="font-size:11px">Les ✓ sont la politique des rôles : elles s'appliquent à tout hôte et ne se décochent pas.</span></label>
|
||
</div>
|
||
<div class="section-tete" style="margin:12px 2px 2px">Applications ici</div>${appsHtml}
|
||
<div class="section-tete" style="margin:12px 2px 2px">Bases ici</div>${bdHtml}
|
||
<div class="dsn" style="padding:6px 2px;color:#94a3b8">VMID / IP / VLAN sont <b>dérivés</b>. Sauvegarde (Ctrl+S) puis « Appliquer le plan » pour régénérer l'inventaire.</div>
|
||
${pied}
|
||
</article>`;
|
||
majBoutonsDeploiement();
|
||
}
|
||
|
||
function serveurValeurValide(cle, v) {
|
||
const s = String(v == null ? '' : v).trim();
|
||
if (s === '') return true; // vide = dérivé / défaut, autorisé
|
||
if (cle === 'nom') return /^[a-z0-9]+(-[a-z0-9]+)*-\d+$/.test(s);
|
||
if (cle === 'memoire') return /^\d+$/.test(s) && +s >= 64 && +s <= 1048576;
|
||
if (cle === 'coeurs') return /^\d+$/.test(s) && +s >= 1 && +s <= 256;
|
||
if (cle === 'disque') return /^\d+\s*[GMgm]?$/.test(s);
|
||
return true;
|
||
}
|
||
function validerChampServeur(el) {
|
||
const ok = serveurValeurValide(el.getAttribute('data-champ'), el.value);
|
||
el.classList.toggle('invalide', !ok);
|
||
return ok;
|
||
}
|
||
|
||
async function sauvegarderServeurs() {
|
||
const champs = Array.from(document.querySelectorAll('#detail [data-champ]'));
|
||
const invalides = champs.filter(el => !validerChampServeur(el));
|
||
// Un controle qui n'inspecte AUCUN champ n'est pas un controle : si le formulaire
|
||
// cesse un jour de marquer ses contröles, on le dit plutot que de laisser passer.
|
||
if (!champs.length) { message('Formulaire non marqué : sauvegarde refusée.', 'erreur'); return; }
|
||
if (invalides.length) {
|
||
message('Corrige les ' + invalides.length + ' champ(s) en rouge avant de sauvegarder.', 'erreur');
|
||
invalides[0].focus();
|
||
return;
|
||
}
|
||
const srv = {};
|
||
const propsS = ((schemaPlan.serveurs || {}).entite || {}).properties || {};
|
||
serveurs.forEach(s => {
|
||
if (!String(s.nom).trim()) return;
|
||
const o = {};
|
||
Object.keys(propsS).forEach(cle => {
|
||
const spec = propsS[cle];
|
||
if (spec.type === 'array') {
|
||
const l = listeDepuisTexte(s[cle]); if (l.length) o[cle] = l; return;
|
||
}
|
||
const v = s[cle];
|
||
if (v === undefined || v === null || String(v).trim() === '') return;
|
||
o[cle] = (spec.type === 'integer') ? (parseInt(v, 10) || v) : v;
|
||
});
|
||
// `etat` a un defaut METIER, pas un defaut de schema : un serveur sans etat est
|
||
// planifie, jamais actif. Le declarer ici plutot qu'au schema evite de le poser
|
||
// par ecrit dans chaque plan.
|
||
if (!o.etat) o.etat = 'planifie';
|
||
srv[s.nom] = o;
|
||
});
|
||
const rep = await fetch('/api/serveurs', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: JSON.stringify({serveurs: srv})
|
||
});
|
||
const data = await rep.json();
|
||
if (!rep.ok) { message(data.erreur || 'Sauvegarde refusée.', 'erreur'); return; }
|
||
chargerServeurs(data); dessinerServeurs();
|
||
message('Serveurs (plan) sauvegardés. Pense à « Appliquer le plan ».', 'ok');
|
||
}
|
||
|
||
async function appliquerPlan() {
|
||
const t = message('Application du plan…', '', {persistant: true});
|
||
try {
|
||
const rep = await fetch('/api/instancier', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON}, body: '{}'});
|
||
const data = await rep.json();
|
||
const derniere = (data.sortie || '').split('\n').pop();
|
||
majToast(t, (data.ok ? 'Plan appliqué : ' : 'Échec : ') + derniere, data.ok ? 'ok' : 'erreur', data.ok ? 4000 : 8000);
|
||
if (data.ok) charger();
|
||
} catch (e) {
|
||
majToast(t, 'Erreur réseau : ' + e, 'erreur', 8000);
|
||
}
|
||
}
|
||
|
||
function dessinerDependances() {
|
||
const cible = document.getElementById('dependances');
|
||
const noms = Object.keys(dependances).sort();
|
||
const bloques = noms.filter(g => statutDependance(g) === 'bloque').length;
|
||
const resume = document.getElementById('dep-resume');
|
||
resume.textContent = bloques ? bloques + ' bloqués' : noms.length + ' suivis';
|
||
if (!noms.length) { cible.innerHTML = '<div class="vide" style="padding:14px">Aucune dépendance.</div>'; return; }
|
||
cible.innerHTML = noms.map(groupe => {
|
||
const config = dependances[groupe] || {};
|
||
const requis = config.requiert_groupes_actifs || [];
|
||
const statut = statutDependance(groupe);
|
||
const manquants = manquantsPourGroupe(groupe);
|
||
const detail = requis.length ? `requiert ${requis.join(', ')}` : 'aucun prérequis';
|
||
const manque = requis.length && manquants.length ? `manquant : ${manquants.join(', ')}` : '';
|
||
return `
|
||
<div class="carte-dep ${statut}" title="${echapper(config.raison || '')}">
|
||
<div class="haut"><span class="nom">${echapper(groupe)}</span><span class="chip"><span class="pt ${statut === 'ok' ? 'actif' : statut === 'bloque' ? 'bloq' : 'plan'}"></span>${libelleStatut(statut)}</span></div>
|
||
<div class="txt">${echapper(detail)}</div>
|
||
${manque ? `<div class="txt">${echapper(manque)}</div>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function tuile(valeur, libelle) {
|
||
const v = (valeur === '' || valeur == null) ? '—' : String(valeur);
|
||
return `<div class="stat"><div class="v ${v === '—' ? 'vide' : ''}">${echapper(v)}</div><div class="l">${echapper(libelle)}</div></div>`;
|
||
}
|
||
|
||
function dessinerDetail() {
|
||
// Le panneau de droite reflète la sélection de la VUE COURANTE.
|
||
if (vuePrincipale === 'flux' || vuePrincipale === 'couches' || vuePrincipale === 'reseau') return legendeLecture();
|
||
if (vuePrincipale === 'assistants') return inspecteurRunbook();
|
||
if (vuePrincipale === 'integrations') return legendeIntegrations();
|
||
if (vuePrincipale === 'applications') return inspecteurApplication();
|
||
if (vuePrincipale === 'bases') return inspecteurBase();
|
||
if (vuePrincipale === 'domaines') return inspecteurDomaine();
|
||
if (vuePrincipale === 'nomenclature') return inspecteurNomenclature();
|
||
return inspecteurServeur();
|
||
}
|
||
|
||
function majBoutonsDeploiement() {
|
||
const bv = document.getElementById('btn-verifier');
|
||
const bd = document.getElementById('btn-deployer');
|
||
const hote = hoteOpCourant || '';
|
||
if (bv) { bv.disabled = occupe; bv.title = occupe ? 'Exécution en cours' : 'Dry-run (--check --diff), aucune modification'; }
|
||
if (bd) { bd.disabled = occupe || !verifie[hote]; bd.title = !verifie[hote] ? 'Vérifiez (dry-run) d\'abord' : 'Applique les playbooks sur la VM réelle'; }
|
||
}
|
||
|
||
function fonctionDe(nom) {
|
||
const m = (nom || '').match(/^(.+)-(\d+)$/);
|
||
return m ? {fonction: m[1], seq: parseInt(m[2], 10)} : {fonction: nom || '', seq: null};
|
||
}
|
||
function infoFonction(fonction) {
|
||
const d = (nomenclature.fonctions || {})[fonction];
|
||
if (!d) return null;
|
||
const c = (nomenclature.categories || {})[d.categorie];
|
||
if (!c) return null;
|
||
// VLAN derive du seed index (miroir de inventory_rules.vlan_de) : 1000 + index*10 + zone.
|
||
const idx = (nomenclature.index != null) ? nomenclature.index : 9;
|
||
const vlan = 1000 + idx * 10 + Number(d.categorie);
|
||
return {categorie: d.categorie, service: d.service, libelle: c.libelle, vlan};
|
||
}
|
||
function echapper(valeur) {
|
||
return String(valeur).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
function ouvrirConsole(titre, hote) {
|
||
document.getElementById('console-titre').textContent = titre + ' · ' + hote;
|
||
document.getElementById('console-sortie').textContent = '';
|
||
const res = document.getElementById('console-resume'); res.hidden = true; res.innerHTML = '';
|
||
const etat = document.getElementById('console-etat');
|
||
etat.className = 'console-etat actif'; etat.textContent = '● en cours';
|
||
document.getElementById('btn-fermer-console').disabled = true;
|
||
document.getElementById('console').hidden = false;
|
||
consoleDebut = Date.now();
|
||
clearInterval(chronoConsole);
|
||
chronoConsole = setInterval(() => {
|
||
const s = Math.round((Date.now() - consoleDebut) / 1000);
|
||
etat.textContent = '● en cours · ' + s + 's';
|
||
}, 1000);
|
||
}
|
||
function ecrireConsole(t) { const p = document.getElementById('console-sortie'); p.textContent += t; p.scrollTop = p.scrollHeight; }
|
||
let dernierResumeTexte = '';
|
||
function afficherResumeErreur(resume) {
|
||
const el = document.getElementById('console-resume');
|
||
const echecs = (resume && resume.echecs) || [];
|
||
if (!echecs.length) { el.hidden = true; return; }
|
||
const lignes = echecs.map(e =>
|
||
`<li><b>${echapper(e.tache)}</b> — <span class="re-hote">${echapper(e.hote)}</span> <span class="re-type">${echapper(e.type)}</span>
|
||
<div class="re-msg">${echapper(e.message)}</div></li>`).join('');
|
||
dernierResumeTexte = echecs.map(e => `• [${e.type}] ${e.tache} @ ${e.hote} : ${e.message}`).join('\n');
|
||
el.innerHTML = `<div class="re-tete">❌ Échec — ${echecs.length} tâche(s) en erreur
|
||
<button type="button" class="fantome" onclick="copierResume()" title="Copier un résumé clair à transmettre">Copier</button></div>
|
||
<ul class="re-liste">${lignes}</ul>`;
|
||
el.hidden = false;
|
||
}
|
||
function copierResume() {
|
||
if (navigator.clipboard) navigator.clipboard.writeText(dernierResumeTexte)
|
||
.then(() => message('Résumé copié — à transmettre au mainteneur.', 'ok'), () => {});
|
||
}
|
||
function finirConsole(rc) {
|
||
clearInterval(chronoConsole); chronoConsole = null;
|
||
const s = Math.round((Date.now() - consoleDebut) / 1000);
|
||
const etat = document.getElementById('console-etat');
|
||
etat.className = 'console-etat ' + (rc === 0 ? 'ok' : 'echec');
|
||
etat.textContent = (rc === 0 ? '✓ succès' : '✗ échec (rc=' + rc + ')') + ' · ' + s + 's';
|
||
document.getElementById('btn-fermer-console').disabled = false;
|
||
}
|
||
function fermerConsole() { if (!occupe) document.getElementById('console').hidden = true; }
|
||
|
||
function ouvrirModale(titre, texte) {
|
||
document.getElementById('modale-titre').textContent = titre;
|
||
document.getElementById('modale-texte').textContent = texte;
|
||
const champ = document.getElementById('modale-vault');
|
||
champ.value = motVault || '';
|
||
document.getElementById('modale').hidden = false;
|
||
setTimeout(() => champ.focus(), 0);
|
||
return new Promise(res => { modaleResolveur = res; });
|
||
}
|
||
function confirmerModale() {
|
||
const v = document.getElementById('modale-vault').value;
|
||
document.getElementById('modale').hidden = true;
|
||
const r = modaleResolveur; modaleResolveur = null;
|
||
if (r) r(v);
|
||
}
|
||
function annulerModale() {
|
||
document.getElementById('modale').hidden = true;
|
||
const r = modaleResolveur; modaleResolveur = null;
|
||
if (r) r(null);
|
||
}
|
||
|
||
function ouvrirIntrants() {
|
||
if (!intrants) { message('Intrants indisponibles (rechargez).', 'erreur'); return; }
|
||
rendreIntrants();
|
||
document.getElementById('intrants').hidden = false;
|
||
}
|
||
function fermerIntrants() { document.getElementById('intrants').hidden = true; }
|
||
|
||
function rendreIntrants() {
|
||
const corps = document.getElementById('intrants-corps');
|
||
const schema = intrants.schema || [];
|
||
const valeurs = intrants.valeurs || {};
|
||
const sections = [];
|
||
const parSection = {};
|
||
const proprioDe = {};
|
||
schema.forEach(ch => {
|
||
if (!parSection[ch.section]) { parSection[ch.section] = []; sections.push(ch.section); }
|
||
parSection[ch.section].push(ch);
|
||
proprioDe[ch.section] = ch.proprietaire || 'tenant';
|
||
});
|
||
let html = '';
|
||
sections.forEach(sec => {
|
||
// Le panneau melangeait deux proprietaires sans le dire. Un tenant et son
|
||
// hebergeur n'ont ni le meme depot, ni le meme cycle de vie : editer une
|
||
// section « hébergeur » touche TOUS les tenants du cluster.
|
||
const heb = proprioDe[sec] === 'hebergeur';
|
||
const chip = heb
|
||
? '<span class="intr-proprio heb" title="Propriété de l\'HÉBERGEUR — écrit hors du dépôt du tenant ; la modification vaut pour tous ses tenants">hébergeur</span>'
|
||
: '<span class="intr-proprio ten" title="Propriété du TENANT — écrit dans son dépôt ; n\'affecte que lui">tenant</span>';
|
||
html += '<div class="intr-section"><div class="intr-tete">' + echapper(sec) + chip + '</div><div class="intr-grille">';
|
||
parSection[sec].forEach(ch => {
|
||
const v = valeurs[ch.variable];
|
||
let badge;
|
||
if (ch.classe === 'constante') badge = '<span class="badge-classe const" title="Valeur unique, non surchargeable">Constante</span>';
|
||
else if (ch.classe === 'catalogue') badge = '<span class="badge-classe cat" title="Liste de valeurs autorisées, proposée dans les menus déroulants">Catalogue</span>';
|
||
else badge = '<span class="badge-classe def" title="Valeur par défaut, surchargeable dans les instances">Défaut</span>';
|
||
const lib = echapper(ch.libelle) + badge;
|
||
if (ch.type === 'bool') {
|
||
const coche = (v === true || v === 'true' || v === 'True') ? ' checked' : '';
|
||
html += '<label class="champ"><span>' + lib + '</span>'
|
||
+ '<input type="checkbox" data-intrant="' + echapper(ch.variable) + '" data-type="bool"' + coche + '></label>';
|
||
} else if (ch.type === 'liste') {
|
||
const txt = Array.isArray(v) ? v.join(', ') : (v == null ? '' : v);
|
||
html += '<label class="champ" style="grid-column:1/-1"><span>' + lib + ' <span class="unite">· séparés par des virgules</span></span>'
|
||
+ '<input type="text" data-intrant="' + echapper(ch.variable) + '" data-type="liste"'
|
||
+ ' placeholder="ex. asgard, pve-02, pve-03" value="' + echapper(txt) + '"></label>';
|
||
} else {
|
||
const t = ch.type === 'int' ? 'number' : 'text';
|
||
html += '<label class="champ"><span>' + lib + '</span>'
|
||
+ '<input type="' + t + '" data-intrant="' + echapper(ch.variable) + '" data-type="' + echapper(ch.type) + '"'
|
||
+ ' value="' + echapper(v == null ? '' : v) + '"></label>';
|
||
}
|
||
});
|
||
html += '</div></div>';
|
||
});
|
||
// Nomenclature en lecture seule (lue ailleurs : vue Nomenclature)
|
||
const n = nomenclature || {};
|
||
if (Object.keys(n).length) {
|
||
html += '<div class="intr-section"><div class="intr-tete">Nomenclature (lecture seule)</div>'
|
||
+ '<div class="intr-ro">';
|
||
if (n.supernet) html += 'Supernet : <b>' + echapper(n.supernet) + '</b><br>';
|
||
if (n.domaine || n.domaine_interne) html += 'Domaine : <b>' + echapper(n.domaine || n.domaine_interne) + '</b><br>';
|
||
const nbCat = n.categories ? Object.keys(n.categories).length : 0;
|
||
const nbFon = n.fonctions ? Object.keys(n.fonctions).length : 0;
|
||
html += nbCat + ' catégorie(s), ' + nbFon + ' fonction(s) déclarée(s).';
|
||
html += '<br><span style="font-style:italic">Modifiable dans le plan (nomenclature.yml), pas ici.</span>';
|
||
html += '</div></div>';
|
||
}
|
||
// Secrets attendus : informatif, jamais saisis ici
|
||
const secrets = intrants.secrets_attendus || [];
|
||
if (secrets.length) {
|
||
html += '<div class="intr-section"><div class="intr-tete">Secrets attendus (Vault — CLI)</div>'
|
||
+ '<div class="intr-ro">Ces clés ne sont jamais saisies ni stockées ici. Édite-les via <b>ansible-vault</b>.'
|
||
+ '<div class="intr-secrets">'
|
||
+ secrets.map(s => '<code>' + echapper(s) + '</code>').join('')
|
||
+ '</div></div></div>';
|
||
}
|
||
const f = intrants.fichiers || {};
|
||
const chemins = Object.values(f).map(x => echapper(x)).join(', ');
|
||
if (chemins) html += '<div class="intr-ro" style="margin-top:2px">Écrit dans : <b>' + chemins + '</b></div>';
|
||
corps.innerHTML = html;
|
||
}
|
||
|
||
async function enregistrerIntrants() {
|
||
const valeurs = {};
|
||
document.querySelectorAll('#intrants-corps [data-intrant]').forEach(el => {
|
||
const k = el.getAttribute('data-intrant');
|
||
valeurs[k] = el.type === 'checkbox' ? el.checked : el.value;
|
||
});
|
||
const ancienDomaine = (intrants.valeurs || {}).domaine_interne;
|
||
const nouveauDomaine = valeurs.domaine_interne;
|
||
if (nouveauDomaine !== undefined && nouveauDomaine !== ancienDomaine) {
|
||
if (!confirm('Changer le domaine interne (« ' + (ancienDomaine || '∅') + ' » → « ' + (nouveauDomaine || '∅')
|
||
+ ' ») impacte tout l\'écosystème (DNS, certificats, noms d\'hôtes). Continuer ?')) return;
|
||
}
|
||
try {
|
||
const reponse = await fetch('/api/intrants', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
|
||
body: JSON.stringify({valeurs}),
|
||
});
|
||
if (!reponse.ok) {
|
||
let detail = '';
|
||
try { detail = (await reponse.json()).erreur || ''; } catch (e) { detail = await reponse.text(); }
|
||
message('Échec de l\'enregistrement des intrants : ' + detail, 'erreur');
|
||
return;
|
||
}
|
||
const data = await reponse.json();
|
||
intrants = data.intrants || intrants;
|
||
nomenclature = data.nomenclature || nomenclature;
|
||
fermerIntrants();
|
||
message('Intrants de base enregistrés.', 'ok');
|
||
} catch (e) {
|
||
message('Erreur réseau : ' + e, 'erreur');
|
||
}
|
||
}
|
||
|
||
async function lancer(mode, index) {
|
||
const hote = hotes[index] ? hotes[index].nom : '';
|
||
if (!hote) { message('Hôte introuvable.', 'erreur'); return; }
|
||
if (occupe) return;
|
||
// Un seul entonnoir pour les quatre gestes : la coupure se dit ici, et elle nomme
|
||
// sa raison. « Interdit » sans raison envoie chercher une panne qui n'existe pas.
|
||
const pouvoirMode = POUVOIR_PAR_MODE[mode];
|
||
if (pouvoirMode && !peut(pouvoirMode)) {
|
||
message(pourquoiPas(pouvoirMode), 'erreur', {duree: 9000});
|
||
return;
|
||
}
|
||
let vault = '';
|
||
if ((mode === 'deployer' || mode === 'pousser') && !verifie[hote]) { message('Vérifiez (dry-run) d\'abord.', 'erreur'); return; }
|
||
if (mode === 'creer' || mode === 'deployer' || mode === 'pousser' || mode === 'verifier') {
|
||
if (estProduction && (mode === 'deployer' || mode === 'pousser')) {
|
||
const saisi = prompt('⚠️ PRODUCTION — action réelle sur « ' + hote + ' ».\n'
|
||
+ 'Tape le nom exact de l\'hôte pour confirmer :');
|
||
if (saisi === null) return;
|
||
if (saisi.trim() !== hote) { message('Confirmation annulée : le nom ne concorde pas.', 'erreur'); return; }
|
||
}
|
||
const av = estProduction ? '⚠️ PRODUCTION. ' : '';
|
||
const txt = mode === 'creer'
|
||
? 'Clone la VM « ' + hote + ' » depuis le golden template sur Proxmox (création réelle).'
|
||
: mode === 'pousser'
|
||
? av + 'Déploie l\'hôte « ' + hote + ' » (applique ses playbooks, en root). Non transactionnel.'
|
||
: mode === 'verifier'
|
||
? 'Dry-run (--check), AUCUNE modification. Mot de passe vault pour déchiffrer les secrets — laisse VIDE si cet hôte n\'a pas de secrets.'
|
||
: av + 'Applique les playbooks de groupes sur la VM réelle, en root (sudo). Non transactionnel.';
|
||
const titre = (mode === 'creer' ? 'Créer la VM — ' : mode === 'pousser' ? 'Déployer — ' : mode === 'verifier' ? 'Vérifier — ' : 'Déployer — ') + hote;
|
||
vault = await ouvrirModale(titre, txt);
|
||
if (vault === null) return;
|
||
motVault = vault;
|
||
}
|
||
occupe = true; majBoutonsDeploiement();
|
||
const titreConsole = {verifier: 'Vérification (dry-run)', deployer: 'Déploiement', creer: 'Création VM (clone)', pousser: 'Déploiement'}[mode] || mode;
|
||
ouvrirConsole(titreConsole, hote);
|
||
try {
|
||
const rep = await fetch('/api/' + mode, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
|
||
body: JSON.stringify(vault ? {hote, vault} : {hote})
|
||
});
|
||
if (!rep.ok || !rep.body) {
|
||
const d = await rep.json().catch(() => ({}));
|
||
ecrireConsole((d.erreur || 'Refusé.') + '\n'); finirConsole(1);
|
||
occupe = false; majBoutonsDeploiement(); return;
|
||
}
|
||
const lecteur = rep.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let rc = null; let brut = '';
|
||
while (true) {
|
||
const {value, done} = await lecteur.read();
|
||
if (done) break;
|
||
let texte = dec.decode(value, {stream: true});
|
||
brut += texte;
|
||
const m = texte.match(/__FIN__ rc=(-?\d+)/);
|
||
if (m) { rc = parseInt(m[1], 10); }
|
||
texte = texte.replace(/\n?__RESUME__ .*\n?/, '').replace(/\n?__FIN__ rc=-?\d+\n?/, '');
|
||
if (texte) ecrireConsole(texte);
|
||
}
|
||
if (rc === null) rc = 1;
|
||
const mr = brut.match(/__RESUME__ (.+)/);
|
||
if (rc !== 0 && mr) { try { afficherResumeErreur(JSON.parse(mr[1])); } catch (e) {} }
|
||
finirConsole(rc);
|
||
if (mode === 'verifier' && rc === 0) { verifie[hote] = true; message('Vérification réussie — déploiement débloqué pour ' + hote + '.', 'ok'); }
|
||
else if (mode === 'creer') { message(rc === 0 ? 'VM « ' + hote + ' » créée — hôte marqué actif.' : 'Échec de la création.', rc === 0 ? 'ok' : 'erreur'); if (rc === 0) appliquerPlan(); else sonder(); }
|
||
else if (mode === 'deployer' || mode === 'pousser') { verifie[hote] = false; message(rc === 0 ? 'Déploiement terminé — hôte marqué actif.' : 'Déploiement en échec.', rc === 0 ? 'ok' : 'erreur'); if (rc === 0) appliquerPlan(); else sonder(); }
|
||
} catch (e) {
|
||
ecrireConsole('Erreur client : ' + e.message + '\n'); finirConsole(1);
|
||
}
|
||
occupe = false; majBoutonsDeploiement();
|
||
}
|
||
|
||
|
||
// ═══ LES ASSISTANTS — un runbook, son ordre, et pourquoi cet ordre-la ═══════════
|
||
//
|
||
// CE QUE CETTE VUE AJOUTE. Les autres vues editent le PLAN ; celle-ci conduit des
|
||
// GESTES. Le Makefile porte 132 cibles qui disent chacune ce qu'elle fait, et aucune
|
||
// ne dit dans quel ordre. Cette connaissance vivait en prose dans les documents :
|
||
// l'exploitant devait la lire ailleurs, puis revenir cliquer ici.
|
||
//
|
||
// RIEN N'EST ECRIT EN DUR ICI. La liste, les libelles, les natures et les variables
|
||
// viennent de `/api/runbooks`, qui joint `docs/runbooks-construction.yml` au Makefile
|
||
// au moment de servir. Une cible renommee se voit donc ici, elle ne s'invente pas.
|
||
let runbooks = [], rbSel = null, rbEcarts = [], rbEtats = {};
|
||
let etapeResolveur = null;
|
||
|
||
async function chargerRunbooks() {
|
||
try {
|
||
const r = await fetch('/api/runbooks', {headers: {'X-Jeton': JETON}});
|
||
const d = await r.json();
|
||
runbooks = d.runbooks || [];
|
||
rbEcarts = d.ecarts || [];
|
||
if (d.erreur) message('Registre des runbooks : ' + d.erreur, 'erreur', {duree: 9000});
|
||
if (!rbSel && runbooks.length) rbSel = (runbooks.find(x => x.conduisible) || runbooks[0]).id;
|
||
} catch (e) { message('Assistants indisponibles : ' + e.message, 'erreur'); }
|
||
}
|
||
|
||
function rbCourant() { return runbooks.find(x => x.id === rbSel) || null; }
|
||
function rbEtat(id, i) { return (rbEtats[id] || {})[i] || null; }
|
||
|
||
// LA SEQUENCE EST UN GARDE-FOU, PAS UNE DECORATION. Une etape qui ECRIT suppose que la
|
||
// precedente a reussi — sinon on batit sur un terrain qu'on n'a pas verifie. Une
|
||
// etape qui MESURE reste toujours offerte : mesurer apres un echec est exactement ce
|
||
// qu'on fait pour comprendre. Les etapes facultatives ne bloquent personne.
|
||
function etapeBloquee(rb, i) {
|
||
// Hors de portee de CETTE console : l'etape se lit, elle ne se lance pas.
|
||
if (rb.etapes[i].conduisible === false) return true;
|
||
if (rb.etapes[i].nature === 'mesure') return false;
|
||
for (let j = i - 1; j >= 0; j--) {
|
||
if (rb.etapes[j].facultative) continue;
|
||
return rbEtat(rb.id, j) !== 'ok';
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function dessinerAssistants() {
|
||
const g = document.getElementById('grilles');
|
||
if (!runbooks.length) {
|
||
g.innerHTML = '<div class="hint-dep">Chargement des assistants…</div>';
|
||
chargerRunbooks().then(() => {
|
||
if (vuePrincipale === 'assistants') { dessinerAssistants(); dessinerDetail(); }
|
||
});
|
||
return;
|
||
}
|
||
const puces = runbooks.map(rb => {
|
||
const n = rb.etapes.length;
|
||
return '<button type="button" class="rb-puce' + (rb.id === rbSel ? ' on' : '')
|
||
+ (rb.conduisible ? '' : ' hors') + '" onclick="choisirRunbook(\'' + rb.id + '\')"'
|
||
+ ' title="' + echapper(rb.conduisible ? rb.but : rb.raison) + '">'
|
||
+ echapper(rb.titre) + '<span class="chip">' + n + '</span></button>';
|
||
}).join('');
|
||
const rb = rbCourant();
|
||
let corps = '';
|
||
if (rb) {
|
||
corps = rb.etapes.map((e, i) => {
|
||
const etat = rbEtat(rb.id, i);
|
||
const bloquee = etapeBloquee(rb, i);
|
||
const cls = etat === 'ok' ? ' faite' : etat === 'echec' ? ' ratee' : bloquee ? ' bloquee' : '';
|
||
const vars = (e.variables || []).map(v => v.nom + '=…').join(' ');
|
||
const fixes = Object.entries(e.fixes || {}).map(([k, v]) => k + '=' + v).join(' ');
|
||
const meta = [e.duree ? 'durée observée ' + e.duree : '',
|
||
e.facultative ? 'facultative' : ''].filter(Boolean).join(' · ');
|
||
return '<div class="rb-etape' + cls + '">'
|
||
+ '<div class="rb-num">' + (i + 1) + '</div>'
|
||
+ '<div><div><span class="rb-cible">make ' + echapper(e.cible)
|
||
+ (vars ? ' ' + echapper(vars) : '') + (fixes ? ' ' + echapper(fixes) : '')
|
||
+ '</span><span class="rb-nature ' + e.nature + '">' + e.nature + '</span></div>'
|
||
+ '<div class="rb-libelle">' + echapper(e.libelle || '(aucun libellé au Makefile)') + '</div>'
|
||
+ '<div class="rb-pourquoi">' + echapper(e.pourquoi) + '</div>'
|
||
+ (meta ? '<div class="rb-meta">' + echapper(meta) + '</div>' : '')
|
||
+ (etat === 'ok' ? '<div class="rb-meta">✔ réussie dans cette session</div>' : '')
|
||
+ (etat === 'echec' ? '<div class="rb-meta">✘ échouée — la suite reste bloquée</div>' : '')
|
||
+ (e.conduisible === false ? '<div class="rb-meta">⊘ hors de portée de cette console — '
|
||
+ echapper(e.raison || '') + '</div>' : '')
|
||
+ '</div>'
|
||
+ '<div><button type="button" class="' + (e.nature === 'destructif' ? '' : 'primaire')
|
||
+ '" ' + (bloquee ? 'disabled' : '') + ' onclick="lancerEtape(' + i + ')">'
|
||
+ (e.nature === 'mesure' ? 'Mesurer' : e.nature === 'destructif' ? 'Détruire…' : 'Lancer')
|
||
+ '</button></div></div>';
|
||
}).join('');
|
||
}
|
||
const alerte = rbEcarts.length
|
||
? '<div class="hint-dep" style="border-color:#8a3b3b">Le registre des runbooks porte '
|
||
+ rbEcarts.length + ' écart(s) avec le Makefile : <code>python3 scripts/runbooks.py verifier</code></div>'
|
||
: '';
|
||
g.innerHTML = alerte + '<div class="rb-liste">' + puces + '</div>' + corps;
|
||
}
|
||
|
||
function choisirRunbook(id) { rbSel = id; dessinerAssistants(); dessinerDetail(); }
|
||
|
||
function inspecteurRunbook() {
|
||
const d = document.getElementById('detail');
|
||
const rb = rbCourant();
|
||
if (!rb) { d.innerHTML = '<div class="detail"><div class="hint-dep">Aucun assistant.</div></div>'; return; }
|
||
const compte = rb.etapes.filter((e, i) => rbEtat(rb.id, i) === 'ok').length;
|
||
d.innerHTML = '<div class="detail"><div class="section-tete">' + echapper(rb.titre) + '</div>'
|
||
+ '<div class="rb-but">' + echapper(rb.but) + '</div>'
|
||
+ '<div class="hint-dep" style="margin-top:10px">Portée : <code>' + echapper(rb.portee)
|
||
+ '</code> · ' + compte + '/' + rb.etapes.length + ' étape(s) réussie(s) dans cette session.</div>'
|
||
+ (function () {
|
||
const fermees = rb.etapes.filter(e => e.conduisible === false);
|
||
if (!fermees.length) return '';
|
||
return '<div class="hint-dep" style="margin-top:10px;border-color:#8a3b3b">'
|
||
+ fermees.length + ' étape(s) sur ' + rb.etapes.length + ' hors de portée ici : '
|
||
+ echapper(fermees.map(e => e.cible).join(', ')) + '. ' + echapper(fermees[0].raison || '')
|
||
+ '</div>';
|
||
})()
|
||
+ (rb.doc ? '<div class="hint-dep" style="margin-top:10px">Le récit complet : <code>'
|
||
+ echapper(rb.doc) + '</code></div>' : '')
|
||
+ '<div class="hint-dep" style="margin-top:10px">Les libellés viennent du <code>Makefile</code>, '
|
||
+ 'lus à chaque affichage ; l\'ordre et le « pourquoi » viennent de '
|
||
+ '<code>docs/runbooks-construction.yml</code>. Une étape d\'écriture attend que la '
|
||
+ 'précédente ait réussi ; une mesure reste toujours offerte.</div></div>';
|
||
}
|
||
|
||
// La modale d'etape : les champs que le REGISTRE declare, et rien d'autre.
|
||
function ouvrirModaleEtape(rb, e, i) {
|
||
document.getElementById('etape-titre').textContent = (i + 1) + '. make ' + e.cible;
|
||
const avert = e.nature === 'destructif'
|
||
? '⚠️ GESTE DESTRUCTIF. ' : e.nature === 'ecriture' ? 'Ce geste écrit. ' : 'Aucune écriture. ';
|
||
document.getElementById('etape-texte').textContent = avert + e.pourquoi;
|
||
const champs = document.getElementById('etape-champs');
|
||
let html = (e.variables || []).map(v => {
|
||
if (v.valeurs) {
|
||
return '<label class="champ"><span>' + echapper(v.invite || v.nom)
|
||
+ (v.facultatif ? ' (facultatif)' : '') + '</span><select id="var-' + v.nom + '">'
|
||
+ (v.facultatif ? '<option value=""></option>' : '')
|
||
+ v.valeurs.map(x => '<option>' + echapper(x) + '</option>').join('') + '</select></label>';
|
||
}
|
||
return '<label class="champ"><span>' + echapper(v.invite || v.nom)
|
||
+ (v.facultatif ? ' (facultatif)' : '') + '</span><input id="var-' + v.nom
|
||
+ '" autocomplete="off" placeholder="' + echapper(v.exemple || '') + '"></label>';
|
||
}).join('');
|
||
// LA CONFIRMATION ECRITE EST LE SEUL GARDE-FOU QUI RESISTE A UN CLIC DISTRAIT.
|
||
if (e.nature === 'destructif') {
|
||
html += '<label class="champ"><span>Pour confirmer, écris <b>DETRUIRE</b></span>'
|
||
+ '<input id="var-__confirmation" autocomplete="off"></label>';
|
||
}
|
||
champs.innerHTML = html;
|
||
document.getElementById('etape-vault').value = motVault || '';
|
||
document.getElementById('etape').hidden = false;
|
||
return new Promise(res => { etapeResolveur = res; });
|
||
}
|
||
function confirmerEtape() {
|
||
const rb = rbCourant(); if (!rb) return;
|
||
const r = etapeResolveur;
|
||
const boite = document.getElementById('etape-champs');
|
||
const valeurs = {};
|
||
let confirmation = null;
|
||
boite.querySelectorAll('input,select').forEach(el => {
|
||
const nom = el.id.replace(/^var-/, '');
|
||
if (nom === '__confirmation') confirmation = el.value.trim();
|
||
else valeurs[nom] = el.value.trim();
|
||
});
|
||
document.getElementById('etape').hidden = true;
|
||
etapeResolveur = null;
|
||
if (r) r({valeurs, vault: document.getElementById('etape-vault').value, confirmation});
|
||
}
|
||
function annulerEtape() {
|
||
document.getElementById('etape').hidden = true;
|
||
const r = etapeResolveur; etapeResolveur = null;
|
||
if (r) r(null);
|
||
}
|
||
|
||
async function lancerEtape(i) {
|
||
const rb = rbCourant(); if (!rb || occupe) return;
|
||
const e = rb.etapes[i];
|
||
if (e.conduisible === false) { message(e.raison || rb.raison, 'erreur', {duree: 9000}); return; }
|
||
if (etapeBloquee(rb, i)) { message('L\'étape précédente n\'a pas réussi.', 'erreur'); return; }
|
||
const rep0 = await ouvrirModaleEtape(rb, e, i);
|
||
if (rep0 === null) return;
|
||
if (e.nature === 'destructif' && rep0.confirmation !== 'DETRUIRE') {
|
||
message('Confirmation annulée : le mot ne concorde pas.', 'erreur'); return;
|
||
}
|
||
motVault = rep0.vault;
|
||
occupe = true; majBoutonsDeploiement();
|
||
ouvrirConsole(rb.titre + ' — étape ' + (i + 1), e.cible);
|
||
try {
|
||
const rep = await fetch('/api/runbook-etape', {
|
||
method: 'POST', headers: {'Content-Type': 'application/json', 'X-Jeton': JETON},
|
||
body: JSON.stringify({runbook: rb.id, cible: e.cible, index: i,
|
||
valeurs: rep0.valeurs, vault: rep0.vault})
|
||
});
|
||
if (!rep.ok || !rep.body) {
|
||
const d = await rep.json().catch(() => ({}));
|
||
ecrireConsole((d.erreur || 'Refusé.') + '\n'); finirConsole(1);
|
||
occupe = false; majBoutonsDeploiement(); return;
|
||
}
|
||
const lecteur = rep.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let rc = null, brut = '';
|
||
while (true) {
|
||
const {value, done} = await lecteur.read();
|
||
if (done) break;
|
||
let texte = dec.decode(value, {stream: true});
|
||
brut += texte;
|
||
const m = texte.match(/__FIN__ rc=(-?\d+)/);
|
||
if (m) rc = parseInt(m[1], 10);
|
||
texte = texte.replace(/\n?__RESUME__ .*\n?/, '').replace(/\n?__FIN__ rc=-?\d+\n?/, '');
|
||
if (texte) ecrireConsole(texte);
|
||
}
|
||
if (rc === null) rc = 1;
|
||
const mr = brut.match(/__RESUME__ (.+)/);
|
||
if (rc !== 0 && mr) { try { afficherResumeErreur(JSON.parse(mr[1])); } catch (err) {} }
|
||
finirConsole(rc);
|
||
rbEtats[rb.id] = rbEtats[rb.id] || {};
|
||
rbEtats[rb.id][i] = rc === 0 ? 'ok' : 'echec';
|
||
message(rc === 0 ? 'Étape ' + (i + 1) + ' réussie.' : 'Étape ' + (i + 1) + ' en échec.',
|
||
rc === 0 ? 'ok' : 'erreur');
|
||
} catch (err) {
|
||
ecrireConsole('Erreur client : ' + err.message + '\n'); finirConsole(1);
|
||
rbEtats[rb.id] = rbEtats[rb.id] || {}; rbEtats[rb.id][i] = 'echec';
|
||
}
|
||
occupe = false; majBoutonsDeploiement();
|
||
if (vuePrincipale === 'assistants') { dessinerAssistants(); dessinerDetail(); }
|
||
}
|
||
|
||
function sauvegarderVue() {
|
||
if (occupe) return;
|
||
if (vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') sauvegarderServeurs();
|
||
else if (vuePrincipale === 'applications') sauvegarderApplications();
|
||
else if (vuePrincipale === 'bases') sauvegarderBases();
|
||
else if (vuePrincipale === 'domaines') sauvegarderDomaines();
|
||
else if (vuePrincipale === 'nomenclature') sauvegarderNomenclature();
|
||
else message('Vue en lecture seule (Flux / Couches) — édite dans Serveurs / Applications / Bases / Domaines.');
|
||
}
|
||
|
||
function ordreServeursVisibles() {
|
||
const vis = serveurs.map((s, i) => [s, i]).filter(([s]) => correspondServeur(s));
|
||
return [...vis.filter(([s]) => s.etat === 'actif'), ...vis.filter(([s]) => s.etat !== 'actif')].map(([, i]) => i);
|
||
}
|
||
function naviguerServeurs(delta) {
|
||
const ordre = ordreServeursVisibles();
|
||
if (!ordre.length) return;
|
||
let pos = ordre.indexOf(selServeur);
|
||
pos = pos < 0 ? (delta > 0 ? 0 : ordre.length - 1)
|
||
: Math.min(ordre.length - 1, Math.max(0, pos + delta));
|
||
choisirServeur(ordre[pos]);
|
||
const c = document.getElementById('tuile-srv-' + ordre[pos]);
|
||
if (c) c.scrollIntoView({block: 'nearest', behavior: 'smooth'});
|
||
}
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && modaleResolveur) { annulerModale(); return; }
|
||
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); sauvegarderVue(); return; }
|
||
const dansSaisie = /^(INPUT|SELECT|TEXTAREA)$/.test((document.activeElement || {}).tagName || '');
|
||
if (dansSaisie || e.ctrlKey || e.metaKey || e.altKey) return;
|
||
const vues = {'1': 'serveurs', '2': 'applications', '3': 'bases'};
|
||
if (vues[e.key]) { setVue(vues[e.key]); return; }
|
||
if (e.key === '/') { e.preventDefault(); const f = document.getElementById('filtre'); if (f) f.focus(); return; }
|
||
if (vuePrincipale !== 'serveurs') return;
|
||
if (e.key === 'j') { naviguerServeurs(1); return; }
|
||
if (e.key === 'k') { naviguerServeurs(-1); return; }
|
||
if (e.key === 'v') { const b = document.getElementById('btn-verifier'); if (b && !b.disabled) b.click(); return; }
|
||
if (e.key === 'd') { const b = document.getElementById('btn-deployer'); if (b && !b.disabled) b.click(); return; }
|
||
});
|
||
|
||
// Validation inline des champs du plan (vue Serveurs) — liseré rouge avant l'envoi.
|
||
document.addEventListener('input', e => {
|
||
if (e.target && e.target.matches && e.target.matches('[data-v]')) validerChampServeur(e.target);
|
||
});
|
||
|
||
// Garde-fou : prévenir la perte d'éditions de plan non sauvegardées.
|
||
window.addEventListener('beforeunload', e => {
|
||
if (serveursModifie || applicationsModifie || basesModifie || domainesModifie) { e.preventDefault(); e.returnValue = ''; }
|
||
});
|
||
|
||
// ------ Info-bulles d'aide au survol de la description d'un champ ------
|
||
(function () {
|
||
const AIDES = {
|
||
'Adresse IP': "Adresse IPv4 de la VM sur son VLAN. Ex. 10.10.12.11. Souvent dérivée — clic « Proposer ».",
|
||
'CIDR': "Masque réseau en bits. 24 = /24 (255.255.255.0).",
|
||
'Passerelle': "Routeur de sortie du VLAN. En général le .1 du sous-réseau.",
|
||
'VLAN': "Identifiant 802.1Q (1–4094). Dérivé de la fonction via la nomenclature.",
|
||
'Pont': "Pont réseau Proxmox. Ex. vmbr0.",
|
||
'DNS': "Résolveur DNS de la VM. En général le DNS interne de l'écosystème.",
|
||
'Utilisateur Ansible': "Compte technique utilisé par Ansible. Par défaut : ansible.",
|
||
'VMID': "Identifiant unique Proxmox. Dérivé (catégorie + service + rang).",
|
||
'Nœud': "Hôte Proxmox cible. Ex. pve-01.",
|
||
'Stockage': "Pool de stockage Proxmox du disque. Ex. local-zfs.",
|
||
'Disque': "Taille du disque système. Ex. 32G. Dérivée des logiciels hébergés + socle.",
|
||
'Mémoire': "RAM en Mo. Dérivée des logiciels hébergés + socle (avec marge).",
|
||
'Cœurs': "vCPU alloués. Dérivés des logiciels hébergés (maximum, pas additif).",
|
||
'Nom': "Identifiant unique du serveur de base, sans espace.",
|
||
'Type': "Moteur de la base. Ex. postgres, redis.",
|
||
'Hôte': "VM qui héberge ce service (nom du serveur).",
|
||
'Hôte (VM)': "VM cible où l'application est déployée.",
|
||
'Port': "Port d'écoute du service.",
|
||
'Groupe Ansible': "Groupe/rôle Set-OPS appliqué. Ex. serveur_postgresql.",
|
||
'Groupe (rôle)': "Rôle Set-OPS appliqué à l'application.",
|
||
'Identifiant': "Clé courte et unique, sans espace.",
|
||
'Portée': "À qui la base est offerte : groupe, hôte ou application.",
|
||
'Consommateur': "L'entité (groupe / hôte / application) qui utilise la base.",
|
||
'Usage': "Étiquette libre. Ex. principale.",
|
||
'Serveur': "Serveur de base de données qui héberge cette base.",
|
||
'Base': "Nom de la base de données.",
|
||
'Propriétaire': "Rôle SQL propriétaire de la base.",
|
||
'Secret (Vault)': "Nom de la variable Ansible Vault contenant le mot de passe.",
|
||
'Requiert (applis)': "Applications prérequises, séparées par des virgules.",
|
||
'Expose (FQDN)': "Nom(s) public(s) exposé(s). Ex. forge.exemple.ca.",
|
||
'Serveur (fonction-NN)': "Nom = fonction + rang. Ex. web-frontal-03.",
|
||
};
|
||
const bulle = document.createElement('div');
|
||
bulle.id = 'infobulle';
|
||
bulle.setAttribute('role', 'tooltip');
|
||
document.body.appendChild(bulle);
|
||
|
||
function libelleDe(el) {
|
||
const n = el.firstChild;
|
||
return ((n && n.nodeType === 3 ? n.textContent : el.textContent) || '').trim();
|
||
}
|
||
function aideDe(el) {
|
||
if (!el || !el.matches || !el.matches('.champ > span')) return null;
|
||
return AIDES[libelleDe(el)] || null;
|
||
}
|
||
function placer(el) {
|
||
const r = el.getBoundingClientRect();
|
||
bulle.style.left = Math.max(8, Math.min(r.left, innerWidth - bulle.offsetWidth - 8)) + 'px';
|
||
let haut = r.top - bulle.offsetHeight - 8;
|
||
if (haut < 8) haut = r.bottom + 8; // bascule sous le champ si pas la place
|
||
bulle.style.top = haut + 'px';
|
||
}
|
||
document.addEventListener('mouseover', e => {
|
||
const aide = aideDe(e.target);
|
||
if (!aide) return;
|
||
bulle.textContent = aide;
|
||
bulle.classList.add('on');
|
||
placer(e.target);
|
||
});
|
||
document.addEventListener('mouseout', e => {
|
||
if (e.target && e.target.matches && e.target.matches('.champ > span')) bulle.classList.remove('on');
|
||
});
|
||
|
||
// Affordance : souligné pointillé sur les descriptions qui ont une aide.
|
||
function marquer(racine) {
|
||
if (!racine.querySelectorAll) return;
|
||
racine.querySelectorAll('.champ > span').forEach(s => {
|
||
if (AIDES[libelleDe(s)]) s.classList.add('a-aide');
|
||
});
|
||
}
|
||
new MutationObserver(muts => muts.forEach(m =>
|
||
m.addedNodes.forEach(n => { if (n.nodeType === 1) marquer(n); })
|
||
)).observe(document.body, { childList: true, subtree: true });
|
||
marquer(document);
|
||
})();
|
||
|
||
charger().catch(err => message(err.message, 'erreur'));
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def basculer_instance(nom: str) -> str:
|
||
"""Repointe le symlink `instance/` vers un depot frere. Renvoie la nouvelle cible.
|
||
|
||
Memes garde-fous que `make instance-utiliser`, plus une validation STRICTE : `nom`
|
||
doit etre une instance DECOUVERTE (dossier frere avec plan/nomenclature.yml), ce
|
||
qui interdit toute traversee de chemin (pas de '/', '..', cible arbitraire).
|
||
"""
|
||
import instances as _inst
|
||
connues = {i["nom"] for i in _inst.decouvrir()}
|
||
if nom not in connues:
|
||
raise ValueError(f"Instance inconnue : {nom!r} (attendu : {', '.join(sorted(connues)) or 'aucune'}).")
|
||
cible = RACINE.parent / nom
|
||
if not cible.is_dir():
|
||
raise ValueError(f"Dossier introuvable : ../{nom}")
|
||
lien = RACINE / "instance"
|
||
if lien.exists() and not lien.is_symlink():
|
||
raise ValueError("'instance' existe et n'est pas un symlink — refus.")
|
||
if lien.is_symlink() or lien.exists():
|
||
lien.unlink()
|
||
lien.symlink_to(f"../{nom}")
|
||
return os.readlink(lien)
|
||
|
||
|
||
class Gestionnaire(BaseHTTPRequestHandler):
|
||
# Inventaire de l'instance ACTIVE, resolu DYNAMIQUEMENT depuis le symlink
|
||
# `instance/` a chaque acces : ainsi la bascule d'instance (POST
|
||
# /api/instance-utiliser) prend effet sans redemarrer le serveur. Les chemins
|
||
# de plan (FICHIER_*) sont deja relatifs au symlink et suivent de meme.
|
||
# SETOPS_INVENTAIRE force un inventaire fixe (CI / usage explicite).
|
||
@property
|
||
def inventaire(self) -> Path:
|
||
force = os.environ.get("SETOPS_INVENTAIRE")
|
||
return Path(force) if force else _inventaire(INSTANCE, "principal", "production")
|
||
|
||
def log_message(self, *args) -> None: # silence par defaut
|
||
pass
|
||
|
||
def repondre(self, status: int, contenu: bytes, content_type: str) -> None:
|
||
try:
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", content_type)
|
||
self.send_header("Content-Length", str(len(contenu)))
|
||
self.end_headers()
|
||
self.wfile.write(contenu)
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass # le client a ferme la connexion : rien a faire
|
||
|
||
def repondre_json(self, status: int, data: dict) -> None:
|
||
self.repondre(status, json.dumps(data, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
|
||
|
||
def _ecrire_flux(self, texte: str) -> bool:
|
||
try:
|
||
self.wfile.write(texte.encode("utf-8", "replace"))
|
||
self.wfile.flush()
|
||
return True
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
return False
|
||
|
||
def executer_flux(self, hote: str, mode: str, vault: str | None = None) -> None:
|
||
if not MOTIF_HOTE.match(hote or ""):
|
||
self.repondre_json(400, {"erreur": "Nom d'hote invalide."})
|
||
return
|
||
tous_hotes = {h.get("nom") for h in liste_hotes(charger_yaml(self.inventaire))}
|
||
if hote not in tous_hotes:
|
||
self.repondre_json(409, {"erreur": f"{hote} est inconnu de l'inventaire (applique le plan d'abord)."})
|
||
return
|
||
# 'creer' (clone) est permis meme sur un hote planifie ; les autres exigent actif.
|
||
if mode in ("verifier", "deployer", "pousser") and hote not in hotes_actifs_fichier(self.inventaire):
|
||
self.repondre_json(409, {"erreur": f"{hote} n'est pas un hote actif sauvegarde."})
|
||
return
|
||
if mode == "deployer" and not VERIF_OK.get(hote):
|
||
self.repondre_json(409, {"erreur": "Verification (dry-run) requise avant de deployer."})
|
||
return
|
||
if not VERROU.acquire(blocking=False):
|
||
self.repondre_json(409, {"erreur": "Une execution est deja en cours."})
|
||
return
|
||
fichier_vault = None
|
||
journal = None
|
||
try:
|
||
cible = {"deployer": "deployer", "pousser": "deployer",
|
||
"creer": "creer-vm"}.get(mode, "verifier-deploiement")
|
||
env = dict(os.environ, PYTHONUNBUFFERED="1", ANSIBLE_FORCE_COLOR="0")
|
||
if vault:
|
||
# LE MOT DE PASSE SAISI S'AJOUTE AUX CLES, IL NE LES REMPLACE PAS.
|
||
#
|
||
# Verifie empiriquement le 2026-09-08, contre deux voutes jetables a mots
|
||
# de passe distincts : `ANSIBLE_VAULT_PASSWORD_FILE` et
|
||
# `ANSIBLE_VAULT_IDENTITY_LIST` se CUMULENT — Ansible essaie tous les
|
||
# secrets, et un PASSWORD_FILE errone n'empeche rien.
|
||
#
|
||
# Ca compte depuis « une voute, une cle » (2026-08-28) : creer une VM
|
||
# ouvre DEUX voutes dans la meme execution — celle du tenant et
|
||
# `underlay.vault.yml`. Un seul mot de passe ne pourrait pas les ouvrir
|
||
# toutes les deux. Ce qui sauve ce chemin, c'est que `make` calcule
|
||
# `ANSIBLE_VAULT_IDENTITY_LIST` par-dessus (Makefile, `?=`), et que les
|
||
# deux coexistent dans l'environnement de la recette.
|
||
#
|
||
# NE PAS « SIMPLIFIER » EN RETIRANT L'UN DES DEUX. Le PASSWORD_FILE sert
|
||
# le cas ou la cle n'est pas sur la machine (un runner qui n'a que la
|
||
# sienne) ; l'IDENTITY_LIST sert le poste de l'exploitant, qui les a
|
||
# toutes. Chacun couvre ce que l'autre ne couvre pas.
|
||
fd, fichier_vault = tempfile.mkstemp(prefix="setops-vault-") # 0600 par defaut
|
||
os.write(fd, str(vault).encode("utf-8"))
|
||
os.close(fd)
|
||
env["ANSIBLE_VAULT_PASSWORD_FILE"] = fichier_vault
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||
self.send_header("Cache-Control", "no-cache")
|
||
self.end_headers()
|
||
# Persistance : un journal horodaté par exécution, en plus du streaming live.
|
||
dossier_logs = self.inventaire.parents[2] / "logs"
|
||
dossier_logs.mkdir(parents=True, exist_ok=True)
|
||
chemin_log = dossier_logs / f"{hote}-{cible}-{time.strftime('%Y%m%dT%H%M%S')}.log"
|
||
journal = open(chemin_log, "w", encoding="utf-8")
|
||
entete = f"$ make {cible} HOTE={hote}\n\n"
|
||
journal.write(entete)
|
||
self._ecrire_flux(entete)
|
||
tache_courante = ""
|
||
echecs: list[dict] = []
|
||
proc = subprocess.Popen(
|
||
["make", cible, f"HOTE={hote}"],
|
||
cwd=str(RACINE), env=env,
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
|
||
)
|
||
try:
|
||
for ligne in proc.stdout:
|
||
journal.write(ligne)
|
||
if ligne.startswith("TASK ["):
|
||
deb, fin = ligne.find("["), ligne.find("]")
|
||
if -1 < deb < fin:
|
||
tache_courante = ligne[deb + 1:fin]
|
||
elif ("FAILED!" in ligne or "UNREACHABLE!" in ligne) and \
|
||
("fatal:" in ligne or ligne.lstrip().startswith("failed:")):
|
||
echec = _extraire_echec(ligne, tache_courante)
|
||
if echec:
|
||
echecs.append(echec)
|
||
if not self._ecrire_flux(ligne):
|
||
# Navigateur déconnecté : on continue à journaliser jusqu'au bout.
|
||
for reste in proc.stdout:
|
||
journal.write(reste)
|
||
break
|
||
finally:
|
||
rc = proc.wait()
|
||
if mode == "verifier" and rc == 0:
|
||
VERIF_OK[hote] = True
|
||
elif mode in ("deployer", "pousser"):
|
||
VERIF_OK.pop(hote, None)
|
||
# Auto-actif : un hote qu'on vient de materialiser (clone reussi) ou de
|
||
# deployer passe 'actif' dans le plan. Reflet fidele : materialise = actif.
|
||
if rc == 0 and mode in ("creer", "deployer", "pousser"):
|
||
try:
|
||
marquer_serveur_actif(hote)
|
||
except Exception:
|
||
pass
|
||
if rc != 0 and echecs:
|
||
self._ecrire_flux("__RESUME__ " + json.dumps({"echecs": echecs[:6]}, ensure_ascii=False) + "\n")
|
||
journal.write(f"\n__FIN__ rc={rc}\n")
|
||
self._ecrire_flux(f"\n[journal : {os.path.relpath(chemin_log, RACINE)}]\n__FIN__ rc={rc}\n")
|
||
finally:
|
||
if journal:
|
||
try:
|
||
journal.close()
|
||
except OSError:
|
||
pass
|
||
if fichier_vault:
|
||
try:
|
||
os.remove(fichier_vault)
|
||
except OSError:
|
||
pass
|
||
VERROU.release()
|
||
|
||
def executer_runbook(self, runbook: str, cible: str, valeurs: dict,
|
||
vault: str | None = None, index: int | None = None) -> None:
|
||
"""Une etape DECLAREE d'un runbook — et rien d'autre.
|
||
|
||
LE NAVIGATEUR NE NOMME PAS UNE COMMANDE, IL NOMME UNE PLACE. On ne lance pas
|
||
« la cible que la page demande » : on lance ce que le registre declare a cette
|
||
place-la, avec les seules variables qu'il declare. Une page compromise, ou
|
||
simplement perimee, ne peut donc pas reclamer `raser` depuis un runbook de mesure.
|
||
|
||
C'est la meme discipline que `POUVOIR_REQUIS` : ce qui protege n'est pas un bouton
|
||
grise, c'est un serveur qui refuse — et qui DIT pourquoi.
|
||
"""
|
||
try:
|
||
import runbooks as _rb
|
||
etape = _rb.etape_declaree(runbook, cible, RACINE, index)
|
||
except Exception as exc: # noqa: BLE001
|
||
self.repondre_json(500, {"erreur": f"Registre des runbooks illisible : {exc}"})
|
||
return
|
||
if etape is None:
|
||
self.repondre_json(409, {"erreur": f"« {cible} » n'est pas une etape declaree "
|
||
f"du runbook « {runbook} ». Rien n'a ete lance."})
|
||
return
|
||
|
||
# Les variables : celles du registre, dans l'ordre du registre.
|
||
args: list[str] = []
|
||
for v in etape["variables"]:
|
||
brut = str(valeurs.get(v["nom"], "")).strip()
|
||
if not brut:
|
||
if v.get("facultatif"):
|
||
continue
|
||
self.repondre_json(400, {"erreur": f"{v['nom']} est requis pour cette etape."})
|
||
return
|
||
if not MOTIF_VALEUR.match(brut):
|
||
self.repondre_json(400, {"erreur": f"{v['nom']} : valeur refusee (caracteres "
|
||
f"interdits, ou commence par un tiret)."})
|
||
return
|
||
if v.get("valeurs") and brut not in v["valeurs"]:
|
||
self.repondre_json(400, {"erreur": f"{v['nom']} : « {brut} » n'est pas une "
|
||
f"valeur admise ({', '.join(v['valeurs'])})."})
|
||
return
|
||
args.append(f"{v['nom']}={brut}")
|
||
# Les valeurs IMPOSEES par l'etape (CONFIRMER=true et compagnie) ne viennent
|
||
# jamais du navigateur : une confirmation qu'on peut envoyer soi-meme n'en est pas
|
||
# une. Le garde-fou reel est la confirmation ECRITE exigee cote page, plus la
|
||
# garde propre a la cible.
|
||
for clef, val in (etape["fixes"] or {}).items():
|
||
args.append(f"{clef}={val}")
|
||
|
||
if not VERROU.acquire(blocking=False):
|
||
self.repondre_json(409, {"erreur": "Une execution est deja en cours."})
|
||
return
|
||
fichier_vault = None
|
||
journal = None
|
||
try:
|
||
env = dict(os.environ, PYTHONUNBUFFERED="1", ANSIBLE_FORCE_COLOR="0")
|
||
if vault:
|
||
fd, fichier_vault = tempfile.mkstemp(prefix="setops-vault-")
|
||
os.write(fd, str(vault).encode("utf-8"))
|
||
os.close(fd)
|
||
env["ANSIBLE_VAULT_PASSWORD_FILE"] = fichier_vault
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||
self.send_header("Cache-Control", "no-cache")
|
||
self.end_headers()
|
||
# Un journal par execution. Chez un locataire il vit dans l'instance ; sur un
|
||
# runner de site il n'y a pas d'instance, et `tmp/` est deja hors depot.
|
||
dossier_logs = (INSTANCE / "logs") if INSTANCE.exists() else (RACINE / "tmp" / "logs")
|
||
dossier_logs.mkdir(parents=True, exist_ok=True)
|
||
chemin_log = dossier_logs / f"{runbook}-{cible}-{time.strftime('%Y%m%dT%H%M%S')}.log"
|
||
journal = open(chemin_log, "w", encoding="utf-8")
|
||
entete = f"$ make {cible} {' '.join(args)}\n\n"
|
||
journal.write(entete)
|
||
self._ecrire_flux(entete)
|
||
tache_courante = ""
|
||
echecs: list[dict] = []
|
||
proc = subprocess.Popen(
|
||
["make", cible, *args],
|
||
cwd=str(RACINE), env=env,
|
||
stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1,
|
||
)
|
||
try:
|
||
for ligne in proc.stdout:
|
||
journal.write(ligne)
|
||
if ligne.startswith("TASK ["):
|
||
deb, fin = ligne.find("["), ligne.find("]")
|
||
if -1 < deb < fin:
|
||
tache_courante = ligne[deb + 1:fin]
|
||
elif ("FAILED!" in ligne or "UNREACHABLE!" in ligne) and \
|
||
("fatal:" in ligne or ligne.lstrip().startswith("failed:")):
|
||
echec = _extraire_echec(ligne, tache_courante)
|
||
if echec:
|
||
echecs.append(echec)
|
||
if not self._ecrire_flux(ligne):
|
||
for reste in proc.stdout:
|
||
journal.write(reste)
|
||
break
|
||
finally:
|
||
rc = proc.wait()
|
||
if rc != 0 and echecs:
|
||
self._ecrire_flux("__RESUME__ " + json.dumps({"echecs": echecs[:6]},
|
||
ensure_ascii=False) + "\n")
|
||
journal.write(f"\n__FIN__ rc={rc}\n")
|
||
self._ecrire_flux(f"\n[journal : {os.path.relpath(chemin_log, RACINE)}]\n__FIN__ rc={rc}\n")
|
||
finally:
|
||
if journal:
|
||
try:
|
||
journal.close()
|
||
except OSError:
|
||
pass
|
||
if fichier_vault:
|
||
try:
|
||
os.remove(fichier_vault)
|
||
except OSError:
|
||
pass
|
||
VERROU.release()
|
||
|
||
def do_GET(self) -> None:
|
||
chemin = urlparse(self.path).path
|
||
try:
|
||
if chemin == "/":
|
||
page = HTML.replace("__JETON__", JETON)
|
||
self.repondre(200, page.encode("utf-8"), "text/html; charset=utf-8")
|
||
elif chemin == "/favicon.ico":
|
||
self.repondre(204, b"", "image/x-icon")
|
||
elif chemin == "/api/contexte":
|
||
self.repondre_json(200, contexte())
|
||
elif chemin == "/api/inventaire":
|
||
# LE GESTIONNAIRE NE SAIT PLUS LAQUELLE LUI REPOND, et c'est le but : il
|
||
# demandait « est-ce un site ? » pour choisir entre deux assembleurs, et
|
||
# les deux ont derive. Il demande maintenant sa charge a sa console.
|
||
self.repondre_json(200, console(self.inventaire).charge())
|
||
elif chemin == "/api/sondes":
|
||
self.repondre_json(200,
|
||
sonder_donnees(console(self.inventaire).inventaire_brut()))
|
||
elif chemin == "/api/devis-reseau":
|
||
tenants = devis_reseau.decouvrir()
|
||
self.repondre_json(200, {
|
||
"config": devis_reseau.generer(tenants) if tenants else "",
|
||
"tenants": [{"nom": nom, "index": n["index"]} for nom, _, n in tenants],
|
||
})
|
||
elif chemin == "/api/devis-opnsense":
|
||
# Import paresseux : ce module lit l'underlay et les inventaires de tous
|
||
# les tenants ; une erreur ici ne doit pas empecher le reste du panneau.
|
||
try:
|
||
import devis_opnsense as _dop
|
||
tenants = _dop.decouvrir()
|
||
self.repondre_json(200, {
|
||
"config": _dop.rendre(_dop.construire(tenants)) if tenants else "",
|
||
})
|
||
except Exception as exc:
|
||
self.repondre_json(200, {"config": "", "erreur": str(exc)})
|
||
elif chemin == "/api/runbooks":
|
||
# Import paresseux, comme les devis : un registre casse ne doit pas
|
||
# emporter le reste de la console avec lui.
|
||
try:
|
||
import runbooks as _rb
|
||
ctx = contexte()
|
||
_c = console(self.inventaire)
|
||
_juger = _c.peut_conduire
|
||
liste = []
|
||
for rb in _rb.assembler(RACINE):
|
||
# CHAQUE ETAPE SE PESE SEULE (2026-09-20, mesure sur TechnoLibre).
|
||
# Juge au seul runbook, une unique etape qui materialise fermait
|
||
# toute la sequence a un locataire — qui perdait le droit de
|
||
# deployer SA flotte. Il la conduit donc, et bute precisement la
|
||
# ou il faut : sur la machine a engendrer.
|
||
etapes = []
|
||
for etape in rb["etapes"]:
|
||
ok, pourquoi = _juger(etape["portee"])
|
||
etapes.append({**etape, "conduisible": ok, "raison": pourquoi})
|
||
ok_rb, pourquoi_rb = _juger(rb["portee"])
|
||
liste.append({
|
||
**rb,
|
||
"etapes": etapes,
|
||
# ON NE CACHE PAS CE QU'ON NE PEUT PAS FAIRE. Un runbook hors
|
||
# de portee reste VISIBLE et se lit : savoir que le geste
|
||
# existe, et chez qui il se fait, fait partie du metier.
|
||
"conduisible": any(e["conduisible"] for e in etapes),
|
||
"raison": pourquoi_rb if not ok_rb else "",
|
||
})
|
||
self.repondre_json(200, {"runbooks": liste, "contexte": ctx,
|
||
"ecarts": _rb.verifier(RACINE)})
|
||
except Exception as exc: # noqa: BLE001
|
||
self.repondre_json(200, {"runbooks": [], "erreur": str(exc)})
|
||
elif chemin == "/api/instances":
|
||
import instances as _inst
|
||
import instance_creer as _ic
|
||
liste = _inst.decouvrir()
|
||
self.repondre_json(200, {
|
||
"instances": liste,
|
||
"collisions": {str(k): v for k, v in _inst.collisions(liste).items()},
|
||
"modeles": sorted(_ic.modeles_disponibles()),
|
||
"index_pris": sorted(_ic.index_federes_utilises()),
|
||
})
|
||
else:
|
||
self.repondre_json(404, {"erreur": "Introuvable"})
|
||
except Exception as exc:
|
||
self.repondre_json(500, {"erreur": str(exc)})
|
||
|
||
def do_POST(self) -> None:
|
||
chemin = urlparse(self.path).path
|
||
if self.headers.get("X-Jeton") != JETON:
|
||
self.repondre_json(403, {"erreur": "Jeton manquant ou invalide."})
|
||
return
|
||
longueur = int(self.headers.get("Content-Length", "0") or 0)
|
||
corps = self.rfile.read(longueur).decode("utf-8") if longueur else ""
|
||
try:
|
||
donnees = json.loads(corps) if corps else {}
|
||
except json.JSONDecodeError:
|
||
self.repondre_json(400, {"erreur": "Corps JSON invalide."})
|
||
return
|
||
# LA GARDE EST ICI, PAS DANS LE NAVIGATEUR. Un bouton grise est une politesse ;
|
||
# ce qui protege, c'est un serveur qui refuse. Et il DIT pourquoi : « interdit »
|
||
# sans raison envoie chercher une panne la ou il n'y en a pas.
|
||
ctx = contexte()
|
||
besoin = POUVOIR_REQUIS.get(chemin)
|
||
if besoin == "selon-le-runbook":
|
||
# LA PORTEE VIENT DU REGISTRE, PAS DU CORPS DE LA REQUETE. Le navigateur nomme
|
||
# un runbook ; c'est le fichier declare qui dit ce que ce runbook exige.
|
||
besoin = None
|
||
try:
|
||
import runbooks as _rb
|
||
vise = str(donnees.get("runbook", ""))
|
||
_i = donnees.get("index")
|
||
_etape = _rb.etape_declaree(vise, str(donnees.get("cible", "")), RACINE,
|
||
_i if isinstance(_i, int) else None)
|
||
# La portee de L'ETAPE. Celle du runbook n'est que son defaut, et juger
|
||
# sur elle fermerait a un locataire une sequence qui est la sienne.
|
||
portee_etape = _etape["portee"] if _etape else None
|
||
connu = any(r["id"] == vise for r in _rb.assembler(RACINE))
|
||
except Exception: # noqa: BLE001
|
||
portee_etape, connu = None, False
|
||
if not connu:
|
||
self.repondre_json(409, {"erreur": f"Runbook inconnu : « {donnees.get('runbook', '')} »."})
|
||
return
|
||
# Etape introuvable a cette place : `executer_runbook` le dira mieux que nous,
|
||
# avec le nom et la place. On le laisse refuser, sans rien lancer.
|
||
if portee_etape is not None:
|
||
ok_etape, pourquoi = console(self.inventaire).peut_conduire(portee_etape)
|
||
if not ok_etape:
|
||
self.repondre_json(409, {"erreur": pourquoi, "portee": ctx["portee"]})
|
||
return
|
||
if besoin and not ctx["peut"].get(besoin):
|
||
self.repondre_json(409, {"erreur": ctx["raison"].get(besoin, "Geste hors de "
|
||
"la portee de cette console."),
|
||
"portee": ctx["portee"], "pouvoir": besoin})
|
||
return
|
||
try:
|
||
if chemin == "/api/inventaire":
|
||
self.repondre_json(409, {"erreur": "hosts.yml est genere depuis le plan. "
|
||
"Edite instance/plan/serveurs.yml et instance/plan/applications.yml "
|
||
"(vues Serveurs / Applications), puis « Appliquer le plan »."})
|
||
elif chemin == "/api/bases":
|
||
valider_bases(donnees, charger_applications(FICHIER_APPLICATIONS))
|
||
ecrire_bases(FICHIER_BASES, donnees)
|
||
self.repondre_json(200, inventaire_api(self.inventaire))
|
||
elif chemin == "/api/applications":
|
||
valider_applications(donnees, charger_domaines(FICHIER_DOMAINES),
|
||
charger_serveurs(FICHIER_SERVEURS))
|
||
ecrire_applications(FICHIER_APPLICATIONS, donnees)
|
||
self.repondre_json(200, inventaire_api(self.inventaire))
|
||
elif chemin == "/api/serveurs":
|
||
valider_serveurs(donnees, charger_nomenclature(FICHIER_NOMENCLATURE))
|
||
ecrire_serveurs(FICHIER_SERVEURS, donnees)
|
||
self.repondre_json(200, inventaire_api(self.inventaire))
|
||
elif chemin == "/api/nomenclature":
|
||
valider_nomenclature(donnees, charger_serveurs(FICHIER_SERVEURS))
|
||
ecrire_nomenclature(FICHIER_NOMENCLATURE, donnees)
|
||
self.repondre_json(200, inventaire_api(self.inventaire))
|
||
elif chemin == "/api/domaines":
|
||
valider_domaines(donnees)
|
||
ecrire_domaines(FICHIER_DOMAINES, donnees)
|
||
self.repondre_json(200, inventaire_api(self.inventaire))
|
||
elif chemin == "/api/intrants":
|
||
ecrire_intrants(donnees)
|
||
self.repondre_json(200, inventaire_api(self.inventaire))
|
||
elif chemin == "/api/instance-utiliser":
|
||
cible = basculer_instance(str(donnees.get("nom", "")).strip())
|
||
self.repondre_json(200, {"ok": True, "cible": cible})
|
||
elif chemin == "/api/instance-creer":
|
||
import instance_creer as _ic
|
||
brut_idx = str(donnees.get("index", "")).strip()
|
||
_ic.creer(str(donnees.get("nom", "")).strip(),
|
||
str(donnees.get("modele", "")).strip(),
|
||
int(brut_idx) if brut_idx else None)
|
||
self.repondre_json(200, {"ok": True, "nom": str(donnees.get("nom", "")).strip()})
|
||
elif chemin == "/api/instancier":
|
||
# Cliquer « Appliquer le plan » EST l'intention explicite : on force
|
||
# (le diff-refus est un garde-fou CLI ; ici le clic le confirme). git = filet.
|
||
resultat = subprocess.run(
|
||
["python3", str(RACINE / "scripts/instancier.py"), "appliquer", "--force"],
|
||
capture_output=True, text=True, cwd=str(RACINE))
|
||
self.repondre_json(200, {"ok": resultat.returncode == 0,
|
||
"sortie": (resultat.stdout + resultat.stderr).strip()})
|
||
elif chemin == "/api/verifier":
|
||
self.executer_flux(str(donnees.get("hote", "")), "verifier", donnees.get("vault"))
|
||
elif chemin == "/api/deployer":
|
||
self.executer_flux(str(donnees.get("hote", "")), "deployer", donnees.get("vault"))
|
||
elif chemin == "/api/creer":
|
||
self.executer_flux(str(donnees.get("hote", "")), "creer", donnees.get("vault"))
|
||
elif chemin == "/api/runbook-etape":
|
||
_idx = donnees.get("index")
|
||
self.executer_runbook(str(donnees.get("runbook", "")),
|
||
str(donnees.get("cible", "")),
|
||
donnees.get("valeurs") or {},
|
||
donnees.get("vault"),
|
||
int(_idx) if isinstance(_idx, int) else None)
|
||
elif chemin == "/api/pousser":
|
||
self.executer_flux(str(donnees.get("hote", "")), "pousser", donnees.get("vault"))
|
||
else:
|
||
self.repondre_json(404, {"erreur": "Introuvable"})
|
||
except Exception as exc:
|
||
self.repondre_json(400, {"erreur": str(exc)})
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description="Interface web locale pour gerer un inventaire Set-OPS.")
|
||
parser.add_argument("--inventaire", type=Path, default=INVENTAIRE_DEFAUT)
|
||
parser.add_argument("--hote", default="127.0.0.1")
|
||
parser.add_argument("--port", type=int, default=8765)
|
||
args = parser.parse_args()
|
||
|
||
serveur = ThreadingHTTPServer((args.hote, args.port), Gestionnaire)
|
||
print(f"Interface inventaire: http://{args.hote}:{args.port}/")
|
||
print(f"Inventaire: {Gestionnaire.inventaire}")
|
||
serveur.serve_forever()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|