diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eaeca7..ea572a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,146 @@ # CHANGELOG — Set-OPS +## 2026-08-03 (suite 11) — le cluster appartient à l'hébergeur + +En ouvrant le panneau « Intrants de base », on trouvait côte à côte et sans distinction +des valeurs du **tenant** (son domaine, son realm, son modèle) et des valeurs de +l'**hébergeur** (son cluster, sa frontière, sa fabric). Deux propriétaires, deux dépôts, +deux cycles de vie — et rien à l'écran ne le disait. + +Trois sections sur sept étaient déjà chez l'hébergeur (Frontière, Fabric). **La section +Proxmox, elle, ne l'était pas** — alors qu'un cluster est du matériel possédé par +l'hébergeur au même titre que ses commutateurs. + +### La recopie avait déjà divergé + +Même cluster, deux inventaires contradictoires : + +``` +Chezlepro stockages [TrueNAS, CephHDD, CephNVMe] ponts [vmbr3] +Technolibre stockages [local-lvm, TrueNAS] ponts [vmbr1, vmbr2] +``` + +Rien ne « cassait » : ces listes ne peuplent que des menus déroulants. Mais un opérateur +plaçant une VM Technolibre ne se voyait jamais proposer `CephNVMe`, et ça n'était la +décision de personne. Le fichier de l'hébergeur prend l'**union** des deux — aucune n'était +complète, et choisir l'une aurait été arbitraire. **À confirmer contre le cluster réel.** + +### Le partage retenu + +**Hébergeur** (`/proxmox-hebergeur.yml`, à côté d'`underlay.yml`) : +`proxmox_api_host`, `_user`, `_port`, `_validate_certs`, `proxmox_noeuds`, `_stockages`, +`_ponts`. + +**Tenant** (`group_vars/proxmox.yml`) : son **golden template** — chaque tenant a le sien — +et ses **défauts de placement** (nœud, stockage, pont). Ce sont des choix faits *à +l'intérieur* de ce que l'hébergeur offre. + +Le chemin se **dérive** du symlink `underlay.yml`, qui désigne déjà l'hébergeur : rien de +nouveau n'est déclaré (D-17 tenue). Sans underlay monté, tout retombe dans le fichier du +tenant — un dépôt autonome fonctionne exactement comme avant. + +### Ce que ça a demandé de moins que prévu + +Les playbooks chargent ces fichiers **par chemin explicite** (`include_vars`), pas par +appariement de groupe Ansible — il n'existe d'ailleurs aucun groupe `proxmox` dans +l'inventaire. Une tâche `stat` + `include_vars` de plus a suffi ; aucun symlink dans +`group_vars`, aucune génération. + +Vérifié en exécution réelle : depuis Technolibre (tenant actif), la dérivation résout vers +`OPS-Chezlepro/proxmox-hebergeur.yml` et charge `asgard` + les quatre stockages. + +### Le panneau nomme désormais le propriétaire + +Chaque section porte une pastille **tenant** (bleu) ou **hébergeur** (ambre), avec en +infobulle ce que ça implique : éditer une section « hébergeur » vaut pour **tous** ses +tenants. Le schéma d'intrants porte un champ `proprietaire` — c'est la donnée qui manquait, +pas l'affichage. + +**P27** garde la séparation : aucune clé de l'hébergeur ne peut réapparaître dans un +`group_vars` de tenant. Sans elle, le premier `make config` lancé d'un autre poste +recommençait la recopie. Décisions **D-35** et **D-36**, affirmation **AFF-109**. + +### Une chose à trancher + +`modeleChezlepro` reste déclaré comme golden template de **Technolibre**. Ta décision — un +modèle par tenant — le rend incorrect, mais le corriger suppose qu'un `modeleTechnolibre` +existe réellement sur le cluster. Laissé tel quel, signalé ici. + +## 2026-08-03 (suite 10) — les intégrations universelles cessent d'être recopiées + +Le plan portait **57 lignes d'intégration écrites à la main**. Le décompte est sans appel : +`client_metrique` 14/14, `client_journal` 14/14, `client_pki` 13/14 — mais `client_backup` +7/14, `client_smtp` 8/14, `client_unbound` 1/14. + +**28 de ces 57 lignes disaient oui à quelque chose de vrai pour tout le monde.** Elles +n'existaient donc que pour être oubliées une vingt-neuvième fois — et elles l'avaient été : +dans Chezlepro, `backup-01` et `infra-pki-01` n'étaient **ni supervisés, ni journalisés, ni +certifiés**. Rien ne l'aurait signalé, puisqu'une machine non supervisée ne proteste pas. + +### Ce qui change + +Le **rôle** déclare sa politique, une fois, dans `roles//meta/integration.yml` : + +```yaml +integration: + universelle: true + raison: "Tout hôte est mesuré. Une machine hors supervision tombe sans que personne ne l'apprenne." + sauf_role: serveur_step_ca # l'AC ne s'enrôle pas auprès d'elle-même +``` + +Le plan ne porte plus que les intégrations qui sont **un vrai choix** — sauvegarde, courriel, +résolveur. Et il **refuse** désormais une recopie : deux sources finiraient par diverger, et +surtout, l'absence de `client_metrique` en face d'un serveur se lirait « non supervisé » alors +qu'il l'est. + +### L'exemption suit le service, pas le nom d'hôte + +`sauf_role: serveur_step_ca` retire `client_pki` à l'hôte qui *rend* le service. Déplacez +step-ca sur une autre machine et l'exemption suit toute seule. Un nom d'hôte en dur, lui, +aurait laissé la nouvelle AC s'enrôler auprès d'elle-même et l'ancienne sans certificat. + +### Une seule fonction de résolution + +`inventory_rules.integrations_de()` est lue par les **trois** consommateurs — inventaire, +voûte et panneau. La voûte en particulier : sans elle, les secrets des intégrations +universelles auraient cessé d'être exigés et **P18 serait passé au vert sur une voûte +incomplète**. + +### Vérification + +Sur Technolibre, `make instancier` donne **diff vide** : la politique reproduit exactement ce +que les 41 lignes retirées produisaient. Sur Chezlepro, elle produit précisément les trois +groupes manquants sur `backup-01` et les deux sur `infra-pki-01` — et **pas** `client_pki` sur +ce dernier, l'exemption ayant joué. Le trou se referme, rien d'autre ne bouge. + +**P26** garde les deux côtés : aucun hôte sans intégration universelle, aucune recopie dans le +plan. Décisions **D-33** et **D-34**. + +### Ce que le panneau montre + +Dans la fiche d'un serveur : les universelles en ✓ non décochables, les exemptions barrées, +chacune avec sa raison en infobulle. Sans cet affichage, un plan devenu silencieux se serait lu +comme une flotte non supervisée — l'inverse exact de la vérité. + +### Et une vue **Intégrations** : la matrice + +L'autre axe manquait, et c'est celui qui aurait servi. La fiche montre les intégrations **d'un +serveur** ; savoir qui n'a pas de sauvegarde demandait d'ouvrir les quatorze. Le trou de +Chezlepro n'a d'ailleurs **pas** été trouvé par le panneau — il est sorti du devis de pare-feu +Proxmox, qui énumère les rôles par hôte. L'information était à l'écran, répartie sur quatorze +clics, donc invisible. + +La matrice serveurs × intégrations : colonnes ✓ vertes pour la politique, `—` barré pour les +exemptions, cases à cocher pour les facultatives — **éditables sur place**, en-têtes et colonne +de noms figées, clic sur un nom pour ouvrir sa fiche. + +La ligne **couverture** affiche `n/N` sans juger : `7/14` sur `client_backup` peut être +exactement juste. Elle rend le motif visible ; décider s'il s'agit de choix ou d'oublis reste +au lecteur. + +Sur Technolibre, elle affiche **41 ✓ et une exemption** — soit très exactement les 41 lignes +retirées du plan et le `client_pki` de l'AC. + ## 2026-08-03 (suite 9) — le SSH inter-nœud était perdu En éclatant les règles par rôle source, un défaut de ma première version est apparu : je diff --git a/docs/audit/affirmations.md b/docs/audit/affirmations.md index 56b07a5..3758bd8 100644 --- a/docs/audit/affirmations.md +++ b/docs/audit/affirmations.md @@ -156,6 +156,8 @@ Légende du statut : ✅ prouvée · 🟡 partielle · ❌ fausse · ⚪ invéri | AFF-104 | « La frontière déclare toujours une source d'administration ; un devis dont `nftables_admin_ssh` est vide est refusé » (`docs/frontiere-opnsense.md` §5) | fonctionnelle | **P24** (`scripts/devis_opnsense.py --verifier`) | ✅ | Garde anti-lockout : sans cet intrant, la règle SSH n'aurait aucune source et le `block` final fermerait l'accès. | | AFF-105 | « Les modèles publics valident, underlay compris » (`exemples/modeles/socle/README.md`) | structurelle | **P17** (`scripts/modeles.py verifier`) | ✅ | Underlay facultatif ; validé pour sa cohérence **interne** seulement — un modèle est un gabarit, pas un site déployé. | | AFF-107 | « Le filtrage est-ouest intra-tenant est dérivé du registre des flux, et redit la même politique que les nftables d'hôte » (`docs/sdn-evpn.md` §3) | structurelle | **P25** (`scripts/devis_proxmox_fw.py --verifier`) | ✅ | 34 groupes, 40 règles sur 2 tenants. Les deux barrières lisent le registre par les **mêmes fonctions** : elles ne peuvent pas diverger. | +| AFF-108 | « Une intégration universelle est déclarée par le rôle, pas recopiée par serveur : aucun hôte n'y échappe et le plan ne la redit pas » (`docs/decisions-architecture.md` D-33) | structurelle | **P26** (`scripts/prouver.py`) | ✅ | L'exemption se dérive du **service rendu** (`sauf_role`), jamais d'un nom d'hôte : l'AC ne s'enrôle pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace. | +| AFF-109 | « Ce qui appartient à l'hébergeur (cluster, fabric, frontière) vit dans son dépôt, jamais recopié chez un tenant » (`docs/decisions-architecture.md` D-35) | structurelle | **P27** (`scripts/prouver.py`) | ✅ | Le chemin se dérive du symlink `underlay.yml` : l'hébergeur n'est toujours pas déclaré (D-17). Sautée si aucun underlay n'est monté. | | AFF-106 | « Le panneau couvre le schéma du plan : aucun champ n'oblige à éditer le YAML » (`docs/intrants-base-gui-conception.md`) | fonctionnelle | **P19** (`scripts/couverture_gui.py`) | 🟡 | Vrai des registres du plan. **Exceptions assumées** : `reseaux` et `hotes` de l'underlay (listes de tables) restent hors du panneau ; les ports physiques et le nœud de sortie EVPN se nomment à la main. | **Non enregistré volontairement.** Les devis eux-mêmes (`make devis-reseau`, @@ -393,6 +395,8 @@ Chaque affirmation ✅ est soit **rejouée** par une preuve de `make prouver` | AFF-105 | **P17** (tous les modèles valident, underlay compris) | | AFF-106 | **P19** (le GUI couvre le schéma du plan) | | AFF-107 | **P25** (pare-feu Proxmox est-ouest) | +| AFF-108 | **P26** (intégrations universelles : aucun hôte laissé de côté) | +| AFF-109 | **P27** (propriété des intrants : hébergeur et tenant séparés) | **Structurelles/doctrinales, hors harnais récurrent** (vérifiées à l'audit) : AFF-005 (`make`=aide), AFF-014 (ciblage `groupe:&hotes_actifs`), AFF-024 (`instancier-appliquer`, diff --git a/docs/audit/preuve-2026-08-03.md b/docs/audit/preuve-2026-08-03.md index 3ebdf2e..f7b5163 100644 --- a/docs/audit/preuve-2026-08-03.md +++ b/docs/audit/preuve-2026-08-03.md @@ -7,13 +7,13 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/principal/hosts.yml` -- **Verdict** : ✅ CONFORME (25 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (26 OK · 0 echec · 1 saute) ## Preuves | # | Preuve | Affirmations | Statut | Detail | |---|---|---|---|---| -| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | :1: SyntaxWarning: invalid decimal literal | +| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | | | P02 | Tests unitaires (inventory_host) | — | ✅ OK | 4 tests passes. | | P03 | Diff-vide du plan (inventaire genere) | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible. | | P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | | @@ -28,7 +28,7 @@ | P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. | | P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. | | P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. | -| P16 | Inventaire Ansible complet (--list) | AFF-030 | ✅ OK | 14 hotes, 29 groupes (inventaire dechiffre et parse). | +| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). | | P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. | | P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 21 secret(s) exige(s), tous presents. | | P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 27 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. | @@ -38,6 +38,8 @@ | P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 5 reseau(x), aucune collision avec la plage tenant. | | P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | CONFORME : frontiere nord/sud, 26 regles, 2 routes, admin=192.168.254.2/32,192.168.255.0/24,192.168.255.2/32. | | P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 2 tenant(s), 36 groupe(s), 56 regle(s). | +| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 3 integration(s) universelle(s) : aucune lacune, aucune recopie (1 exemption(s) derivee(s) du service rendu). | +| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 7 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. | ## Couverture des affirmations ✅ du registre diff --git a/docs/config-proxmox.md b/docs/config-proxmox.md index 21f1f86..b1ea205 100644 --- a/docs/config-proxmox.md +++ b/docs/config-proxmox.md @@ -4,7 +4,13 @@ la connexion au cluster Proxmox et les valeurs de clonage par défaut. Il pose **16 paramètres non sensibles** puis propose de saisir les **secrets API**. -- Non sensibles → `instance/inventories/production/group_vars/proxmox.yml` +- Non sensibles, côté **tenant** (golden template, défauts de placement) + → `instance/inventories/*/group_vars/proxmox.yml` +- Non sensibles, côté **hébergeur** (API du cluster, nœuds, stockages, ponts) + → `/proxmox-hebergeur.yml`, à côté d'`underlay.yml`. + Le chemin se **dérive** du symlink qui désigne déjà l'hébergeur — rien de nouveau + n'est déclaré. Sans underlay monté, tout retombe dans le fichier du tenant et + `make config` fonctionne comme avant. - Secrets → **voûte unique** `instance/inventories/production/group_vars/all/vault.yml` (chiffrée par `ansible-vault`), qui contient **tous** les secrets de l'instance (token Proxmox + `vault_*`). Voir [§4](#4-secrets-de-linstance-). diff --git a/docs/decisions-architecture.md b/docs/decisions-architecture.md index 8650434..99e83ce 100644 --- a/docs/decisions-architecture.md +++ b/docs/decisions-architecture.md @@ -50,6 +50,8 @@ sont les seules vérifiables. | **D-15** | Ce symlink **ne suit pas** `make instance-utiliser` | basculer le tenant actif ne change pas la fabric | `frontiere-opnsense.md` §2 | — | | **D-16** | Les intrants de la **frontière** se lisent chez l'hébergeur | un hébergeur n'a qu'une frontière pour tous ses tenants | `frontiere-opnsense.md` §2 | — | | **D-17** | L'hébergeur **n'est pas déclaré** : le symlink le désigne | une seconde déclaration ouvrirait deux valeurs contradictoires | `frontiere-opnsense.md` §2 | — | +| **D-35** | Le **cluster Proxmox** appartient à l'hébergeur ; seuls le **golden template** et les **défauts de placement** restent au tenant | recopié chez chaque tenant, l'inventaire du cluster avait déjà divergé — deux listes de stockages contradictoires pour le même matériel | `config-proxmox.md` | P27 | +| **D-36** | Le panneau **nomme le propriétaire** de chaque section d'intrants | éditer une section « hébergeur » vaut pour tous ses tenants ; l'écran ne le disait pas | `scripts/inventory_gui.py` (`INTRANTS_SCHEMA`) | — | | **D-18** | Chaque tenant a un **responsable désigné** | sans lui, « qui peut décider de déménager cette organisation ? » se pose au pire moment | `migration-tenant.md` §3 | — | ## 3. Les secrets @@ -71,6 +73,8 @@ sont les seules vérifiables. | **D-26** | Un modèle peut porter un **underlay** ; tous les hébergeurs n'ont pas le même matériel | générique public, étoffés en privé | `exemples/modeles/socle/README.md` | P17 | | **D-27** | **Migration** : le receveur est prouvé prêt **avant** tout gel | l'interruption se réduit au delta et à la propagation DNS | `migration-tenant.md` §4 | — | | **D-28** | Le mandat de migration est **signé par le tenant**, pas convenu entre hébergeurs | il n'y a pas de registre central pour arbitrer ; une organisation n'est pas la propriété de son hébergeur | `migration-tenant.md` §2 | — | +| **D-33** | Une intégration **universelle** est déclarée par le **rôle**, jamais recopiée par serveur | 28 des 57 lignes du plan disaient oui à ce qui vaut pour tous : elles n'existaient que pour être oubliées — et quatre l'avaient été | `integrations-vm.md` §Politique | P26 | +| **D-34** | Une **exemption** se dérive du **service rendu** (`sauf_role`), jamais d'un nom d'hôte | l'AC ne s'enrôle pas auprès d'elle-même ; l'exemption doit suivre step-ca si on le déplace | `roles/client_pki/meta/integration.yml` | P26 | --- diff --git a/docs/integrations-vm.md b/docs/integrations-vm.md index e3ca359..7812783 100644 --- a/docs/integrations-vm.md +++ b/docs/integrations-vm.md @@ -1,6 +1,60 @@ -# Intégrations futures des VM -Les serveurs centraux ne sont pas encore en place. Les rôles d'intégration doivent donc rester optionnels et désactivés par défaut tant que leurs dépendances n'existent pas. +# Intégrations des VM + +## Politique : le défaut est « oui », l'exception se justifie + +Une intégration est de l'un des deux genres, et ils ne se déclarent pas au même endroit. + +**Universelle** — supervision, journaux, PKI. Il n'y a aucun choix de cible : un seul +Prometheus, un seul Loki, une seule AC. Elle est déclarée **une fois, par le rôle**, dans +`roles//meta/integration.yml`, et tout hôte la reçoit : + +```yaml +integration: + universelle: true + raison: "Tout hôte est mesuré. Une machine hors supervision tombe sans que personne ne l'apprenne." + sauf_role: serveur_step_ca # facultatif — voir « exemptions » +``` + +**Facultative** — `client_backup`, `client_smtp`, `client_unbound`. Là il y a un vrai choix, +et il se déclare par serveur, dans `plan/serveurs.yml : integrations`. + +**Pourquoi cette inversion.** Le plan portait 57 lignes d'intégration écrites à la main. 28 +d'entre elles disaient oui à quelque chose de vrai pour tous les hôtes — elles n'existaient +donc que pour être oubliées, et quatre l'avaient été : deux serveurs n'étaient ni supervisés, +ni journalisés, ni certifiés. Personne ne l'aurait su, puisqu'une machine non supervisée ne +proteste pas. Depuis, oublier est impossible : il faut **exempter**, et dire pourquoi. + +Le plan **refuse** désormais une recopie (`valider_serveurs`). Deux sources finiraient par +diverger, et surtout : l'absence de `client_metrique` en face d'un serveur se lirait +« non supervisé » alors qu'il l'est. + +### Les exemptions se dérivent du service rendu + +Une exemption ne nomme jamais un hôte. Elle nomme le **rôle serveur** dont la présence la +justifie : `sauf_role: serveur_step_ca` retire `client_pki` à l'hôte qui *est* l'AC — elle ne +s'enrôle pas auprès d'elle-même. Déplacez step-ca sur une autre machine et l'exemption suit, +sans qu'on y touche. + +**P26** garde les deux côtés : aucun hôte sans intégration universelle, aucune recopie dans le +plan. + +### Ce que le panneau montre + +Dans la **fiche d'un serveur**, les universelles apparaissent en ✓ **non décochables**, les +exemptions barrées, avec leur raison en infobulle. Sans cet affichage, un plan devenu +silencieux se lirait comme une flotte non supervisée — l'inverse exact de la vérité. + +La vue **Intégrations** donne l'autre axe : une **matrice serveurs × intégrations**. Colonnes +✓ pour la politique, `—` barré pour les exemptions, cases à cocher pour les facultatives — +éditables sur place, puis *Sauvegarder*. La ligne **couverture** affiche `n/N` par colonne. + +Elle **ne juge pas** : `7/14` sur `client_backup` peut être exactement juste. Elle rend le +motif visible, ce que quatorze fiches consultées une par une ne faisaient pas — c'est +précisément pour ça que le trou de Chezlepro est resté invisible jusqu'à ce qu'un devis de +pare-feu l'énumère. + +--- ## Séparation @@ -25,7 +79,7 @@ Visualisation : Grafana côté plateforme centrale ## Principes Ansible -- Chaque rôle d'intégration doit être désactivé par défaut. +- Un rôle d'intégration doit être **inoffensif hors de son groupe** : c'est l'appartenance au groupe qui l'active, jamais un drapeau recopié. (Ce principe disait autrefois « désactivé par défaut », à l'époque où les serveurs centraux n'existaient pas ; depuis, trois intégrations sont universelles — voir §Politique.) - Chaque variable de rôle doit utiliser le préfixe du rôle. - Les secrets ne doivent jamais être stockés en clair dans le dépôt. - Un rôle ne doit pas échouer si son intégration est désactivée. diff --git a/docs/plan-et-generation.md b/docs/plan-et-generation.md index ff6e52c..cd2e5d4 100644 --- a/docs/plan-et-generation.md +++ b/docs/plan-et-generation.md @@ -37,7 +37,7 @@ Tous sous `docs/`, machine-lisibles, validés, consommés par le GUI, le CLI et | Registre | Décrit | Champs clés | | --- | --- | --- | | `nomenclature.yml` | nommage & adressage | `fonctions` (catégorie/service), `categories` (VLAN/sous-réseau/passerelle), `supernet` | -| `serveurs.yml` | les VM du plan | `fonction`, `etat` (actif/planifie), placement Proxmox (`noeud`/`stockage`/`disque`/`memoire`/`coeurs`), `integrations` (les `clients_*`) | +| `serveurs.yml` | les VM du plan | `fonction`, `etat` (actif/planifie), placement Proxmox (`noeud`/`stockage`/`disque`/`memoire`/`coeurs`), `integrations` (les `client_*` **facultatives** seulement — les universelles viennent du rôle, voir `integrations-vm.md`) | | `applications.yml` | les applications | `groupe` (capacité/rôle), `hote` (VM), `port`, `requiert`, `expose`, (+ bases via consommateur) | | `bases-donnees.yml` | serveurs de BD + bases | `serveurs_bd` ; `bases_donnees` : `serveur`/`base`/`proprietaire`/`secret`(Vault), `consommateur` + `portee` (`application`/`groupe`/`hote`), `usage` | | `domaines.yml` | zones DNS publiques | `domaines_publics` : `autorite`, `edge`, `secondaires`, `dnssec`, `mail` | @@ -63,7 +63,8 @@ Tous sous `docs/`, machine-lisibles, validés, consommés par le GUI, le CLI et **dérivés** de la nomenclature ; placement/taille depuis `serveurs.yml`). - **groupes** d'une VM = socle (`serveur_debian` + `serveur_durci`) + **services** (les `groupe` des applications de l'hôte) - + **intégrations** (`serveurs.yml: integrations`) + + **intégrations universelles** (politique du rôle : `roles/client_*/meta/integration.yml`) + + **intégrations facultatives** (`serveurs.yml: integrations`) + **état** (`hotes_actifs` / `hotes_planifies`). La comparaison est **sémantique** (via `ansible-inventory --list`, formatage diff --git a/exemples/modeles/socle/plan/serveurs.yml b/exemples/modeles/socle/plan/serveurs.yml index f6eb78e..3531f84 100644 --- a/exemples/modeles/socle/plan/serveurs.yml +++ b/exemples/modeles/socle/plan/serveurs.yml @@ -1,6 +1,9 @@ --- +# `integrations:` ne porte que les integrations FACULTATIVES. Les universelles +# (supervision, journaux, PKI) viennent de la politique du role — voir +# roles/client_*/meta/integration.yml : tout hote les recoit sans etre listee ici. serveurs: infra-pki-01: { fonction: infra-pki, etat: planifie, disque: 32G, integrations: [client_backup] } - infra-edge-01: { fonction: infra-edge, etat: planifie, integrations: [client_pki] } - infra-mail-01: { fonction: infra-mail, etat: planifie, integrations: [client_pki, client_backup] } - infra-dns-01: { fonction: infra-dns, etat: planifie, disque: 40G, integrations: [client_pki] } + infra-edge-01: { fonction: infra-edge, etat: planifie } + infra-mail-01: { fonction: infra-mail, etat: planifie, integrations: [client_backup] } + infra-dns-01: { fonction: infra-dns, etat: planifie, disque: 40G } diff --git a/playbooks/proxmox/cloner_vm_debian.yml b/playbooks/proxmox/cloner_vm_debian.yml index 55510ae..3d52f25 100644 --- a/playbooks/proxmox/cloner_vm_debian.yml +++ b/playbooks/proxmox/cloner_vm_debian.yml @@ -13,6 +13,11 @@ # ecrit le token) est autoritaire ; `proxmox.vault.yml` reste accepte en compat # legacy. all/vault.yml en dernier => il l'emporte s'il definit les memes cles. setops_gv_proxmox: [proxmox.yml, proxmox.vault.yml, proxmox.local.yml, all/vault.yml] + # Le CLUSTER appartient a l'HEBERGEUR (API, noeuds, stockages, ponts). Son fichier + # vit dans le depot de l'hebergeur, que le symlink `underlay.yml` designe deja + # (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent + # (depot sans underlay monte), le `stat` echoue simplement et rien n'est charge. + setops_proxmox_hebergeur: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/proxmox-hebergeur.yml" tasks: - name: Verifier les fichiers de variables Proxmox optionnels @@ -31,6 +36,16 @@ - item.stat.exists - item.stat.path is not search('vault') + - name: Localiser le fichier Proxmox de l'hebergeur + ansible.builtin.stat: + path: "{{ setops_proxmox_hebergeur }}" + register: proxmox_fichier_hebergeur + + - name: Charger le cluster de l'hebergeur (autoritaire sur ses propres cles) + ansible.builtin.include_vars: + file: "{{ setops_proxmox_hebergeur }}" + when: proxmox_fichier_hebergeur.stat.exists + - name: Charger les secrets Proxmox Vault block: - name: Charger les secrets de la voute Proxmox diff --git a/playbooks/proxmox/supprimer_vm_debian.yml b/playbooks/proxmox/supprimer_vm_debian.yml index 8256b36..453e103 100644 --- a/playbooks/proxmox/supprimer_vm_debian.yml +++ b/playbooks/proxmox/supprimer_vm_debian.yml @@ -14,6 +14,11 @@ # `proxmox.vault.yml` reste accepte en compat legacy pour les instances qui en # ont encore une. all/vault.yml en dernier => il l'emporte sur les memes cles. setops_gv_proxmox: [proxmox.yml, proxmox.vault.yml, proxmox.local.yml, all/vault.yml] + # Le CLUSTER appartient a l'HEBERGEUR (API, noeuds, stockages, ponts). Son fichier + # vit dans le depot de l'hebergeur, que le symlink `underlay.yml` designe deja + # (D-14/D-17) : on en DERIVE le chemin plutot que de redeclarer l'hebergeur. Absent + # (depot sans underlay monte), le `stat` echoue simplement et rien n'est charge. + setops_proxmox_hebergeur: "{{ (playbook_dir ~ '/../../underlay.yml') | realpath | dirname }}/proxmox-hebergeur.yml" # cibles_vmid : liste d'entiers, passée en -e (sinon dérivée des hôtes actifs). cibles_vmid: "{{ groups['hotes_actifs'] | map('extract', hostvars, 'proxmox_vmid') | map('int') | list }}" @@ -34,6 +39,16 @@ - item.stat.exists - item.stat.path is not search('vault') + - name: Localiser le fichier Proxmox de l'hebergeur + ansible.builtin.stat: + path: "{{ setops_proxmox_hebergeur }}" + register: proxmox_fichier_hebergeur + + - name: Charger le cluster de l'hebergeur (autoritaire sur ses propres cles) + ansible.builtin.include_vars: + file: "{{ setops_proxmox_hebergeur }}" + when: proxmox_fichier_hebergeur.stat.exists + - name: Charger les secrets Proxmox (Vault) ansible.builtin.include_vars: file: "{{ item.stat.path }}" diff --git a/roles/client_journal/meta/integration.yml b/roles/client_journal/meta/integration.yml new file mode 100644 index 0000000..21e13c5 --- /dev/null +++ b/roles/client_journal/meta/integration.yml @@ -0,0 +1,9 @@ +--- +# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le +# raisonnement, et docs/decisions-architecture.md (D-33). +integration: + universelle: true + raison: >- + Tout hote expedie ses journaux. Un incident se reconstitue depuis les traces + des machines voisines : celle qui n'expedie pas est un trou dans le recit. + # Pas d'exemption : le serveur Loki expedie aussi les siens. diff --git a/roles/client_metrique/meta/integration.yml b/roles/client_metrique/meta/integration.yml new file mode 100644 index 0000000..ab5e1b7 --- /dev/null +++ b/roles/client_metrique/meta/integration.yml @@ -0,0 +1,11 @@ +--- +# Politique d'integration. Declaree ICI, une fois, plutot que recopiee sur chaque +# serveur du plan : une ligne recopiee 14 fois n'existe que pour etre oubliee une +# quinzieme. Voir docs/integrations-vm.md et docs/decisions-architecture.md (D-33). +integration: + universelle: true + raison: >- + Tout hote est mesure. Une machine hors supervision tombe sans que personne + ne l'apprenne — c'est exactement l'hote qu'on oublie qui manque le plus. + # Pas d'exemption : le serveur Prometheus se mesure lui-meme (node_exporter), + # et c'est voulu — un collecteur muet sur son propre etat est un angle mort. diff --git a/roles/client_pki/meta/integration.yml b/roles/client_pki/meta/integration.yml new file mode 100644 index 0000000..99ee1e4 --- /dev/null +++ b/roles/client_pki/meta/integration.yml @@ -0,0 +1,13 @@ +--- +# Politique d'integration. Voir roles/client_metrique/meta/integration.yml pour le +# raisonnement, et docs/decisions-architecture.md (D-33). +integration: + universelle: true + raison: >- + Tout hote fait confiance a l'AC interne et porte un certificat : c'est le + prerequis du chiffrement est-ouest (voir docs/zero-confiance.md). Un hote sans + certificat ne peut ni presenter ni verifier — il retombe en clair en silence. + # L'AC ne s'enrole pas aupres d'elle-meme : elle EST la source de la confiance. + # L'exemption se derive du service rendu, jamais d'un nom d'hote — elle suit donc + # step-ca si on le deplace. + sauf_role: serveur_step_ca diff --git a/scripts/config_proxmox.py b/scripts/config_proxmox.py index e619b1d..140d442 100644 --- a/scripts/config_proxmox.py +++ b/scripts/config_proxmox.py @@ -30,6 +30,16 @@ def _inventaire_dir(instance: Path, *noms: str) -> Path: _INV_DIR = _inventaire_dir(INSTANCE, "lab", "principal", "production") FICHIER_PROXMOX = _INV_DIR / "group_vars/proxmox.yml" +# Le CLUSTER appartient a l'HEBERGEUR : son fichier vit dans le depot que le symlink +# `underlay.yml` designe deja (D-14/D-17). Recopiees chez chaque tenant, ces cles +# avaient deja diverge. Repli sur le fichier du tenant si aucun underlay n'est monte +# (depot autonome) — `make config` reste utilisable tel quel. +_UNDERLAY = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml")) +FICHIER_PROXMOX_HEBERGEUR = (_UNDERLAY.resolve().parent / "proxmox-hebergeur.yml" + if _UNDERLAY.exists() else FICHIER_PROXMOX) +# Cles ecrites chez l'HEBERGEUR ; tout le reste (modele, defauts de placement) au tenant. +CLES_HEBERGEUR = {"proxmox_api_host", "proxmox_api_user", "proxmox_api_port", + "proxmox_validate_certs"} # Voûte UNIQUE de l'instance : tous les secrets (token Proxmox + vault_*) au même # endroit, un seul mot de passe. Gabarit : exemples/vault.exemple.yml. FICHIER_VAULT = _INV_DIR / "group_vars/all/vault.yml" @@ -114,7 +124,11 @@ def demander(identifiant: str, courant: object) -> object: def configurer_proxmox() -> None: - courant = VALEURS_DEFAUT | charger_yaml(FICHIER_PROXMOX) + # Deux fichiers, deux proprietaires : on fusionne pour AFFICHER l'etat courant, + # on separe pour ECRIRE. Sans quoi `make config` reecrirait chez le tenant les + # cles du cluster, et la recopie recommencerait a la premiere execution. + courant = (VALEURS_DEFAUT | charger_yaml(FICHIER_PROXMOX) + | charger_yaml(FICHIER_PROXMOX_HEBERGEUR)) nouveau = {} print("Configuration Proxmox non sensible") @@ -124,8 +138,17 @@ def configurer_proxmox() -> None: for identifiant in VALEURS_DEFAUT: nouveau[identifiant] = demander(identifiant, courant.get(identifiant, VALEURS_DEFAUT[identifiant])) - ecrire_yaml(FICHIER_PROXMOX, nouveau) + separe = FICHIER_PROXMOX_HEBERGEUR != FICHIER_PROXMOX + tenant = {k: v for k, v in nouveau.items() if not (separe and k in CLES_HEBERGEUR)} + ecrire_yaml(FICHIER_PROXMOX, tenant) print(f"\nEcrit: {os.path.relpath(FICHIER_PROXMOX, RACINE)}") + if separe: + # Fusion : preserve les catalogues (noeuds/stockages/ponts) que ce script ne + # demande pas mais que le panneau ecrit dans le meme fichier. + heb = charger_yaml(FICHIER_PROXMOX_HEBERGEUR) + heb.update({k: nouveau[k] for k in CLES_HEBERGEUR if k in nouveau}) + ecrire_yaml(FICHIER_PROXMOX_HEBERGEUR, heb) + print(f"Ecrit: {FICHIER_PROXMOX_HEBERGEUR} (HEBERGEUR — vaut pour tous ses tenants)") def demander_oui_non(question: str, defaut: bool = False) -> bool: diff --git a/scripts/instancier.py b/scripts/instancier.py index 857bee8..3c43429 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -10,8 +10,10 @@ Derivation : derives de la nomenclature ; placement/taille depuis instance/plan/serveurs.yml) ; - groupes : socle (serveur_debian/durcis) + groupes de service derives des applications de l'hote + groupe d'etat (hotes_actifs/hotes_planifies). - - Les integrations clients_* ne sont PAS derivables du plan a ce stade : la - comparaison les revele (lacune a combler en Phase 3b). + - Les integrations client_* : les universelles viennent de la POLITIQUE du role + (roles/client_*/meta/integration.yml), les facultatives de la liste du plan. + Voir inventory_rules.integrations_de() — source unique des trois lecteurs + (inventaire, voute, GUI). """ from __future__ import annotations @@ -34,6 +36,8 @@ from inventory_rules import ( deriver_ressources, expositions_des_applications, fonction_seq, + integrations_de, + integrations_universelles, liens_acceptes, ) @@ -161,6 +165,9 @@ def generer() -> dict: for app in apps.values(): services_par_hote.setdefault(app.get("hote"), set()).add(app.get("groupe")) + # Politique d'integration : lue une fois, appliquee a tous (voir D-33). + universelles = integrations_universelles(RACINE) + children: dict = { "modeles_vm": {"hosts": {}}, "hotes_actifs": {"hosts": {}}, @@ -189,7 +196,8 @@ def generer() -> dict: # Variables derivees des liens (bindings) declares par les applications. for var, val in injections.get(nom, {}).items(): hostvars[var] = val - groupes = set(GROUPES_SOCLE) | services_par_hote.get(nom, set()) | set(srv.get("integrations") or []) + services = services_par_hote.get(nom, set()) + groupes = set(GROUPES_SOCLE) | services | integrations_de(srv, services, universelles) # SANs d'exposition : les FQDN que cet edge sert (derives du plan) -> cert edge # (client_pki_sans), au lieu d'une liste manuelle. Vide si l'hote n'est pas un edge. sans = sorted({e["fqdn"] for e in expositions if e.get("edge") in groupes}) diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 7564465..e857724 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -34,6 +34,7 @@ from inventory_rules import ( charger_serveurs, est_groupe_operationnel, groupes_operationnels_connus, + integrations_universelles, reconcilier_serveur, valider_applications, catalogue_liens, @@ -117,8 +118,24 @@ def _fichier_frontiere() -> Path: 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} @@ -132,35 +149,40 @@ CHEMINS_UNDERLAY = { "underlay_stp_topologie": ("stp", "topologie"), } -# (variable, fichier, classe, section, libelle, type) +# (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.(10+index), VLAN 1000+index×10+zone, VMID)", "int"), - ("domaine_interne", "identite", "constante", "Identité", "Domaine DNS interne", "str"), - ("fuseau_horaire", "identite", "defaut", "Identité", "Fuseau horaire", "str"), - ("organisation", "identite", "defaut", "Identité", "Organisation (annuaire LDAP, certificats)", "str"), - ("identite_realm", "identite", "defaut", "Identité", "Realm SSO (Keycloak)", "str"), - ("nftables_admin_ssh", "identite", "catalogue", "Sécurité", "Sources SSH d'administration toujours autorisées (garde anti-lockout du pare-feu)", "liste"), - ("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"), - ("proxmox_noeuds", "proxmox", "catalogue", "Proxmox", "Nœuds disponibles (liste)", "liste"), - ("proxmox_stockages", "proxmox", "catalogue", "Proxmox", "Stockages disponibles (liste)", "liste"), - ("proxmox_ponts", "proxmox", "catalogue", "Proxmox", "Ponts réseau disponibles (liste)", "liste"), - ("opnsense_api_url", "frontiere", "constante", "Frontière", "URL de gestion de la frontière OPNsense (API REST)", "str"), - ("opnsense_api_verifier_certs", "frontiere", "defaut", "Frontière", "Valider le certificat TLS de la frontière", "bool"), - ("opnsense_wan_ip", "frontiere", "constante", "Frontière", "Adresse publique du WAN de la frontière (face Internet de l'écosystème)", "str"), - ("opnsense_if_wan", "frontiere", "defaut", "Frontière", "Interface WAN — IDENTIFIANT OPNsense (wan), pas le libellé ni le périphérique", "str"), - ("opnsense_if_transit", "frontiere", "defaut", "Frontière", "Interface de transit — IDENTIFIANT OPNsense (opt1, opt2…), pas « igb1 » ni le libellé affiché", "str"), - ("underlay_routeur", "fabric", "constante", "Fabric", "Switch qui porte le routage (SVI, ACL, routes) — les autres restent en L2 pur", "str"), - ("underlay_dialecte", "fabric", "constante", "Fabric", "Dialecte de CLI du commutateur (cisco | binardat)", "str"), - ("underlay_stp_mode", "fabric", "defaut", "Fabric", "Mode de spanning-tree (rstp | mstp | pvst)", "str"), - ("underlay_stp_topologie", "fabric", "defaut", "Fabric", "Topologie de câblage (etoile | anneau | maille)", "str"), + ("index", "reseau", "constante", "Réseau", "Index d'instance dans la fédération (seed : dérive supernet 10.(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"), + ("proxmox_clone_pont", "proxmox", "defaut", "Proxmox", "Pont réseau (défaut)", "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"), + ("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. @@ -417,6 +439,7 @@ def inventaire_api(path: Path) -> dict: "hotes": liste_hotes(data), "intrants": intrants_api(), "integrations_disponibles": integrations_disponibles(), + "integrations_universelles": integrations_universelles(RACINE), "liens_acceptes": catalogue_liens(RACINE), } @@ -464,7 +487,7 @@ def _lire_fabric_a_plat() -> dict: def intrants_courants() -> dict: cache: dict = {} valeurs: dict = {} - for variable, fcle, _classe, _section, _libelle, typ in INTRANTS_SCHEMA: + 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])) @@ -503,14 +526,21 @@ CHAMPS_ECRITS_PAR_GUI = { def integrations_disponibles() -> list: - """Roles client_* presents dans roles/ : source des integrations selectionnables.""" + """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" - return sorted(p.name for p in base.glob("client_*") if p.is_dir()) + 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} for (v, f, c, s, l, t) in INTRANTS_SCHEMA] + "libelle": l, "type": t, "proprietaire": p} for (v, f, c, s, l, t, p) in INTRANTS_SCHEMA] return { "schema": schema, "valeurs": intrants_courants(), @@ -573,8 +603,8 @@ def ecrire_intrants(payload: dict) -> None: 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) in INTRANTS_SCHEMA} - fichier_de = {v: f for (v, f, c, s, l, t) in INTRANTS_SCHEMA} + 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: @@ -595,6 +625,24 @@ def ecrire_intrants(payload: dict) -> None: "# 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_* }}`. @@ -1067,9 +1115,35 @@ HTML = r""" .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; } @@ -1136,6 +1210,7 @@ HTML = r""" + @@ -1226,6 +1301,7 @@ HTML = r""" 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; @@ -1274,6 +1350,7 @@ HTML = r""" couchesRegistre = data.couches || []; intrants = data.intrants || null; integrationsDispo = data.integrations_disponibles || []; + integrationsUniv = data.integrations_universelles || {}; liensAcceptes = data.liens_acceptes || {}; chargerBases(data); chargerApplications(data); @@ -1319,8 +1396,8 @@ HTML = r""" function majIndicateurSauvegarde() { const b = document.getElementById('btn-sauver'); if (!b) return; - const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines'; - const sale = (vuePrincipale === 'serveurs' && serveursModifie) + const editable = vuePrincipale === 'serveurs' || vuePrincipale === 'integrations' || vuePrincipale === 'applications' || vuePrincipale === 'bases' || vuePrincipale === 'domaines'; + const sale = ((vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') && serveursModifie) || (vuePrincipale === 'applications' && applicationsModifie) || (vuePrincipale === 'bases' && basesModifie) || (vuePrincipale === 'domaines' && domainesModifie); @@ -1358,6 +1435,7 @@ HTML = r""" 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'); @@ -1383,6 +1461,7 @@ HTML = r""" if (vuePrincipale === 'applications') { dessinerApplications(); return; } if (vuePrincipale === 'bases') { dessinerBases(); return; } if (vuePrincipale === 'domaines') { dessinerDomaines(); return; } + if (vuePrincipale === 'integrations') { dessinerIntegrations(); return; } if (vuePrincipale === 'flux') { dessinerFlux(); return; } if (vuePrincipale === 'couches') { dessinerCouches(); return; } if (vuePrincipale === 'reseau') { dessinerReseau(); return; } @@ -1547,6 +1626,100 @@ HTML = r"""
${blocs}
`; } + // --- 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 = '
Aucun rôle client_* dans roles/.
'; return; } + if (!lignes.length) { cible.innerHTML = '
Aucun serveur ne correspond au filtre.
'; 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 `—`; + } + couvert[role]++; + return `✓`; + } + if (set.has(role)) couvert[role]++; + return ``; + }).join(''); + const sel = i === selServeur ? ' class="mx-sel"' : ''; + return `` + + `${echapper(s.nom || '(sans nom)')}${echapper(s.fonction || '')}${cellules}`; + }).join(''); + + const n = lignes.length; + const tetes = colonnes.map(role => { + const u = !!integrationsUniv[role]; + return `` + + `${echapper(role.replace(/^client_/, ''))}`; + }).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 `${c}/${n}`; + }).join(''); + + cible.innerHTML = `
+
Intégrations — matrice ${n} × ${colonnes.length} + Les colonnes ✓ sont la politique des rôles ; les cases sont les choix, éditables ici
+
+ ${tetes} + ${corps} + ${pieds} +
serveur
couverture
`; + } + + function legendeIntegrations() { + const univ = Object.entries(integrationsUniv).sort(); + const lignes = univ.map(([role, p]) => `
${echapper(role)} +
${echapper(p.raison || '')}${p.sauf_role ? ` Exemption : l'hôte qui rend ${echapper(p.sauf_role)}.` : ''}
`).join(''); + document.getElementById('detail').innerHTML = `
+
Deux genres d'intégration
+
Universelle — aucun choix de cible : un seul Prometheus, un seul Loki, une seule AC. + Déclarée une fois par le rôle (roles/<rôle>/meta/integration.yml), elle s'applique à tout hôte + et ne se décoche pas. Le plan refuse de la redire : deux sources finiraient par diverger.
+ ${lignes} +
Facultative — un vrai choix, serveur par serveur, écrit dans + plan/serveurs.yml. Coche dans la matrice, puis Sauvegarder.
+
La ligne couverture ne juge pas : une colonne à 7/14 + peut être exactement juste. Elle rend le motif visible — c'est au lecteur de savoir si les sept manquants + sont des décisions ou des oublis.
`; + } + function basesDeApp(app, hoteNom) { return bdApplis.filter(a => { const p = a.portee || 'groupe'; @@ -2036,6 +2209,16 @@ HTML = r""" const cases = tousRoles.length ? tousRoles.map(role => ``).join('') : 'aucun rôle client_* disponible'; + // 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]) => `✓ ${echapper(role.replace(/^client_/, ''))}`).join(''); + const exempts = Object.entries(integrationsUniv) + .filter(([role, p]) => p.sauf_role && rendus.has(p.sauf_role)) + .map(([role, p]) => `— ${echapper(role.replace(/^client_/, ''))}`).join(''); const info = infoFonction(s.fonction); const zoneHint = info ? `${echapper(info.libelle)} · VLAN ${info.vlan}` : ''; const h = hotes.find(x => x.nom === s.nom); @@ -2083,7 +2266,7 @@ HTML = r""" - +
Applications ici
${appsHtml}
Bases ici
${bdHtml} @@ -2177,6 +2360,7 @@ HTML = r""" 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 === 'integrations') return legendeIntegrations(); if (vuePrincipale === 'applications') return inspecteurApplication(); if (vuePrincipale === 'bases') return inspecteurBase(); if (vuePrincipale === 'domaines') return inspecteurDomaine(); @@ -2287,13 +2471,22 @@ HTML = r""" 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 => { - html += '
' + echapper(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 + ? 'hébergeur' + : 'tenant'; + html += '
' + echapper(sec) + chip + '
'; parSection[sec].forEach(ch => { const v = valeurs[ch.variable]; let badge; @@ -2448,7 +2641,7 @@ HTML = r""" function sauvegarderVue() { if (occupe) return; - if (vuePrincipale === 'serveurs') sauvegarderServeurs(); + if (vuePrincipale === 'serveurs' || vuePrincipale === 'integrations') sauvegarderServeurs(); else if (vuePrincipale === 'applications') sauvegarderApplications(); else if (vuePrincipale === 'bases') sauvegarderBases(); else if (vuePrincipale === 'domaines') sauvegarderDomaines(); diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index d8b8b3a..093c2d1 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -47,6 +47,51 @@ def catalogue_liens(racine: Path | None = None) -> dict: return catalogue +def integrations_universelles(racine: Path | None = None) -> dict[str, dict]: + """{role: {raison, sauf_role}} — les integrations declarees universelles. + + Une integration universelle n'est PAS recopiee sur chaque serveur du plan : le + role la declare une fois dans roles//meta/integration.yml, et tout hote + la recoit. Le plan ne porte plus que les integrations reellement facultatives + (client_backup, client_smtp, client_unbound) et les exemptions. + + Pourquoi : recopiee 14 fois, la meme ligne finit par manquer une quinzieme — + et un hote non supervise, non journalise ou sans certificat ne proteste pas. + Le defaut est desormais « oui », et c'est l'exception qui doit se justifier. + """ + base = (racine or RACINE_DEPOT) / "roles" + if not base.is_dir(): + return {} + politiques = {} + for meta in sorted(base.glob("client_*/meta/integration.yml")): + data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {} + politique = data.get("integration") or {} + if politique.get("universelle"): + politiques[meta.parents[1].name] = { + "raison": str(politique.get("raison", "")).strip(), + "sauf_role": politique.get("sauf_role") or None, + } + return politiques + + +def integrations_de(srv: dict, services_hote: set[str] | None = None, + universelles: dict[str, dict] | None = None, + racine: Path | None = None) -> set[str]: + """Integrations effectives d'un serveur : les universelles + celles du plan. + + `services_hote` = groupes de service que cet hote rend (derives des + applications). Il sert aux exemptions : `sauf_role` retire l'integration a + l'hote qui rend le service lui-meme — l'AC ne s'enrole pas aupres d'elle-meme. + L'exemption suit donc le service, jamais un nom d'hote. + """ + if universelles is None: + universelles = integrations_universelles(racine) + rendus = services_hote or set() + effectives = {role for role, p in universelles.items() + if not (p["sauf_role"] and p["sauf_role"] in rendus)} + return effectives | set(srv.get("integrations") or []) + + def est_groupe_operationnel(groupe: str) -> bool: return groupe.startswith(GROUPES_OPERATIONNELS_PREFIXES) @@ -466,6 +511,7 @@ def valider_serveurs(registre: dict, nomenclature: dict | None = None) -> None: fonctions = (nomenclature or {}).get("fonctions") or {} if not isinstance(serveurs, dict): raise ValueError("serveurs doit etre une table.") + universelles = integrations_universelles() for nom, srv in serveurs.items(): if not isinstance(srv, dict): raise ValueError(f"Serveur '{nom}': table attendue.") @@ -480,6 +526,16 @@ def valider_serveurs(registre: dict, nomenclature: dict | None = None) -> None: integrations = srv.get("integrations") if integrations is not None and not isinstance(integrations, list): raise ValueError(f"Serveur '{nom}': 'integrations' doit etre une liste.") + # Une integration universelle recopiee dans le plan a deux torts : elle + # laisse croire que son ABSENCE chez un voisin signifie « non integre », + # et elle offre une valeur a oublier. La politique du role est la seule + # source ; on refuse la copie plutot que de la tolerer silencieusement. + redites = sorted(set(integrations or []) & set(universelles)) + if redites: + raise ValueError( + f"Serveur '{nom}': integration(s) {', '.join(redites)} universelle(s) — " + f"declaree(s) dans roles//meta/integration.yml, elles s'appliquent " + f"a tout hote. Retirer ces lignes du plan.") def reconcilier_serveur(nom: str, srv: dict, hote_inventaire: dict | None, nomenclature: dict) -> dict: diff --git a/scripts/prouver.py b/scripts/prouver.py index dd13d10..32a3a5a 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -163,6 +163,84 @@ def preuve_modele_socle() -> tuple[bool, str]: return True, "Modele public socle : domaines/serveurs/applications/bases valides." +def preuve_propriete_des_intrants() -> tuple[bool, str]: + """Aucune cle de l'HEBERGEUR ne subsiste dans un group_vars de tenant. + + Un cluster, une fabric et une frontiere appartiennent a l'hebergeur : recopies + chez chaque tenant, ils avaient deja diverge (deux listes de stockages + contradictoires pour le meme materiel). La garde attrape la rechute — un + `make config` d'un autre poste, une reprise a la main — avant qu'elle ne + reintroduise deux valeurs pour un seul objet. + + Portee : l'instance liee. Un depot sans hebergeur monte (aucun `underlay.yml`) + ecrit legitimement tout au meme endroit : la preuve se saute alors. + """ + sys.path.insert(0, str(RACINE / "scripts")) + import yaml as _yaml + underlay = Path(os.environ.get("SETOPS_UNDERLAY") or (RACINE / "underlay.yml")) + if not underlay.exists(): + return True, "Aucun hebergeur monte (pas d'underlay.yml) : separation sans objet." + hebergeur = underlay.resolve().parent + instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + if instance.resolve() == hebergeur.resolve(): + cles = {} # l'hebergeur est aussi tenant : son propre depot porte les deux + else: + fichier_heb = hebergeur / "proxmox-hebergeur.yml" + if not fichier_heb.is_file(): + return False, f"Fichier du cluster absent chez l'hebergeur : {fichier_heb}" + cles = _yaml.safe_load(fichier_heb.read_text(encoding="utf-8")) or {} + fautes = [] + for gv in sorted((instance / "inventories").glob("*/group_vars/*.yml")): + data = _yaml.safe_load(gv.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + continue + redites = sorted(set(data) & set(cles)) + if redites: + fautes.append(f"{gv.parent.name}/{gv.name}: {', '.join(redites)}") + if fautes: + return False, "cle(s) de l'hebergeur recopiee(s) chez le tenant — " + " ; ".join(fautes[:4]) + return True, (f"{len(cles)} cle(s) de cluster chez l'hebergeur, aucune recopiee dans les " + f"group_vars du tenant.") + + +def preuve_integrations_universelles() -> tuple[bool, str]: + """Aucun hote n'echappe a une integration universelle, et le plan ne la redit pas. + + C'est la garde de l'inversion du defaut (D-33). Elle attrape les deux rechutes + possibles : un hote sans supervision/journal/certificat, et une recopie dans le + plan — qui reintroduirait deux sources et donc, tot ou tard, un ecart. + """ + sys.path.insert(0, str(RACINE / "scripts")) + from inventory_rules import (charger_applications, charger_serveurs, + integrations_de, integrations_universelles) + universelles = integrations_universelles(RACINE) + if not universelles: + return False, "Aucune integration universelle declaree : la politique a disparu." + instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + serveurs = (charger_serveurs(instance / "plan/serveurs.yml").get("serveurs") or {}) + apps = (charger_applications(instance / "plan/applications.yml").get("applications") or {}) + services: dict[str, set[str]] = {} + for app in apps.values(): + services.setdefault(app.get("hote"), set()).add(app.get("groupe")) + fautes, exemptes = [], 0 + for nom, srv in serveurs.items(): + rendus = services.get(nom, set()) + obtenues = integrations_de(srv, rendus, universelles) + for role, politique in universelles.items(): + exempt = politique["sauf_role"] and politique["sauf_role"] in rendus + if exempt: + exemptes += 1 + elif role not in obtenues: + fautes.append(f"{nom} sans {role}") + redites = sorted(set(srv.get("integrations") or []) & set(universelles)) + if redites: + fautes.append(f"{nom} redit {', '.join(redites)} (politique du role)") + if fautes: + return False, "; ".join(fautes[:6]) + return True, (f"{len(serveurs)} hote(s) x {len(universelles)} integration(s) universelle(s) : " + f"aucune lacune, aucune recopie ({exemptes} exemption(s) derivee(s) du service rendu).") + + def preuve_nomenclature_derivee() -> tuple[bool, str]: """Aucune nomenclature ne STOCKE d'adressage : tout se derive du seed `index`. @@ -296,6 +374,10 @@ PREUVES: list[dict] = [ "cmds": [[sys.executable, "scripts/devis_proxmox_fw.py", "--verifier"]]}, {"id": "P24", "titre": "Frontiere nord/sud : acces d'administration declare", "refs": ["AFF-104"], "cmds": [[sys.executable, "scripts/devis_opnsense.py", "--verifier"]]}, + {"id": "P26", "titre": "Integrations universelles : aucun hote laisse de cote", "refs": ["AFF-108"], + "func": preuve_integrations_universelles}, + {"id": "P27", "titre": "Propriete des intrants : hebergeur et tenant separes", "refs": ["AFF-109"], + "func": preuve_propriete_des_intrants}, ] diff --git a/scripts/serveurs.py b/scripts/serveurs.py index 9c58cf2..42d8867 100644 --- a/scripts/serveurs.py +++ b/scripts/serveurs.py @@ -21,6 +21,7 @@ from inventory_rules import ( charger_nomenclature, charger_serveurs, fonction_seq, + integrations_universelles, reconcilier_serveur, valider_serveurs, ) @@ -67,6 +68,9 @@ def hotes_inventaire() -> dict: def bootstrap() -> dict: serveurs = {} + # Les universelles ne se recopient pas dans le plan (D-33) : les reinscrire ici + # reconstituerait, a chaque bootstrap, les lignes qu'on vient de retirer. + universelles = set(integrations_universelles()) for nom, h in hotes_inventaire().items(): fonction, _ = fonction_seq(nom) srv = {"fonction": fonction, "etat": h.get("etat", "planifie")} @@ -74,7 +78,8 @@ def bootstrap() -> dict: v = str(h.get(cle_src, "")).strip() if v: srv[cle_dst] = int(v) if v.isdigit() else v - integrations = sorted(g for g in (h.get("groupes") or []) if g.startswith("client_")) + integrations = sorted(g for g in (h.get("groupes") or []) + if g.startswith("client_") and g not in universelles) if integrations: srv["integrations"] = integrations serveurs[nom] = srv diff --git a/scripts/voute.py b/scripts/voute.py index e87f393..946fa0d 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -29,7 +29,8 @@ from pathlib import Path import yaml -from inventory_rules import charger_applications, charger_bases_donnees, charger_serveurs +from inventory_rules import (charger_applications, charger_bases_donnees, charger_serveurs, + integrations_universelles) RACINE = Path(__file__).resolve().parents[1] INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) @@ -67,8 +68,15 @@ def groupes_du_plan() -> set[str]: apps = charger_applications(INSTANCE / "plan/applications.yml") srv = charger_serveurs(INSTANCE / "plan/serveurs.yml") groupes = {a.get("groupe") for a in (apps.get("applications") or {}).values() if a.get("groupe")} - for s in (srv.get("serveurs") or {}).values(): + serveurs = (srv.get("serveurs") or {}) + for s in serveurs.values(): groupes.update(s.get("integrations") or []) + # Les integrations universelles ne figurent plus dans le plan (D-33) : sans + # cette ligne, leurs secrets cesseraient d'etre exiges — et P18 passerait au + # vert sur une voute incomplete. L'exemption `sauf_role` ne retire le role qu'a + # UN hote : le groupe existe des qu'il reste un serveur, donc pas de filtrage. + if serveurs: + groupes.update(integrations_universelles()) return groupes diff --git a/wiki/Le-GUI-console-d-exploitation.md b/wiki/Le-GUI-console-d-exploitation.md index 6bc9c2a..4d3b264 100644 --- a/wiki/Le-GUI-console-d-exploitation.md +++ b/wiki/Le-GUI-console-d-exploitation.md @@ -26,7 +26,7 @@ Une bonne console d'exploitation obéit à trois règles : | Vues **éditables** (le plan) | Vues **dérivées** (lecture seule) | |---|---| -| Serveurs · Applications · Bases · Domaines · **Intrants** | Flux · Couches · **Réseau** (flotte + devis) | +| Serveurs · **Intégrations** · Applications · Bases · Domaines · **Intrants** | Flux · Couches · **Réseau** (flotte + devis) | Le flux d'exploitation, de bout en bout : @@ -73,6 +73,16 @@ un pointeur seulement) et le **DSN dérivé** (secret masqué). ![Vue Domaines, annotée](img/Set-OPS-Domaines-annote.svg) +**Intégrations** *(éditable)* — la **matrice serveurs × intégrations**. Les colonnes ✓ vertes +sont la **politique des rôles** : elles s'appliquent à tout hôte et ne se décochent pas ; un `—` +barré signale une **exemption dérivée du service rendu** (l'AC ne s'enrôle pas auprès d'elle-même). +Les autres colonnes sont de vrais choix, cochables ici. La ligne **couverture** ne juge pas — elle +rend le motif visible : c'est au lecteur de savoir si les manquants sont des décisions ou des oublis. + +> Cette vue existe parce que la fiche de détail ne montrait les intégrations que **d'un serveur à +> la fois**. Deux serveurs sans supervision ni journaux sont restés invisibles jusqu'à ce qu'un +> devis de pare-feu les énumère : l'information était à l'écran, répartie sur quatorze clics. + **Flux** *(dérivée)* — la **matrice d'audit** : la source de nftables *et* la justification lisible de chaque flux (sens ingress/egress, chiffrement).