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:
Daniel Allaire 2026-06-30 10:07:17 -04:00
parent 5d30a3a608
commit 6c1d26ebf7
4 changed files with 567 additions and 1 deletions

View file

@ -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.

View 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
View 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.

View file

@ -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&nbsp;; les <b>défauts</b> resurgissent
comme valeurs proposées ils réapparaissent dans les instances déclarées.
Les secrets ne sont jamais saisis ici&nbsp;: 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&nbsp;: <b>' + echapper(n.supernet) + '</b><br>';
if (n.domaine || n.domaine_interne) html += 'Domaine&nbsp;: <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&nbsp;: <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 (14094). 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"],