GUI : panneau Intrants de base, info-bulles, source d'identité partagée
Nouveau panneau « ⚙ Intrants » pour saisir d'un endroit unique les valeurs communes à l'écosystème, avec distinction constantes (non surchargeables) vs défauts (surchargeables dans les instances). Les secrets ne sont jamais saisis ni affichés (garde INTRANTS_CLES_INTERDITES + filtrage par schéma) ; un domaine_interne vide est refusé et son changement demande confirmation. Ajoute aussi les info-bulles d'aide au survol des champs. domaine_interne/chezlepro_timezone consolidés en une source partagée (inventories/partage/intrants-identite.yml). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5d30a3a608
commit
6c1d26ebf7
4 changed files with 567 additions and 1 deletions
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -1,6 +1,45 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-06-25
|
||||
## 2026-06-30
|
||||
|
||||
### Ajouté
|
||||
- **Panneau « Intrants de base » dans le GUI.** Un bouton ⚙ Intrants ouvre une fenêtre
|
||||
unique pour saisir les valeurs communes à tout l'écosystème, avec une distinction
|
||||
visible entre **constantes** (valeur unique, non surchargeable) et **défauts**
|
||||
(valeurs proposées, surchargeables dans les instances). Les secrets ne sont **jamais**
|
||||
saisis ni affichés ici (garde-fou `INTRANTS_CLES_INTERDITES` + filtrage par schéma) :
|
||||
ils restent dans le Vault Ansible, édités en CLI ; le panneau les liste seulement à
|
||||
titre informatif. Un changement de `domaine_interne` (clé de voûte) demande une
|
||||
confirmation explicite ; un `domaine_interne` vide est refusé côté serveur. La
|
||||
nomenclature reste en lecture seule (modifiable dans le plan). Voir
|
||||
`docs/intrants-communs.md` et `docs/intrants-base-gui-conception.md`.
|
||||
- **Source unique d'identité partagée.** `domaine_interne` et `chezlepro_timezone`
|
||||
vivent désormais dans `inventories/partage/intrants-identite.yml`, référencé par
|
||||
symlink depuis chaque environnement (`group_vars/all/10-intrants.yml`) — fin de la
|
||||
duplication lab/production.
|
||||
- **Info-bulles d'aide sur les champs du GUI.** Survoler la description d'un champ à
|
||||
saisir affiche une bulle avec des instructions sommaires.
|
||||
|
||||
## 2026-06-28
|
||||
|
||||
### Ajouté
|
||||
- **Document de présentation de l'écosystème Chezlepro** (`docs/ecosysteme-chezlepro.md`).
|
||||
Description vulgarisée à destination client/partenaire : les piliers de l'écosystème
|
||||
souverain, le modèle reproductible (plan → génération → clonage → conformité), et un
|
||||
inventaire des **mesures de renforcement** réellement en place (SSH durci, fail2ban,
|
||||
sysctl, nftables, AppArmor, auditd, mises à jour automatiques, gestion des secrets,
|
||||
garde-fous destructifs, sécurité par l'architecture). Honnête sur le statut
|
||||
« défini/validé vs déployé ».
|
||||
|
||||
### Ajouté
|
||||
- **Dimensionnement dérivé des ressources VM.** Les cœurs/RAM/disque d'une VM sont
|
||||
désormais **estimés depuis les logiciels hébergés + le socle SE**, au lieu d'hériter
|
||||
des specs du golden template (CPU/RAM identiques pour toutes les VM auparavant).
|
||||
Chaque rôle déclare son empreinte (`roles/<rôle>/meta/empreinte.yml`) ; le
|
||||
générateur somme par hôte (marge + arrondis) et écrit `proxmox_coeurs`/
|
||||
`proxmox_memoire`/`proxmox_disque_taille` ; le clonage passe `cores`/`memory` à
|
||||
Proxmox (`omit` si absent → aucune régression). Override par hôte possible dans le
|
||||
plan (`serveurs.yml`). Voir `docs/dimensionnement-ressources.md`.
|
||||
|
||||
### Corrigé
|
||||
- **`make instancier-appliquer FORCE=1` n'honorait pas `--force`.** La recette Makefile lançait `instancier.py appliquer` sans relayer `FORCE` ; le message « Utilise FORCE=1 » était donc trompeur (un changement de plan intentionnel restait bloqué). La recette passe désormais `$(if $(FORCE),--force)`. Trouvé en dogfooding.
|
||||
|
|
|
|||
94
docs/intrants-base-gui-conception.md
Normal file
94
docs/intrants-base-gui-conception.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Note de conception — panneau « Intrants de base » du GUI
|
||||
|
||||
But : saisir depuis **un endroit unique** les [intrants communs](intrants-communs.md)
|
||||
de l'écosystème, en distinguant **constantes** et **défauts surchargeables**.
|
||||
|
||||
> Extension assumée du plan de contrôle (gelé — cf. [`positionnement.md`](positionnement.md)),
|
||||
> au même titre que le [dimensionnement](dimensionnement-ressources.md). Décidée le
|
||||
> 2026-06-26.
|
||||
|
||||
## 1. Décisions cadre (validées)
|
||||
|
||||
1. **Secrets : hors périmètre.** Le GUI n'affiche ni ne stocke aucun secret. Les
|
||||
`vault_*` et tokens Proxmox restent édités via Ansible Vault en ligne de commande.
|
||||
Le panneau peut, au plus, afficher une **liste de rappel en lecture seule** des
|
||||
secrets attendus (sans valeur).
|
||||
2. **Nomenclature : lecture seule** dans le panneau. L'édition de `supernet`/VLAN/
|
||||
catégories reste dans `plan/nomenclature.yml` (autorité unique du plan réseau).
|
||||
3. **Conception avant code** (cette note).
|
||||
|
||||
## 2. Modèle : constante vs défaut surchargeable
|
||||
|
||||
- **Constante** — une seule valeur, pas de surcharge. Éditée *uniquement* au panneau ;
|
||||
apparaît en **lecture seule** dans les formulaires d'hôte (contexte).
|
||||
- **Défaut surchargeable** — valeur globale qui **ressurgit** comme défaut là où
|
||||
l'intrant réapparaît (hôte / groupe). Le formulaire d'hôte la montre **pré-remplie
|
||||
et marquée « hérité »** ; toute saisie locale devient une **surcharge** (badge
|
||||
« surchargé » + bouton « rétablir le défaut »).
|
||||
|
||||
Classification de départ : voir [`intrants-communs.md` §2](intrants-communs.md).
|
||||
Résumé : *Constantes* = `domaine_interne`, nomenclature, accès Proxmox, golden
|
||||
template, secrets. *Défauts* = timezone, nœud/stockage/pont Proxmox, DNS internes,
|
||||
politiques de durcissement, relais SMTP, `ciuser`/compte `ansible`.
|
||||
|
||||
## 3. Persistance — fichiers cibles, sans perte de commentaires
|
||||
|
||||
Contrainte : pas de dépendance non-stdlib (souveraineté) → pas de round-trip YAML
|
||||
préservant les commentaires (ruamel). Solution : **fichiers possédés par le GUI**,
|
||||
réécrits en bloc avec un **en-tête généré** (comme `hosts.genere.yml`), à côté des
|
||||
fichiers tenus à la main.
|
||||
|
||||
| Domaine | Fichier possédé par le GUI | Tenu à la main (coexiste) |
|
||||
| --- | --- | --- |
|
||||
| Identité + politiques (défauts) | `group_vars/all/10-intrants.yml` | `group_vars/all/00-instance.yml` |
|
||||
| Proxmox (constantes + défauts) | `group_vars/proxmox/10-intrants.yml` | `group_vars/proxmox/00-base.yml` |
|
||||
| Template/durcissement (défauts) | `group_vars/modeles_vm/10-intrants.yml` | `group_vars/modeles_vm/00-base.yml` |
|
||||
|
||||
> Migration légère : convertir `group_vars/all.yml` → répertoire `group_vars/all/`
|
||||
> (Ansible le supporte nativement). Idem `proxmox.yml`, `modeles_vm.yml`. Les valeurs
|
||||
> existantes sont scindées : commentaires/contexte dans `00-*`, valeurs pilotées par
|
||||
> le GUI dans `10-intrants.yml`. La précédence Ansible reste identique.
|
||||
|
||||
Les **surcharges par hôte** continuent de vivre dans `plan/serveurs.yml` (déjà
|
||||
supporté) ; par groupe, dans le `group_vars/<groupe>/` correspondant.
|
||||
|
||||
## 4. Mécanique d'héritage (côté générateur)
|
||||
|
||||
- Le panneau écrit les **défauts** dans les `10-intrants.yml`.
|
||||
- `instancier.py` / les formulaires lisent ces défauts pour **pré-remplir** et
|
||||
marquer « hérité » ; une valeur présente dans `serveurs.yml` (hôte) **prime** et
|
||||
s'affiche « surchargé » (même logique `setdefault` que le dimensionnement).
|
||||
- Les **constantes** ne sont jamais réémises par hôte : un seul point de vérité.
|
||||
|
||||
## 5. Écran (UI)
|
||||
|
||||
- Bouton **« Intrants de base »** dans l'en-tête du GUI → panneau modal/plein écran.
|
||||
- Sections repliables par domaine : **Identité**, **Proxmox**, **Politiques de
|
||||
durcissement**, **Services centraux**, **Nomenclature (lecture seule)**,
|
||||
**Secrets attendus (lecture seule)**.
|
||||
- Chaque champ porte un badge **Constante** / **Défaut**, et l'info-bulle d'aide
|
||||
(réutilise le mécanisme `AIDES` déjà en place).
|
||||
- Bandeau d'avertissement sur `domaine_interne` (changement à fort impact : re-dérive
|
||||
zones, FQDN, base DN…) → confirmation explicite.
|
||||
|
||||
## 6. Garde-fous
|
||||
|
||||
- `--syntax-check` + `ansible-inventory --list` doivent rester verts après écriture.
|
||||
- Discipline **diff-vide** : `make instancier` montre l'impact, application via FORCE
|
||||
si intentionnel.
|
||||
- Aucune valeur secrète n'entre dans un fichier écrit par le GUI (vérifié par un test).
|
||||
|
||||
## 7. Périmètre proposé (MVP → suite)
|
||||
|
||||
- **MVP** : `domaine_interne` (constante, avec garde-fou), `chezlepro_timezone`
|
||||
(défaut), accès Proxmox (constantes), golden template (constantes), placement
|
||||
Proxmox par défaut (nœud/stockage/pont), + nomenclature et secrets en lecture seule.
|
||||
- **Suite** : politiques de durcissement (défauts par groupe), DNS internes, relais
|
||||
SMTP, endpoints services centraux.
|
||||
|
||||
## 8. Points ouverts à confirmer
|
||||
- OK pour la **migration `group_vars/*.yml` → répertoires** (§3) ? (alternative :
|
||||
réécrire les fichiers existants en bloc, au prix des commentaires).
|
||||
- Le panneau affiche-t-il la **liste de rappel des secrets attendus** (lecture seule),
|
||||
ou on n'en parle pas du tout dans le GUI ?
|
||||
- Périmètre MVP (§7) suffisant pour une première itération ?
|
||||
98
docs/intrants-communs.md
Normal file
98
docs/intrants-communs.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Intrants communs de l'écosystème
|
||||
|
||||
Recensement des **intrants communs** : les valeurs partagées par tout l'écosystème
|
||||
(par opposition aux valeurs propres à un seul hôte). Objectif : les saisir **une
|
||||
fois**, depuis un endroit unique, puis les laisser se **dériver** ou se **propager**.
|
||||
|
||||
> Concept lié : [`meta-classe.md`](meta-classe.md) (une définition instancie la flotte)
|
||||
> et [`dimensionnement-ressources.md`](dimensionnement-ressources.md).
|
||||
|
||||
## 1. Recensement par domaine
|
||||
|
||||
### A. Identité de l'instance — `group_vars/<env>/all.yml`
|
||||
- **`domaine_interne`** — domaine DNS interne (ex. `chezlepro.internal`). **Keystone** :
|
||||
zones DNS, FQDN, base DN LDAP, expéditeurs courriel, URL AC en dérivent.
|
||||
- `chezlepro_timezone` — fuseau (ex. `America/Toronto`).
|
||||
- `setops_plan_dir` — chemin du plan.
|
||||
|
||||
### B. Réseau & nomenclature — `plan/nomenclature.yml`
|
||||
- `supernet`, `cidr_hote`, `reservations`, `categories` (VLAN/sous-réseau/passerelle),
|
||||
`fonctions` (catégorie + service).
|
||||
|
||||
### C. Hyperviseur Proxmox — `group_vars/proxmox.yml` (+ `proxmox.vault.yml`)
|
||||
- `proxmox_api_host`, `proxmox_api_user`, `proxmox_api_port`, `proxmox_validate_certs`.
|
||||
- 🔒 `proxmox_api_token_id`, `proxmox_api_token_secret` *(Vault)*.
|
||||
- Golden template : `proxmox_clone_vmid_modele`, `proxmox_clone_source_nom`.
|
||||
- Placement par défaut : `proxmox_clone_noeud`, `proxmox_clone_stockage`,
|
||||
`proxmox_clone_pont`, format, complet, timeout, disque, interface, démarrer.
|
||||
|
||||
### D. Identité initiale des VM — `group_vars/modeles_vm.yml`, `creer-vm`
|
||||
- **Clé publique SSH** (cloud-init) — accès admin de toute la flotte.
|
||||
- Compte technique `ansible` (`sudo_ansible_admin_user`) + sudo NOPASSWD, `ciuser`.
|
||||
- Politiques SSH communes (port, password auth off, root login off, grace/auth tries).
|
||||
|
||||
### E. Socle durci commun — `group_vars/modeles_vm.yml`
|
||||
nftables baseline · fail2ban SSH · auditd · AppArmor · sysctl · unattended-upgrades ·
|
||||
journald (rétention) · core_dumps · systemd_ssh_auto.
|
||||
|
||||
### F. Endpoints des services centraux (les rôles `client_*` en dérivent)
|
||||
- DNS interne (`client_dns_nameservers`, zone = `domaine_interne`).
|
||||
- AC/PKI (`client_pki_ca_url` → infra-pki, provisioner).
|
||||
- IdM/LDAP (`client_ldap_uri` → idm, base DN dérivé).
|
||||
- Relais courriel (`client_smtp_relais` → infra-mail, port 25).
|
||||
- Métriques (node_exporter `:9100`).
|
||||
- Journaux (`client_journal_loki_url` → obs).
|
||||
- Temps (NTP) : **implicite** (pool Debian par défaut — aucun intrant configuré).
|
||||
|
||||
### G. Dépôts APT tiers communs
|
||||
- Smallstep (step-cli) — `client_pki` ↔ `serveur_step_ca`.
|
||||
- Grafana (alloy/loki/grafana) — `client_journal` ↔ `serveur_loki`/`serveur_grafana`.
|
||||
|
||||
### H. Secrets Vault communs 🔒 — `proxmox.vault.yml` + vault d'instance
|
||||
`proxmox_api_token_id/secret`, `vault_step_ca_password`, `vault_step_ca_fingerprint`,
|
||||
`vault_step_ca_provisioner_password`, `vault_openldap_admin`, `vault_ldap_sssd`,
|
||||
`vault_keycloak_admin`, `vault_postgresql_keycloak`, `vault_bd_keycloak`,
|
||||
`vault_forgejo_admin`, `vault_forgejo_secret_key`, `vault_forgejo_internal_token`,
|
||||
`vault_bd_forgejo`, `vault_grafana_admin`, `vault_redis`, `vault_bd_icingadb`.
|
||||
Mot de passe du Vault lui-même : saisi au déploiement.
|
||||
|
||||
### I. Exposition publique — `plan/domaines.yml`
|
||||
domaines publics, `edge`, autorité DNS, FQDN exposés.
|
||||
|
||||
## 2. Classification proposée : constante vs défaut surchargeable
|
||||
|
||||
> **Proposition à valider.** « Constante » = une seule valeur, pas de surcharge
|
||||
> (diverger casserait le modèle). « Défaut surchargeable » = valeur globale qui
|
||||
> ressurgit comme défaut là où l'intrant réapparaît (hôte / groupe / application).
|
||||
|
||||
| Intrant | Classe | Surcharge où ? |
|
||||
| --- | --- | --- |
|
||||
| `domaine_interne` | **Constante** | — |
|
||||
| Nomenclature (supernet, CIDR, catégories, fonctions) | **Constante** | — (le plan réseau est la loi) |
|
||||
| Accès Proxmox (API host/user/port/token) | **Constante** | — (un seul cluster) |
|
||||
| Golden template (vmid_modele, source_nom) | **Constante** | — |
|
||||
| Secrets Vault | **Constante** 🔒 | — (gérés à part, jamais en clair) |
|
||||
| `chezlepro_timezone` | Défaut | par hôte (rare) |
|
||||
| `proxmox_clone_noeud` / `stockage` / `pont` | Défaut | par hôte (`serveurs.yml`) |
|
||||
| DNS internes (`client_dns_nameservers`) | Défaut | par hôte / groupe |
|
||||
| Politiques durcissement (SSH, nftables, fail2ban, journald…) | Défaut | par hôte / groupe |
|
||||
| Relais SMTP, TLS internes | Défaut | par hôte / groupe |
|
||||
| `ciuser`, compte `ansible` | Défaut | rarement surchargé |
|
||||
|
||||
## 3. Saisie unique dans le GUI (cible)
|
||||
|
||||
Un panneau « Intrants de base » dans le GUI :
|
||||
- édite les **constantes** et les **défauts** au même endroit ;
|
||||
- pour un **défaut**, la valeur globale apparaît pré-remplie (et signalée comme
|
||||
« héritée ») là où l'intrant réapparaît dans le plan, surchargeable sur place ;
|
||||
- les **secrets** ne sont **jamais** stockés en clair par le GUI (cf. §H) — il en
|
||||
gère au plus les *références*, l'édition réelle restant côté Vault.
|
||||
|
||||
Persistance : les fichiers existants (`group_vars/all.yml`, `proxmox.yml`,
|
||||
`modeles_vm.yml`, `plan/nomenclature.yml`). À détailler dans la note de conception
|
||||
de la fonctionnalité GUI.
|
||||
|
||||
## 4. Incohérences repérées (à corriger)
|
||||
- `chezlepro_timezone` défini en **lab** seulement, absent de **production**.
|
||||
- `group_vars/serveur_debian.yml` **référencé** (commentaire de prod `all.yml`) mais
|
||||
**absent** des deux environnements.
|
||||
|
|
@ -48,6 +48,42 @@ 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 : source UNIQUE partagee par tous les envs (symlink depuis chaque
|
||||
# group_vars/all/). Proxmox : fichier lab (le clonage le lit en dur), cles gerees
|
||||
# mises a jour, les autres cles preservees. Les SECRETS n'y entrent jamais.
|
||||
INTRANTS_IDENTITE = INSTANCE / "inventories/partage/intrants-identite.yml"
|
||||
INTRANTS_PROXMOX = INSTANCE / "inventories/lab/group_vars/proxmox.yml"
|
||||
FICHIERS_INTRANTS = {"identite": INTRANTS_IDENTITE, "proxmox": INTRANTS_PROXMOX}
|
||||
|
||||
# (variable, fichier, classe, section, libelle, type)
|
||||
INTRANTS_SCHEMA = [
|
||||
("domaine_interne", "identite", "constante", "Identité", "Domaine DNS interne", "str"),
|
||||
("chezlepro_timezone", "identite", "defaut", "Identité", "Fuseau horaire", "str"),
|
||||
("proxmox_api_host", "proxmox", "constante", "Proxmox", "Hôte API Proxmox", "str"),
|
||||
("proxmox_api_user", "proxmox", "constante", "Proxmox", "Utilisateur API", "str"),
|
||||
("proxmox_api_port", "proxmox", "constante", "Proxmox", "Port API", "str"),
|
||||
("proxmox_validate_certs", "proxmox", "constante", "Proxmox", "Valider les certificats TLS", "bool"),
|
||||
("proxmox_clone_source_nom", "proxmox", "constante", "Proxmox", "Nom du golden template", "str"),
|
||||
("proxmox_clone_vmid_modele", "proxmox", "constante", "Proxmox", "VMID du golden template", "int"),
|
||||
("proxmox_clone_noeud", "proxmox", "defaut", "Proxmox", "Nœud Proxmox (défaut)", "str"),
|
||||
("proxmox_clone_stockage", "proxmox", "defaut", "Proxmox", "Stockage (défaut)", "str"),
|
||||
("proxmox_clone_pont", "proxmox", "defaut", "Proxmox", "Pont réseau (défaut)", "str"),
|
||||
]
|
||||
# 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",
|
||||
}
|
||||
# Rappel en lecture seule (jamais de valeur) : secrets a fournir via Ansible Vault.
|
||||
SECRETS_ATTENDUS = [
|
||||
"proxmox_api_token_id", "proxmox_api_token_secret",
|
||||
"vault_step_ca_password", "vault_step_ca_fingerprint", "vault_step_ca_provisioner_password",
|
||||
"vault_openldap_admin", "vault_ldap_sssd", "vault_keycloak_admin",
|
||||
"vault_postgresql_keycloak", "vault_bd_keycloak", "vault_forgejo_admin",
|
||||
"vault_forgejo_secret_key", "vault_forgejo_internal_token", "vault_bd_forgejo",
|
||||
"vault_grafana_admin", "vault_redis", "vault_bd_icingadb",
|
||||
]
|
||||
|
||||
# Garde-fous des executions (verifier / deployer) depuis l'interface.
|
||||
JETON = secrets.token_urlsafe(18)
|
||||
VERROU = threading.Lock()
|
||||
|
|
@ -210,9 +246,86 @@ def inventaire_api(path: Path) -> dict:
|
|||
"domaines": charger_domaines(FICHIER_DOMAINES),
|
||||
"serveurs": serveurs_reconcilies(data),
|
||||
"hotes": liste_hotes(data),
|
||||
"intrants": intrants_api(),
|
||||
}
|
||||
|
||||
|
||||
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 intrants_courants() -> dict:
|
||||
cache: dict = {}
|
||||
valeurs: dict = {}
|
||||
for variable, fcle, *_ in INTRANTS_SCHEMA:
|
||||
if fcle not in cache:
|
||||
cache[fcle] = _lire_yaml_dict(FICHIERS_INTRANTS[fcle])
|
||||
v = cache[fcle].get(variable, "")
|
||||
valeurs[variable] = "" if v is None else v
|
||||
return valeurs
|
||||
|
||||
|
||||
def intrants_api() -> dict:
|
||||
schema = [{"variable": v, "fichier": f, "classe": c, "section": s,
|
||||
"libelle": l, "type": t} for (v, f, c, s, l, t) 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:
|
||||
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
|
||||
|
||||
|
||||
def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None:
|
||||
interdits = sorted(set(valeurs) & INTRANTS_CLES_INTERDITES)
|
||||
if interdits:
|
||||
raise ValueError(f"Refus d'ecrire des cles sensibles via le GUI: {', '.join(interdits)}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") 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 {}
|
||||
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) in INTRANTS_SCHEMA}
|
||||
fichier_de = {v: f for (v, f, c, s, l, t) 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:
|
||||
_ecrire_intrants_fichier(
|
||||
INTRANTS_IDENTITE, par_fichier["identite"],
|
||||
"# 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 vont dans proxmox.vault.yml).\n"
|
||||
"# Cles d'intrants editees par le panneau « Intrants de base » du GUI.\n---\n")
|
||||
|
||||
|
||||
def ecrire_bases(path: Path, registre: dict) -> None:
|
||||
entete = (
|
||||
"# Registre des bases de donnees Set-OPS (serveurs_bd + bases_donnees).\n"
|
||||
|
|
@ -509,6 +622,16 @@ HTML = r"""<!doctype html>
|
|||
.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; }
|
||||
#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; }
|
||||
.groupes-liste { display: grid; gap: 7px; }
|
||||
.groupe { display: block; border: 1px solid var(--ligne); border-radius: var(--rayon-sm); padding: 8px 10px; background: var(--surface-2); cursor: pointer; transition: border-color .12s, background .12s; }
|
||||
.groupe:hover { border-color: var(--ligne-forte); }
|
||||
|
|
@ -569,6 +692,18 @@ HTML = r"""<!doctype html>
|
|||
.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); }
|
||||
.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); }
|
||||
|
|
@ -600,6 +735,7 @@ HTML = r"""<!doctype html>
|
|||
</div>
|
||||
<span class="recherche"><input id="filtre" type="search" placeholder="Filtrer…" oninput="filtrer(this.value)"></span>
|
||||
<button type="button" class="fantome" onclick="ajouterHote()">+ Hôte</button>
|
||||
<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="sauvegarder()">Sauvegarder</button>
|
||||
</div>
|
||||
|
|
@ -628,6 +764,20 @@ HTML = r"""<!doctype html>
|
|||
</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="console" id="console" hidden>
|
||||
<div class="console-tete">
|
||||
<span class="console-titre" id="console-titre"></span>
|
||||
|
|
@ -663,6 +813,7 @@ HTML = r"""<!doctype html>
|
|||
let verifie = {};
|
||||
let motVault = '';
|
||||
let modaleResolveur = null;
|
||||
let intrants = null;
|
||||
|
||||
const ONGLETS = {
|
||||
reseau: [
|
||||
|
|
@ -697,6 +848,7 @@ HTML = r"""<!doctype html>
|
|||
dependances = data.dependances || {};
|
||||
nomenclature = data.nomenclature || {};
|
||||
chaine = data.chaine || {};
|
||||
intrants = data.intrants || null;
|
||||
chargerBases(data);
|
||||
chargerApplications(data);
|
||||
chargerServeurs(data);
|
||||
|
|
@ -1381,6 +1533,107 @@ HTML = r"""<!doctype html>
|
|||
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 = {};
|
||||
schema.forEach(ch => {
|
||||
if (!parSection[ch.section]) { parSection[ch.section] = []; sections.push(ch.section); }
|
||||
parSection[ch.section].push(ch);
|
||||
});
|
||||
let html = '';
|
||||
sections.forEach(sec => {
|
||||
html += '<div class="intr-section"><div class="intr-tete">' + echapper(sec) + '</div><div class="intr-grille">';
|
||||
parSection[sec].forEach(ch => {
|
||||
const v = valeurs[ch.variable];
|
||||
const badge = ch.classe === 'constante'
|
||||
? '<span class="badge-classe const" title="Valeur unique, non surchargeable">Constante</span>'
|
||||
: '<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 {
|
||||
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('Nommez l\'hôte d\'abord.', 'erreur'); return; }
|
||||
|
|
@ -1450,6 +1703,85 @@ HTML = r"""<!doctype html>
|
|||
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); if (!occupe) sauvegarder(); }
|
||||
});
|
||||
|
||||
// ------ 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>
|
||||
|
|
@ -1584,6 +1916,9 @@ class Gestionnaire(BaseHTTPRequestHandler):
|
|||
valider_serveurs(donnees, charger_nomenclature(FICHIER_NOMENCLATURE))
|
||||
ecrire_serveurs(FICHIER_SERVEURS, 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/instancier":
|
||||
resultat = subprocess.run(
|
||||
["python3", str(RACINE / "scripts/instancier.py"), "appliquer"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue