le site surveille enfin sa fabric
La supervision du site voyait ses sept VM et rien d autre. Au depart, depuis site-mon-01, les trois hyperviseurs, les neuf pattes de la frontiere et sa PROPRE passerelle par defaut rendaient tous 100 pourcent de perte au ping. 16 hotes UP sur 16 dont 9 pattes de frontiere en controle ACTIF 11 cibles Prometheus dont 3 hyperviseurs, job fabric separe LE PARTAGE. Les hyperviseurs portent node_exporter - D-48 l autorise - et exposent 1005 unites systemd avec leur etat, soit ce que la sonde sante mesurait, plus la charge et le disque. Ils n entrent PAS dans le socle : leur appliquer serveur_durci reecrirait le pare-feu, le SSH et les sysctl de la machine qui tient tout le reste. La frontiere, elle, n accueille aucun agent : controle actif, une entree par PATTE, parce qu une interface eteinte coupe une zone pendant que les autres vont bien. ON TIRE, ON NE POUSSE PAS. J avais propose du passif et il avait ete valide ; la mesure a dit non. Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site, et sa route par defaut est GELEE (D-57). Le porteur de sante y expirait en 20 s. LE VRAI DEFAUT ETAIT UNE LISTE QUI N A PAS SUIVI. Le mecanisme de routage existait deja sur vmbr0, avec un commentaire du 2026-08-26 tenant exactement le raisonnement qu on venait de refaire. Sa liste s arretait a 10.0.34.0/24 quand le site en declare six : les zones sauvegarde et supervision sont nees, les routes n ont pas suivi. Le symptome ne ressemblait pas a une route manquante - il ressemblait a un pare-feu, puis a un probleme de reseau chez l exploitant. make routes-fabric-etat compare desormais TROIS choses : zones declarees, routes declarees dans /etc/network/interfaces, routes vivantes dans le noyau. Le cas le plus traitre est vivante mais non declaree : tout fonctionne, la supervision est verte, et la panne attend la prochaine maintenance. Eprouvee dans les deux sens. Deux defauts trouves en construisant. bifrost-2 est un nom RESERVE, pas un boitier - l underlay le disait en prose, illisible par le moteur ; il porte desormais etat: reserve. Et un service passif n existe que pour un hote qui peut POUSSER : l appartenance a un groupe sert deux choses qui ne coincident pas toujours, a qui l on deploie et de qui l on attend un rapport. Les commutateurs restent dehors, choix de l exploitant, coherent avec D-48. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
parent
009325ee51
commit
5abf20102d
13 changed files with 475 additions and 10 deletions
75
CHANGELOG.md
75
CHANGELOG.md
|
|
@ -1,5 +1,80 @@
|
|||
# CHANGELOG — Set-OPS
|
||||
|
||||
## 2026-09-10 (14) — Le site surveille enfin sa fabric
|
||||
|
||||
La supervision du site voyait ses sept VM et rien d'autre. Mesure de depart, depuis
|
||||
`site-mon-01` : les trois hyperviseurs, les neuf pattes de la frontiere et **sa propre
|
||||
passerelle par defaut** rendaient tous 100 % de perte au ping.
|
||||
|
||||
16 hotes UP / 16 dont 9 pattes de frontiere en controle ACTIF
|
||||
11 cibles Prometheus dont 3 hyperviseurs, job `fabric` separe
|
||||
|
||||
### Le partage : ce qui peut porter un agent, et ce qui ne le peut pas
|
||||
|
||||
**Les hyperviseurs** portent `node_exporter` — D-48 l'autorise. Chacun expose 6 600 a
|
||||
7 900 lignes de metriques, dont **1 005 unites systemd avec leur etat** : soit exactement
|
||||
ce que la sonde `sante` mesurait, plus la charge, le disque et l'horloge.
|
||||
|
||||
**Ils n'entrent PAS dans le socle**, et c'est le point delicat. Un hyperviseur Proxmox
|
||||
n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son pare-feu, son
|
||||
SSH et ses sysctl — sur la machine qui tient tout le reste. Ils vivent dans leur propre
|
||||
groupe, hors de `GROUPE_SOCLE` et de `hotes_actifs`.
|
||||
|
||||
**La frontiere** n'accueille aucun agent : controle ACTIF, une entree par PATTE. La
|
||||
frontiere est un seul boitier, mais chaque zone depend de SON interface — une interface
|
||||
eteinte coupe une zone pendant que les autres vont bien, et on en a deja vu (les routes
|
||||
creees `disabled` le 2026-09-02). Un ping vers une seule adresse dirait « la frontiere est
|
||||
debout » et manquerait ce cas.
|
||||
|
||||
### On TIRE, on ne pousse pas — et c'est la route gelee qui le decide
|
||||
|
||||
J'avais propose du passif, et il avait ete valide. **La mesure a dit non** :
|
||||
|
||||
asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site)
|
||||
route par defaut via 192.168.11.254 GELEE (D-57)
|
||||
|
||||
Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site : le porteur
|
||||
de sante y expirait en 20 s. Le remede evident — router 10.0.0.0/8 par la frontiere —
|
||||
touche la route par defaut d'une machine EN SERVICE, ce que D-57 interdit. On tire donc,
|
||||
dans le sens que la frontiere route deja.
|
||||
|
||||
### Le vrai defaut : une liste qui n'a pas suivi
|
||||
|
||||
Le mecanisme de routage EXISTAIT sur `vmbr0`, avec un commentaire du 2026-08-26 tenant
|
||||
exactement le raisonnement qu'on venait de refaire. Sa liste s'arretait a `10.0.34.0/24` :
|
||||
|
||||
le site declare 6 zones (31 -> 36)
|
||||
les hyperviseurs 4 routes (31 -> 34)
|
||||
|
||||
Les zones `sauvegarde` (35) et `supervision` (36) sont nees, **les routes n'ont pas suivi**.
|
||||
Le symptome ne ressemblait pas a une route manquante : il ressemblait a un pare-feu, puis a
|
||||
un probleme de reseau chez l'exploitant.
|
||||
|
||||
**`make routes-fabric-etat`** compare desormais TROIS choses : les zones declarees, les
|
||||
routes declarees dans `/etc/network/interfaces`, et les routes vivantes dans le noyau. Le
|
||||
cas le plus traitre est le troisieme — *vivante mais non declaree* : tout fonctionne, la
|
||||
supervision est verte, et la panne attend la prochaine maintenance. Une garde qui ne
|
||||
comparerait que le vivant ne le verrait jamais. Eprouvee dans les deux sens.
|
||||
|
||||
### Deux defauts trouves en construisant
|
||||
|
||||
**`bifrost-2` est un nom reserve, pas un boitier.** L'underlay le disait — « pour que les
|
||||
noms soient reserves » — mais en PROSE, illisible par le moteur. Le surveiller aurait donne
|
||||
deux CRITICAL permanents pour un equipement absent. Il porte desormais `etat: reserve` ; le
|
||||
jour ou le second boitier arrive, retirer la cle le fait entrer dans la supervision.
|
||||
|
||||
**Un service passif n'existe que pour un hote qui peut POUSSER.** Les hyperviseurs sont
|
||||
dans `client_metrique` — c'est par ce groupe qu'on leur deploie node_exporter — et la
|
||||
derivation en tirait `asgard!metriques`. Icinga refusait la configuration ENTIERE. Meme en
|
||||
definissant l'hote, le service serait reste UNKNOWN pour toujours. *L'appartenance a un
|
||||
groupe sert deux choses qui ne coincident pas toujours : a qui l'on deploie, et de qui l'on
|
||||
attend un rapport.* La seconde se lit sur `client_sante`, et nulle part ailleurs.
|
||||
|
||||
### Les commutateurs restent dehors
|
||||
|
||||
Choix de l'exploitant, coherent avec D-48 : ils sont hors flotte, et rien ne les rend
|
||||
interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir.
|
||||
|
||||
## 2026-09-10 (13) — D-88 : le noeud du gabarit, point unique de la REPRODUCTION
|
||||
|
||||
Question de l'exploitant : *« le modele vit sur vishnu, les clones sont sur asgard — qu'
|
||||
|
|
|
|||
8
Makefile
8
Makefile
|
|
@ -1071,6 +1071,14 @@ site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops
|
|||
#
|
||||
# A LA DEMANDE, pas dans `make prouver` : ce controle exige le cluster, que le harnais ne
|
||||
# suppose pas joignable. Meme nature que `genome-etat` et `underlay-plan`.
|
||||
.PHONY: routes-fabric-etat
|
||||
routes-fabric-etat: ansible-runtime ## Les hyperviseurs routent-ils toutes les zones du SITE ? — ne corrige rien
|
||||
@# UNE LISTE QUI DOIT SUIVRE UNE AUTRE LISTE PREND DU RETARD. Les routes specifiques
|
||||
@# de `vmbr0` s'arretaient a 10.0.34.0/24 quand le site en declarait six : les zones
|
||||
@# `sauvegarde` et `supervision` etaient nees sans que les routes suivent. Le symptome
|
||||
@# ne ressemblait pas a une route manquante — il ressemblait a un pare-feu.
|
||||
python3 scripts/routes_fabric_etat.py
|
||||
|
||||
.PHONY: gabarit-etat
|
||||
gabarit-etat: ## Le gabarit porte-t-il ce que le SITE declare ? — ne corrige rien, regarde
|
||||
python3 scripts/gabarit_etat.py
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
|
||||
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check), 1 nom(s) surveille(s) sans reference orpheline. |
|
||||
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 41 groupes classes, aucun cycle, aucune arete en arriere. |
|
||||
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 103 flux, schéma + matrice OK. |
|
||||
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 39 rôles, 105 flux, schéma + matrice OK. |
|
||||
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
|
||||
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
|
||||
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
|
||||
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
|
||||
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 58 scripts expliques et atteignables, 115 cibles make documentees, 67 roles avec README. |
|
||||
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 59 scripts expliques et atteignables, 116 cibles make documentees, 67 roles avec README. |
|
||||
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 38 exigence(s) de role, toutes satisfaites (132 cle(s) declaree(s) par l'instance). |
|
||||
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (36 groupes). |
|
||||
| P34 | Chaque document declare son lecteur | — | ✅ OK | 44 document(s) declarent leur lecteur (36 genere(s) exempte(s)). |
|
||||
|
|
@ -53,16 +53,16 @@
|
|||
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 40 role(s) serveur/client tous nommes, 41 groupe(s) cite(s) en table existent tous. |
|
||||
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
|
||||
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
|
||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 54 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 55 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
|
||||
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
|
||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 151 regle(s) du site. |
|
||||
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 153 regle(s) du site. |
|
||||
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 5 integration(s) appliquent leur serveur avant leurs clients. |
|
||||
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
|
||||
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
|
||||
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
|
||||
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 89 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
|
||||
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (121 lignes). |
|
||||
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 213 regles `pass`), tous non consignes et tous motives. |
|
||||
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (123 lignes). |
|
||||
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 215 regles `pass`), tous non consignes et tous motives. |
|
||||
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
|
||||
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
|
||||
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
| `serveur_icinga` | ingress | 5665 | tcp | serveur_debian | tls-requis | Rapport passif de sante de chaque noeud (unites systemd en echec). |
|
||||
| `serveur_icinga` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Base relationnelle du moteur Icinga (verify-full). |
|
||||
| `serveur_icinga` | egress | echo-request | icmp | serveur_debian | n-a | La supervision verifie que ses hotes repondent (hostalive) : sans ce flux, elle les tient tous pour morts et supprime leurs notifications. |
|
||||
| `serveur_icinga` | egress | echo-request | icmp | fabric | n-a | La supervision verifie que la frontiere sert encore chaque zone. Aucun agent ne peut vivre sur un pare-feu : le controle ACTIF est le seul chemin, et il n'existait pas. |
|
||||
| `serveur_icingaweb2` | ingress | 8080 | tcp | edge | clair | Interface web servie via l'edge (TLS terminé à l'edge ; SSO possible via oauth2-proxy). |
|
||||
| `serveur_icingaweb2` | egress | 636 | tcp | serveur_openldap | tls-requis | Authentification des utilisateurs sur l'annuaire (LDAPS). |
|
||||
| `serveur_icingaweb2` | egress | 5432 | tcp | serveur_postgresql | tls-requis | Lecture d'IcingaDB (base relationnelle, verify-full). |
|
||||
|
|
@ -96,6 +97,7 @@
|
|||
| `serveur_powerdns` | egress | 53 | tcp | externe | clair | Repli TCP de la résolution sortante du serveur autoritatif (réponses dépassant la taille UDP). |
|
||||
| `serveur_prometheus` | ingress | 9090 | tcp | localhost | clair | Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud). |
|
||||
| `serveur_prometheus` | egress | 9100 | tcp | client_metrique | tls | Scrape des node_exporter (HTTPS via cert step-ca) sur chaque nœud instrumenté. |
|
||||
| `serveur_prometheus` | egress | 9100 | tcp | fabric | clair | Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee, D-57), donc on la tire. |
|
||||
| `serveur_redis` | ingress | 6379 | tcp | localhost | clair | Cache/verrous consommés uniquement par l'application co-localisée (ex. Nextcloud). Aucune exposition inter-nœud. |
|
||||
| `serveur_resolveur` | ingress | 53 | udp | flotte | clair | Toute la flotte du tenant résout ici — et nulle part ailleurs. |
|
||||
| `serveur_resolveur` | ingress | 53 | tcp | flotte | clair | Réponses longues et bascule TCP, obligatoires en DNS. |
|
||||
|
|
@ -112,8 +114,8 @@
|
|||
|
||||
## Synthèse chiffrement
|
||||
|
||||
- **clair** : 37 flux
|
||||
- **n-a** : 5 flux
|
||||
- **clair** : 38 flux
|
||||
- **n-a** : 6 flux
|
||||
- **ssh** : 8 flux
|
||||
- **starttls** : 6 flux
|
||||
- **tls** : 10 flux
|
||||
|
|
|
|||
|
|
@ -4,6 +4,21 @@
|
|||
# echoue chaque heure apprend a ignorer le rouge.
|
||||
client_sante_icinga_hote: "{{ (groups['serveur_icinga'] | default([]) | first) | default('') }}"
|
||||
|
||||
# L'ADRESSE DU TEMOIN, PAR SON NOM — SAUF QUAND LE NOM N'EXISTE PAS (2026-09-10).
|
||||
#
|
||||
# Le porteur visait `<hote>.<domaine>`, ce qui suppose le plancher `/etc/hosts` pose par
|
||||
# le socle. Les HYPERVISEURS ne l'ont pas, et ne doivent pas l'avoir : Proxmox se sert de
|
||||
# `/etc/hosts` pour l'identite de noeud du cluster, et la reecrire est un risque qu'on ne
|
||||
# prend pas pour une commodite de supervision.
|
||||
#
|
||||
# Mesure : `curl: (6) Could not resolve host: site-mon-01.genese.internal` sur les trois
|
||||
# hyperviseurs, alors que le porteur etait correctement installe et tournait.
|
||||
#
|
||||
# On DERIVE donc l'URL, ce qui laisse une machine sans resolveur la surcharger par une
|
||||
# ADRESSE. Une seule chose change, et elle se declare la ou le nom ne marche pas.
|
||||
client_sante_icinga_url: >-
|
||||
https://{{ client_sante_icinga_hote }}.{{ domaine_interne }}:5665
|
||||
|
||||
# LE MEME COMPTE D'API QUE LE RAPPORT DE SAUVEGARDE, et c'est un choix.
|
||||
#
|
||||
# Un second compte serait plus pur — un secret par usage. Il exigerait une CLEF DE VOUTE
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
# ce qui n'a alerte personne.
|
||||
set -uo pipefail
|
||||
|
||||
API="https://{{ client_sante_icinga_hote }}.{{ domaine_interne }}:5665"
|
||||
API="{{ client_sante_icinga_url }}"
|
||||
TTL={{ client_sante_ttl_icinga }}
|
||||
MOI="{{ inventory_hostname }}"
|
||||
MOTDEPASSE="$(cat /etc/setops/icinga-api.pass)"
|
||||
|
|
|
|||
|
|
@ -154,3 +154,7 @@ serveur_icinga_sonde_age_crit: 300
|
|||
# DEGRADE, JAMAIS DEVINE : pas de cache d'amorcage declare, pas de reecriture.
|
||||
serveur_icinga_depot_schema: >-
|
||||
{{ 'http' if (artefacts_amorcage | default('') | string | length > 0) else 'https' }}
|
||||
|
||||
# LA FABRIC SURVEILLEE, DERIVEE DE L'UNDERLAY PAR L'INVENTAIRE DU SITE.
|
||||
# Forme : [{ nom, adresse, role }]. Vide chez un tenant — il n'a pas de fabric a lui.
|
||||
serveur_icinga_fabric: []
|
||||
|
|
|
|||
|
|
@ -96,3 +96,27 @@ flux:
|
|||
pair: serveur_postgresql
|
||||
chiffrement: tls-requis
|
||||
raison: "Base relationnelle du moteur Icinga (verify-full)."
|
||||
|
||||
# LA PASSERELLE FAIT PARTIE DE CE QUI PEUT TOMBER (2026-09-10).
|
||||
#
|
||||
# La supervision du site voyait ses sept VM et rien d'autre. Mesure : depuis
|
||||
# `site-mon-01`, AUCUNE patte de la frontiere ne repondait — pas meme `10.0.36.1`, sa
|
||||
# PROPRE passerelle par defaut. OPNsense ne repond pas a l'ICMP sur ses interfaces
|
||||
# internes tant qu'une regle ne l'autorise pas.
|
||||
#
|
||||
# UNE PATTE PAR ZONE, ET C'EST LE POINT. La frontiere est un seul boitier, mais chaque
|
||||
# zone du site depend de SON interface a elle. Une interface eteinte — on en a deja vu,
|
||||
# les routes creees `disabled` le 2026-09-02 — coupe une zone pendant que les autres
|
||||
# vont bien. Un ping vers une seule patte dirait « la frontiere est debout » et
|
||||
# manquerait exactement ce cas-la.
|
||||
#
|
||||
# `fabric` couvre les hyperviseurs ET la frontiere : c'est le materiel de l'hebergeur,
|
||||
# celui qu'aucun agent ne peut habiter.
|
||||
- sens: egress
|
||||
port: echo-request
|
||||
protocole: icmp
|
||||
pair: fabric
|
||||
chiffrement: n-a
|
||||
raison: >-
|
||||
La supervision verifie que la frontiere sert encore chaque zone. Aucun agent ne peut
|
||||
vivre sur un pare-feu : le controle ACTIF est le seul chemin, et il n'existait pas.
|
||||
|
|
|
|||
|
|
@ -19,3 +19,26 @@ object Host "{{ noeud }}" {
|
|||
vars.role = "{{ 'depot de sauvegarde' if noeud == serveur_icinga_hote_sauvegarde else 'noeud de l ecosysteme' }}"
|
||||
}
|
||||
{% endfor %}
|
||||
|
||||
/*
|
||||
* LA FABRIC — CE QUI PORTE TOUT, ET QU'AUCUN AGENT NE PEUT HABITER (2026-09-10).
|
||||
*
|
||||
* Un pare-feu n'accueille pas de porteur de sante : le controle ACTIF est le seul chemin
|
||||
* possible, et il n'existait pas. La supervision du site voyait ses sept VM et rien
|
||||
* d'autre — pas meme la passerelle par defaut de la machine qui la fait tourner.
|
||||
*
|
||||
* UNE ENTREE PAR PATTE, ET C'EST LE POINT. La frontiere est un seul boitier, mais chaque
|
||||
* zone depend de SON interface. Une interface eteinte — on en a deja vu, les routes
|
||||
* creees `disabled` le 2026-09-02 — coupe une zone pendant que les autres vont bien.
|
||||
* Surveiller une seule adresse dirait « la frontiere est debout » et manquerait ce cas.
|
||||
*
|
||||
* L'ADRESSE, JAMAIS UN NOM : ces equipements ne sont dans aucun annuaire, et le plancher
|
||||
* `/etc/hosts` ne les porte pas. Un nom fabrique ici ne resoudrait nulle part.
|
||||
*/
|
||||
{% for f in serveur_icinga_fabric | default([]) %}
|
||||
object Host "{{ f.nom }}" {
|
||||
check_command = "hostalive"
|
||||
address = "{{ f.adresse }}"
|
||||
vars.role = "{{ f.role }}"
|
||||
}
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,25 @@
|
|||
* Les HOTES sont definis dans `setops-hotes.conf` : ici, rien que des services.
|
||||
*/
|
||||
|
||||
{#
|
||||
* UN SERVICE PASSIF N'EXISTE QUE POUR UN HOTE QUI PEUT POUSSER (2026-09-10).
|
||||
*
|
||||
* Les hyperviseurs ont rejoint `client_metrique` — c'est par ce groupe qu'on leur
|
||||
* DEPLOIE node_exporter. La derivation en tirait donc `asgard!metriques`, et Icinga
|
||||
* refusait la configuration ENTIERE :
|
||||
*
|
||||
* Attribute 'host_name': Object 'asgard' of type 'Host' does not exist.
|
||||
*
|
||||
* Meme en definissant l'hote, le service serait reste UNKNOWN pour toujours : un
|
||||
* hyperviseur ne porte pas `client_sante`, parce que sa route par defaut est gelee et
|
||||
* qu'il ne peut rien pousser (D-57). On le TIRE avec Prometheus.
|
||||
*
|
||||
* L'appartenance a un groupe sert donc DEUX choses qui ne coincident pas toujours : a
|
||||
* qui l'on deploie, et de qui l'on attend un rapport. La seconde se lit sur le porteur
|
||||
* lui-meme — `client_sante` — et nulle part ailleurs.
|
||||
#}
|
||||
{% for role, sondes in (serveur_icinga_sondes | default({})) | dictsort %}
|
||||
{% for hote in (groups[role] | default([])) | sort %}
|
||||
{% for hote in ((groups[role] | default([])) | intersect(groups['client_sante'] | default([]))) | sort %}
|
||||
{% for sonde in sondes %}
|
||||
object Service "{{ sonde.nom }}" {
|
||||
host_name = "{{ hote }}"
|
||||
|
|
|
|||
|
|
@ -13,3 +13,23 @@ flux:
|
|||
pair: localhost
|
||||
chiffrement: clair
|
||||
raison: "Console Prometheus consommée en local par Grafana co-localisé (pas d'exposition inter-nœud)."
|
||||
|
||||
# LA FABRIC SE SURVEILLE EN LA TIRANT (2026-09-10).
|
||||
#
|
||||
# Les hyperviseurs portent `node_exporter` — D-48 l'autorise : « hors flotte » ne vaut
|
||||
# que pour les commutateurs et la frontiere. Mais ils ne peuvent pas POUSSER : leur route
|
||||
# par defaut vise le routeur du site, qui ne connait pas les reseaux du site, et cette
|
||||
# route est GELEE (D-57). Mesure : le porteur de sante y expirait en 20 s.
|
||||
#
|
||||
# Prometheus tire donc, dans le sens que la frontiere route deja pour `serveur_ops_site`.
|
||||
# 7887 lignes de metriques sur `asgard`, dont 1005 unites systemd avec leur etat — soit
|
||||
# davantage que ce qu'un ping ou un porteur de sante auraient dit.
|
||||
- sens: egress
|
||||
port: 9100
|
||||
protocole: tcp
|
||||
pair: fabric
|
||||
chiffrement: clair
|
||||
raison: >-
|
||||
Scrape des hyperviseurs : la machine qui PORTE les VM etait invisible de la
|
||||
supervision qui les surveille. Elle ne peut pas pousser (route par defaut gelee,
|
||||
D-57), donc on la tire.
|
||||
|
|
|
|||
141
scripts/routes_fabric_etat.py
Normal file
141
scripts/routes_fabric_etat.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Les hyperviseurs routent-ils toutes les zones que le SITE declare ? Ne corrige rien.
|
||||
|
||||
POURQUOI CE CONTROLE EXISTE (2026-09-10).
|
||||
|
||||
Un hyperviseur atteint les zones du site par des routes SPECIFIQUES posees sur `vmbr0`,
|
||||
et non par sa route par defaut — celle-ci est GELEE (D-57) et vise le routeur du site.
|
||||
Le commentaire qui les accompagne, ecrit le 2026-08-26, dit pourquoi :
|
||||
|
||||
Aller et retour empruntent la MEME patte — pf cree son etat sur une seule
|
||||
interface, et une reponse revenant par une autre serait jetee.
|
||||
|
||||
CE QUE CA A COUTE. Le site a gagne deux zones — `site-sauvegarde` (35) et
|
||||
`site-supervision` (36) — et la liste des routes ne les a pas suivies. Elle s'arretait a
|
||||
34. Consequence mesuree : la supervision du site ne pouvait PAS collecter les
|
||||
hyperviseurs, parce que la reponse partait vers un routeur qui ne connait pas 10.0.36.0/24.
|
||||
Le symptome ne ressemblait pas a une route manquante — il ressemblait a un pare-feu, puis
|
||||
a un probleme de reseau chez l'exploitant.
|
||||
|
||||
Une liste qui doit suivre une autre liste finit toujours par prendre du retard. La seule
|
||||
question est de savoir si quelqu'un s'en apercevra avant la panne.
|
||||
|
||||
CE QU'ON COMPARE, ET DANS LES DEUX SENS :
|
||||
|
||||
1. les zones DECLAREES par `underlay.reseaux` (la verite du site) ;
|
||||
2. les routes DECLAREES dans `/etc/network/interfaces` (ce qui survit au redemarrage) ;
|
||||
3. les routes VIVANTES dans la table du noyau (ce qui vaut maintenant).
|
||||
|
||||
Une route vivante mais non declaree disparait au prochain redemarrage — c'est le cas le
|
||||
plus traitre, parce que tout fonctionne jusqu'a la maintenance suivante.
|
||||
|
||||
Ce n'est pas une preuve du harnais : elle exigerait le cluster, que `make prouver` ne
|
||||
suppose pas joignable. C'est un controle a la demande, comme `make gabarit-etat`.
|
||||
|
||||
Usage :
|
||||
python3 scripts/routes_fabric_etat.py # code de sortie 0 si conforme
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import underlay as underlay_mod # noqa: E402
|
||||
|
||||
# La patte de la frontiere sur le plan d'administration : le prochain saut de ces routes.
|
||||
# DERIVEE, jamais ecrite : c'est l'hote `frontiere` du reseau `grappe-controle`.
|
||||
RESEAU_PROCHAIN_SAUT = "grappe-controle"
|
||||
|
||||
|
||||
def _lire(hote: str, commande: str) -> str:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=8", "-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=no", f"ansible@{hote}", commande],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
return r.stdout if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
u = underlay_mod.charger()
|
||||
if not u:
|
||||
print("Aucun underlay monte : rien a comparer.")
|
||||
return 0
|
||||
|
||||
hotes = underlay_mod.hotes(u)
|
||||
|
||||
# 1. Les zones du site, telles que la carte les declare.
|
||||
zones = sorted({str(r["sous_reseau"]) for r in underlay_mod.reseaux(u)
|
||||
if str(r.get("nom", "")).startswith("site-") and r.get("sous_reseau")})
|
||||
if not zones:
|
||||
print("Le site ne declare aucune zone : rien a router.")
|
||||
return 0
|
||||
|
||||
saut = next((str(h["ip"]) for h in hotes
|
||||
if h.get("role") == "frontiere"
|
||||
and str(h.get("reseau")) == RESEAU_PROCHAIN_SAUT and h.get("ip")), "")
|
||||
if not saut:
|
||||
print(f"Aucune patte `frontiere` sur `{RESEAU_PROCHAIN_SAUT}` : "
|
||||
f"le prochain saut ne se derive pas.")
|
||||
return 1
|
||||
|
||||
# Les hyperviseurs, par leur adresse d'administration — la seule que le poste route.
|
||||
noeuds = {}
|
||||
for h in hotes:
|
||||
if h.get("role") != "hyperviseur" or not h.get("ip"):
|
||||
continue
|
||||
if str(h.get("reseau")) != RESEAU_PROCHAIN_SAUT:
|
||||
continue
|
||||
noeuds.setdefault(str(h["nom"]), str(h["ip"]))
|
||||
if not noeuds:
|
||||
print("Aucun hyperviseur sur le plan d'administration : rien a interroger.")
|
||||
return 0
|
||||
|
||||
print(f"Zones declarees par le site : {len(zones)} — prochain saut {saut}")
|
||||
ecarts: list[str] = []
|
||||
for nom, ip in sorted(noeuds.items()):
|
||||
conf = _lire(ip, "cat /etc/network/interfaces")
|
||||
table = _lire(ip, "ip route show")
|
||||
if not conf and not table:
|
||||
ecarts.append(f"{nom} ({ip}) : injoignable — le controle ne peut rien dire")
|
||||
print(f" {nom:<9} INJOIGNABLE")
|
||||
continue
|
||||
declarees = set(re.findall(r"ip route replace (\d+\.\d+\.\d+\.\d+/\d+) via " + re.escape(saut), conf))
|
||||
vivantes = set(re.findall(r"^(\d+\.\d+\.\d+\.\d+/\d+) via " + re.escape(saut), table, re.M))
|
||||
manque_decl = [z for z in zones if z not in declarees]
|
||||
manque_vive = [z for z in zones if z not in vivantes]
|
||||
# Le cas le plus traitre : vivante mais non declaree — elle part au redemarrage.
|
||||
ephemeres = sorted(vivantes - declarees)
|
||||
etat = "=" if not (manque_decl or manque_vive or ephemeres) else "≠"
|
||||
print(f" {nom:<9} declarees {len(declarees)}/{len(zones)} "
|
||||
f"vivantes {len(vivantes)}/{len(zones)} {etat}")
|
||||
for z in manque_decl:
|
||||
ecarts.append(f"{nom} : {z} n'est pas declaree dans `/etc/network/interfaces` "
|
||||
f"— elle ne survivra pas au redemarrage")
|
||||
for z in manque_vive:
|
||||
ecarts.append(f"{nom} : {z} absente de la table du noyau — les reponses vers "
|
||||
f"cette zone partent par la route par defaut et se perdent")
|
||||
for z in ephemeres:
|
||||
ecarts.append(f"{nom} : {z} est VIVANTE mais non declaree — elle disparaitra "
|
||||
f"au prochain redemarrage, et tout marchera jusque-la")
|
||||
|
||||
if ecarts:
|
||||
print("\nECART — la fabric ne route pas ce que le site declare :")
|
||||
for e in ecarts:
|
||||
print(f" - {e}")
|
||||
print(f"\n Remede, sur le noeud concerne :")
|
||||
print(f" ip route replace <zone> via {saut} dev vmbr0 # tout de suite")
|
||||
print(f" puis la meme ligne en `post-up` dans /etc/network/interfaces # pour apres")
|
||||
print(" L'ordre importe peu ; faire les DEUX importe.")
|
||||
return 1
|
||||
|
||||
print("\nConforme — chaque hyperviseur route toutes les zones du site, "
|
||||
"declarees ET vivantes.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -311,6 +311,44 @@ def inventaire() -> dict:
|
|||
if erreurs:
|
||||
raise SystemExit("plan du site refuse :\n - " + "\n - ".join(erreurs))
|
||||
|
||||
# LES ADRESSES DE LA FABRIC, pour que Prometheus puisse la TIRER (2026-09-10).
|
||||
#
|
||||
# Le job principal de Prometheus vaut `client_metrique ∩ hotes_actifs`, et les
|
||||
# hyperviseurs sont volontairement HORS de `hotes_actifs` : ce groupe sert aux
|
||||
# operations de flotte (`deployer-tout`), et un hyperviseur n'est pas une VM de la
|
||||
# flotte. L'intersection les excluait donc, alors meme qu'ils portent node_exporter.
|
||||
#
|
||||
# `serveur_prometheus_cibles_supplementaires` est le point d'extension prevu par le
|
||||
# role. On y met la fabric sous son PROPRE job : elle n'est pas la flotte, et un
|
||||
# tableau qui les melange ferait croire a un parc homogene.
|
||||
_fabric_metriques = sorted(
|
||||
f"{h['ip']}:9100" for h in U.hotes(u)
|
||||
if str(h.get("role")) == "hyperviseur" and str(h.get("ip", "")).startswith("192.168.11."))
|
||||
|
||||
# CE QUE LA SUPERVISION IRA VOIR : chaque patte de la frontiere, nommee par la zone
|
||||
# qu'elle sert. Les commutateurs restent dehors — D-48 les tient hors flotte, et rien
|
||||
# ne les rend interrogeables sans leur ouvrir un acces qu'on ne veut pas leur ouvrir.
|
||||
_fabric_surveillee = []
|
||||
for _h in U.hotes(u):
|
||||
if str(_h.get("role")) != "frontiere":
|
||||
continue
|
||||
# UN NOM RESERVE N'EST PAS UN EQUIPEMENT. `bifrost-2` est declare pour que le lien
|
||||
# de transit soit documente et que le nom soit pris — le boitier n'existe pas.
|
||||
# Le surveiller donnerait deux CRITICAL permanents, et une alarme toujours rouge
|
||||
# est une alarme qu'on cesse de lire.
|
||||
if str(_h.get("etat") or "actif") != "actif":
|
||||
continue
|
||||
_ip = str(_h.get("ip") or "")
|
||||
_res = str(_h.get("reseau") or "")
|
||||
if not _ip:
|
||||
continue
|
||||
_fabric_surveillee.append({
|
||||
"nom": f"frontiere-{_ip.replace('.', '-')}",
|
||||
"adresse": _ip,
|
||||
"role": f"frontiere — patte {_res or _ip}",
|
||||
})
|
||||
_fabric_surveillee.sort(key=lambda x: x["adresse"])
|
||||
|
||||
hostvars: dict[str, dict] = {}
|
||||
groupes: dict[str, list[str]] = {}
|
||||
|
||||
|
|
@ -440,6 +478,17 @@ def inventaire() -> dict:
|
|||
# Le plan du site, pour les roles qui lisent des registres.
|
||||
"setops_plan_dir": str(plan_dir() or ""),
|
||||
"hosts_statiques_expositions": expositions,
|
||||
# LA FABRIC SURVEILLEE PAR CONTROLE ACTIF (2026-09-10).
|
||||
#
|
||||
# Un pare-feu n'accueille aucun agent : Icinga doit aller VOIR. Une entree par
|
||||
# PATTE de la frontiere, parce que chaque zone depend de son interface a elle —
|
||||
# une interface eteinte coupe une zone pendant que les autres vont bien.
|
||||
**({"serveur_icinga_fabric": _fabric_surveillee}
|
||||
if (_fabric_surveillee and "serveur_icinga" in _groupes_de(nom)) else {}),
|
||||
# La fabric, en job separe — voir `_fabric_metriques` plus haut.
|
||||
**({"serveur_prometheus_cibles_supplementaires":
|
||||
[{"job": "fabric", "cibles": _fabric_metriques}]}
|
||||
if (_fabric_metriques and "serveur_prometheus" in _groupes_de(nom)) else {}),
|
||||
**communes,
|
||||
# En dernier : ce qu'une machine declare d'elle-meme prime sur ce que le
|
||||
# plan declare pour toutes.
|
||||
|
|
@ -461,6 +510,93 @@ def inventaire() -> dict:
|
|||
if hote in hostvars and groupe:
|
||||
groupes.setdefault(str(groupe), []).append(hote)
|
||||
|
||||
# --- LES HYPERVISEURS ENTRENT, MAIS SEULEMENT POUR ETRE VUS (2026-09-10) ----------
|
||||
#
|
||||
# L'Icinga du site ne connaissait que ses sept VM. Mesure : depuis `site-mon-01`, les
|
||||
# trois hyperviseurs ET sa propre passerelle rendaient 100 % de perte au ping. La
|
||||
# machine qui PORTE les VM etait invisible de la supervision qui les surveille.
|
||||
#
|
||||
# D-48 le permet, et le dit : « les hyperviseurs sont gerables par Ansible ; hors
|
||||
# flotte ne vaut que pour les commutateurs et la frontiere ». Ils peuvent donc porter
|
||||
# les integrations d'observation, ce qui vaut infiniment mieux qu'un ping : charge,
|
||||
# disque, unites systemd en echec, certificat.
|
||||
#
|
||||
# ILS N'ENTRENT PAS DANS LE SOCLE, ET C'EST LE POINT DELICAT. Un hyperviseur Proxmox
|
||||
# n'est pas une VM de la flotte : lui appliquer `serveur_durci` reecrirait son
|
||||
# pare-feu, son SSH et ses sysctl — sur la machine qui tient tout le reste. Ils sont
|
||||
# donc places dans leur PROPRE groupe, hors de `GROUPE_SOCLE` et hors de
|
||||
# `GROUPE_FLOTTE` : `make site-appliquer GROUPE=serveur_durci` ne les touchera jamais,
|
||||
# parce qu'ils n'y sont pas.
|
||||
#
|
||||
# Ce que ca coute a dire : un groupe qui recoit des roles doit etre nomme a chaque
|
||||
# fois. C'est le prix d'un perimetre explicite, et il est moins cher qu'un
|
||||
# durcissement applique par megarde a un hyperviseur.
|
||||
# L'adresse du temoin, derivee comme tout le reste : l'hote qui porte `serveur_icinga`.
|
||||
_ip_icinga = ""
|
||||
for _n_app, _a_app in applications.items():
|
||||
if str(_a_app.get("groupe")) == "serveur_icinga":
|
||||
_s = serveurs.get(_a_app.get("hote")) or {}
|
||||
_ip_icinga = str(_s.get("ip") or "")
|
||||
break
|
||||
|
||||
_hyperviseurs = {}
|
||||
for _h in U.hotes(u):
|
||||
if str(_h.get("role")) != "hyperviseur":
|
||||
continue
|
||||
_nom, _ip = str(_h.get("nom") or ""), str(_h.get("ip") or "")
|
||||
if not _nom or not _ip:
|
||||
continue
|
||||
# UNE SEULE ADRESSE PAR HYPERVISEUR : l'underlay en declare trois par machine
|
||||
# (administration, transport, fabric). On retient celle du plan d'ADMINISTRATION,
|
||||
# la seule que le poste et le runner routent — les autres ne sont pas joignables
|
||||
# d'ou l'on parle.
|
||||
if _nom in _hyperviseurs:
|
||||
continue
|
||||
if not _ip.startswith("192.168.11."):
|
||||
continue
|
||||
_hyperviseurs[_nom] = _ip
|
||||
|
||||
for _nom, _ip in sorted(_hyperviseurs.items()):
|
||||
hostvars[_nom] = {
|
||||
"ansible_host": _ip,
|
||||
"ansible_user": UTILISATEUR_DEFAUT,
|
||||
"setops_site": True,
|
||||
"setops_plan_dir": str(plan_dir() or ""),
|
||||
# PAS DE PARE-FEU DERIVE : aucun `nftables_baseline_ruleset_genere`. Un
|
||||
# hyperviseur porte son propre filtrage, pose par Proxmox et par l'exploitant.
|
||||
#
|
||||
# LE TEMOIN SE JOINT PAR SON ADRESSE, PAS PAR SON NOM. Le porteur de sante vise
|
||||
# `<hote>.<domaine>`, ce qui suppose le plancher `/etc/hosts` du socle — que ces
|
||||
# machines n'ont pas, et ne doivent pas avoir : Proxmox s'en sert pour l'identite
|
||||
# de noeud du cluster. Mesure : `curl: (6) Could not resolve host` sur les trois.
|
||||
**({"client_sante_icinga_url": f"https://{_ip_icinga}:5665"}
|
||||
if _ip_icinga else {}),
|
||||
**communes,
|
||||
}
|
||||
groupes.setdefault("hyperviseurs", []).append(_nom)
|
||||
# ON TIRE, ON NE POUSSE PAS — ET C'EST LA ROUTE GELEE QUI LE DECIDE (2026-09-10).
|
||||
#
|
||||
# Toute la supervision de Set-OPS est PASSIVE : chaque noeud mesure et pousse. Ici
|
||||
# c'est impossible, et la mesure le dit sans ambiguite :
|
||||
#
|
||||
# asgard -> 10.0.36.11 via 192.168.11.254 (le routeur du site)
|
||||
# route par defaut via 192.168.11.254 GELEE (D-57)
|
||||
#
|
||||
# Un hyperviseur envoie vers un routeur qui ne connait pas les reseaux du site.
|
||||
# Le porteur de sante expirait donc en 20 s. Le remede evident — router 10.0.0.0/8
|
||||
# par la frontiere — touche la route par defaut d'un hyperviseur EN SERVICE, ce que
|
||||
# D-57 interdit : « risquer de perdre l'hyperviseur ET le chemin pour le reparer ».
|
||||
#
|
||||
# LE SENS INVERSE, LUI, EST OUVRABLE : la frontiere route deja `serveur_ops_site`
|
||||
# vers `SETOPS_FABRIC`. Prometheus TIRE donc les metriques, et rien ne pousse.
|
||||
#
|
||||
# CE QU'ON NE PERD PAS EN ECHANGE. `node_exporter` expose 1005 unites systemd avec
|
||||
# leur etat — exactement ce que la sonde `sante` mesurait, plus la charge, le
|
||||
# disque et l'horloge. On perd le mecanisme du `ttl` (le SILENCE qui alerte) ;
|
||||
# Prometheus le remplace par sa propre cible `down`, que la sonde `collecte`
|
||||
# rapporte deja.
|
||||
groupes.setdefault("client_metrique", []).append(_nom)
|
||||
|
||||
out: dict = {g: {"hosts": sorted(set(h))} for g, h in groupes.items()}
|
||||
out["site"] = {"hosts": sorted(hostvars)}
|
||||
out["_meta"] = {"hostvars": hostvars}
|
||||
|
|
|
|||
Loading…
Reference in a new issue