--- # Recuperee UNE FOIS. Sans garde, chaque deploiement recontactait le serveur du # fournisseur : cinq cles x quatorze hotes = soixante-dix allers-retours externes pour # des cles deja installees, et autant d'occasions qu'un tiers lent fasse tomber le # deploiement. Arbitrage rendu le 2026-08-09 : une plateforme souveraine ne depend pas # de six serveurs etrangers pour redeployer ce qu'elle possede deja. # # CONSEQUENCE ASSUMEE : une rotation de cle amont n'est plus recuperee toute seule. Elle # ne passe pas inapercue pour autant — `apt` refuse alors le depot, bruyamment. Pour # forcer le rafraichissement : supprimer le fichier et rejouer le role. # UN FICHIER VIDE EXISTE (mesure du 2026-09-10). # # Cette garde demandait « ce fichier est-il la ? ». Un telechargement interrompu — un 503, # un delai depasse, une coupure — laisse un fichier de ZERO octet, qui existe. La tache # est donc sautee a tous les passages suivants, et la machine garde une ressource morte # pour toujours, en silence. # # CE QUE CA A COUTE : `infra-mail-01` a garde une cle smallstep de 0 octet apres une # epreuve hors ligne. Treize machines portaient 1022 octets, elle portait le vide : # # E: Le depot http://packages.smallstep.com/... n'est pas signe. # # Le meme jour, l'index `dists/trixie` du cache du site etait tombe de 140 416 a 4 332 # octets — meme famille, autre endroit. Un telechargement partiel ne se signale pas : il # se fait passer pour un succes. # # On demande donc « est-il ENTIER ? » — a defaut de pouvoir demander « est-il JUSTE ? », # ce qui exigerait une empreinte de reference que l'amont ne publie pas toujours. - name: Cette ressource est-elle deja recuperee ? (Telecharger le trousseau de cles I) ansible.builtin.stat: path: "/tmp/icinga-archive-keyring.deb" register: telecharger_le_trousseau_de_cles_icinga_present - name: Telecharger le trousseau de cles Icinga when: not (telecharger_le_trousseau_de_cles_icinga_present.stat.exists and telecharger_le_trousseau_de_cles_icinga_present.stat.size | default(0) > 0) ansible.builtin.get_url: url: "{{ serveur_icinga_keyring_url }}" dest: "/tmp/icinga-archive-keyring.deb" mode: "0644" # RECUPERER N'EST PAS INSTALLER, ET `--check` CASSAIT LA PAIRE (2026-09-14). # # Sans `check_mode: false`, ce telechargement est SIMULE en mode idempotent — et la # tache qui suit cherche alors un fichier qui n'existe pas : # # Unable to install package: E:Could not open file # /tmp/icinga-archive-keyring.deb - open (2: No such file or directory) # # Un essai a blanc rendait donc un echec sur la machine de supervision, alors que le # deploiement reel passe. Le Makefile conseille ce mode : suivre le conseil fabriquait # une fausse panne, et c'est la troisieme de cette famille dans le meme essai. # # Ce que ce `check_mode: false` autorise reellement : deposer un fichier dans `/tmp`. # Rien du systeme gere n'en depend, et c'est ce qui permet a l'installation d'ETRE # evaluee — donc au mode idempotent de dire ce qu'il promet. check_mode: false # LA CLE PASSE PAR LE CACHE, ELLE AUSSI (mesure du 2026-09-10). # # `get_url` ignore la configuration d'apt : le mandataire pose dans # `/etc/apt/apt.conf.d/` ne vaut que pour apt. Les cles de signature sortaient donc # TOUJOURS en direct, meme apres que tous les depots soient passes par le cache — un # trou reste ouvert derriere une porte qu'on croyait fermee. # # Et ce n'est pas theorique : depuis `collab-01`, la route directe vers # `www.collaboraoffice.com` EXPIRE, quand le cache l'atteint sans peine. # # en direct grafana 200 collabora TIMEOUT # via le cache collabora 200 # # Le meme geste corrige donc les deux : plus rien ne sort, et la machine qui n'avait # pas de route en trouve une. # # DEGRADE, JAMAIS DEVINE : sans cache d'amorcage declare, la valeur est VIDE et # `get_url` sort en direct comme avant. On n'invente pas un mandataire. environment: http_proxy: >- {{ ('http://' ~ artefacts_amorcage) if (artefacts_amorcage | default('') | string | length > 0) else '' }} https_proxy: "" - name: Installer le trousseau de cles Icinga ansible.builtin.apt: deb: "/tmp/icinga-archive-keyring.deb" state: present # `apt_repository` AJOUTE, IL NE REMPLACE PAS (mesure du 2026-09-10). # # Passer une source de `https://` a `http://` y ecrit une SECONDE ligne au lieu de # corriger la premiere. apt interroge alors les deux — et l'ancienne continue de sortir # en direct sur Internet, ce que le passage au cache visait justement a supprimer. Le # symptome le disait, sur les quatorze machines : # # W: La cible Packages est specifiee plusieurs fois dans grafana.list:1 et :2 # # On retire donc explicitement la forme precedente. `state: absent` ne mord que si la # ligne existe : sur une machine neuve, cette tache ne fait rien. - name: Retirer la forme HTTPS du depot (elle contournait le cache) ansible.builtin.apt_repository: repo: "{{ serveur_icinga_depot_source | replace('http://', 'https://') }}" filename: icinga state: absent when: serveur_icinga_depot_schema == 'http' - name: Ajouter le depot apt Icinga ansible.builtin.apt_repository: repo: "{{ serveur_icinga_depot_source }}" filename: icinga state: present # LE CACHE DU CONTROLEUR D'ABORD, LE DEPOT DISTANT POUR LE RESTE (2026-09-03). # # Les depots tiers sont en HTTPS, et `client_artefacts` pose # `Acquire::https::Proxy "DIRECT"` — ils CONTOURNENT donc le cache du site et sortent sur # Internet a chaque construction de VM. `make cacher-paquets` les tire une fois, versions # epinglees et empreintes verifiees ; ce role les depose depuis ce cache. - name: Poser les paquets tiers depuis le cache du controleur ansible.builtin.include_role: name: paquets_tiers vars: paquets_tiers_noms: "{{ serveur_icinga_paquets }}" - name: Installer Icinga 2, Icinga DB et Redis dedie ansible.builtin.apt: # CE QUE LE CACHE N'A PAS FOURNI, ET RIEN D'AUTRE. Les dependances Debian passent # par le cache du site en HTTP ; seuls les paquets tiers non caches exigent Internet. # Reinstaller ce que le cache vient de poser ferait un `update_cache` inutile — et # hors ligne, il echouerait APRES un travail deja fait. name: "{{ serveur_icinga_paquets | difference((paquets_tiers_disponibles | default({})).keys() | list) }}" state: present update_cache: true when: (serveur_icinga_paquets | difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0 - name: Resoudre la base de donnees depuis le registre (role partage) ansible.builtin.include_role: name: resoudre_base vars: resoudre_base_groupe: "{{ serveur_icinga_groupe }}" - name: Adopter les facts de base pour Icinga ansible.builtin.set_fact: serveur_icinga_entree: "{{ resoudre_base_entree }}" serveur_icinga_db_password: "{{ resoudre_base_db_password }}" serveur_icinga_db_host: "{{ resoudre_base_db_host }}" no_log: true # --- Identite TLS de l'API (5665) --- # ON NE TOUCHE PAS A `NodeName` — et il n'y a rien a corriger ici. # # `icinga2 api setup` ECRIT lui-meme NodeName d'apres `hostname -f`, puis nomme ses # certificats d'apres lui. Tant que `/etc/hosts` mettait le nom COURT en premier, # `hostname -f` rendait « mon-01 » et le certificat devenait invérifiable en appelant par # le nom complet. On a longuement tente d'aligner NodeName a la main, avant et apres # `api setup` : efface a chaque fois. # # LA CAUSE ETAIT AILLEURS. Depuis que `hosts_statiques` place le FQDN en premier # (2026-08-13), `hostname -f` rend le nom complet et Icinga s'emet spontanement un # certificat CN et SAN = FQDN. Rien a forcer : il suffisait que la machine sache # comment elle s'appelle. - name: Configurer l'API Icinga 2 ansible.builtin.command: cmd: icinga2 api setup creates: /etc/icinga2/features-enabled/api.conf notify: Redemarrer icinga2 - name: Activer la fonctionnalite icingadb dans Icinga 2 ansible.builtin.command: cmd: icinga2 feature enable icingadb creates: /etc/icinga2/features-enabled/icingadb.conf notify: Redemarrer icinga2 - name: Activer et demarrer le Redis Icinga DB when: not ansible_check_mode ansible.builtin.systemd: name: "{{ serveur_icinga_service_redis }}" enabled: true state: started - name: Importer le schema Icinga DB dans PostgreSQL (une fois) ansible.builtin.shell: cmd: >- PGPASSWORD='{{ serveur_icinga_db_password }}' psql -h {{ serveur_icinga_db_host }} -U {{ serveur_icinga_entree.proprietaire }} -d {{ serveur_icinga_entree.base }} -f {{ serveur_icinga_schema }} && touch /etc/icingadb/.schema-imported creates: /etc/icingadb/.schema-imported no_log: true - name: Deployer la configuration Icinga DB ansible.builtin.template: src: config.yml.j2 dest: "{{ serveur_icinga_config }}" owner: root group: icingadb mode: "0640" no_log: true notify: Redemarrer icingadb # `icinga2 api setup` active la fonctionnalite EN MEME TEMPS qu'il pose les certificats, # et sa garde `creates:` la saute des que le fichier existe. Consequence mesuree le # 2026-08-12 : apres un nettoyage des certificats, l'API restait DESACTIVEE — icinga2 # demarrait, se declarait `active`, et n'ecoutait sur rien. Exiger l'activation # separement rend l'etat independant de l'ordre des nettoyages. - name: API — exiger que la fonctionnalite soit activee ansible.builtin.command: cmd: icinga2 feature enable api creates: /etc/icinga2/features-enabled/api.conf notify: Redemarrer icinga2 - name: API — durcir l'ApiListener (aucune config ni commande acceptee) ansible.builtin.template: src: api.conf.j2 dest: /etc/icinga2/features-available/api.conf owner: root group: nagios mode: "0640" notify: Redemarrer icinga2 # --- Supervision des sauvegardes --- # La liste des detenteurs d'etat appartient a `client_backup`. On la LIT chez lui plutot # que de la recopier : deux listes finissent toujours par diverger, et la divergence se # lirait « tout va bien » des deux cotes. - name: Lire la liste des detenteurs d'etat chez client_backup ansible.builtin.include_vars: file: "{{ role_path }}/../client_backup/vars/main.yml" name: _catalogue_sauvegarde - name: Adopter la liste des detenteurs d'etat ansible.builtin.set_fact: client_backup_groupes_etat: "{{ _catalogue_sauvegarde.client_backup_groupes_etat }}" # UN ECOSYSTEME PEUT N'AVOIR AUCUN DEPOT A LUI, ET C'EST LE CAS NORMAL DEPUIS QU'ILS # DEPOSENT CHEZ LEUR HEBERGEUR (2026-09-02). # # Cette assertion exigeait un hote `serveur_backup` dans l'ecosysteme. C'etait juste tant # que le depot y vivait : il etait le seul a voir ce qui arrivait vraiment, et il # rapportait pour tout le monde. # # Depuis la bascule vers le depot du SITE, l'ecosysteme n'en a plus. Le site, lui, ne # peut pas ouvrir ces depots — restic chiffre chez le client. C'est donc chaque NOEUD qui # verifie le sien (`client_backup/tasks/verifier.yml`) et rapporte ici. Ce qui reste # exige, c'est le secret d'API : sans lui, personne ne peut rien rapporter. - name: Exiger le secret d'API pour les rapports passifs (Vault) ansible.builtin.assert: that: - serveur_icinga_api_motdepasse | length > 0 fail_msg: >- vault_icinga_api_depot requis : sans lui, ni le depot ni les noeuds ne peuvent rapporter l'etat de leurs sauvegardes, et personne ne les surveille. # LU AVANT LE COMPTE D'API, ET CE N'EST PAS COSMETIQUE : le filtre de permission # DERIVE de ces declarations. Releve apres, il aurait ete ecrit sans les noms des # sondes — les services auraient existe, et Icinga aurait refuse leurs resultats # avec un 404 « No objects found » sur des objets bien presents. Mesure du # 2026-09-09 : `certificat` manquait au filtre pour cette seule raison. - name: Relever les sondes que les roles declarent ansible.builtin.find: paths: "{{ role_path }}/.." patterns: supervision.yml recurse: true depth: 3 delegate_to: localhost become: false register: serveur_icinga_metas_supervision - name: Lire chaque declaration de supervision ansible.builtin.slurp: src: "{{ item.path }}" delegate_to: localhost become: false loop: "{{ serveur_icinga_metas_supervision.files }}" loop_control: label: "{{ item.path | dirname | dirname | basename }}" register: serveur_icinga_supervision_brute - name: Assembler le registre des sondes (role -> sondes) ansible.builtin.set_fact: serveur_icinga_sondes: >- {{ dict(serveur_icinga_supervision_brute.results | map(attribute='item.path') | map('dirname') | map('dirname') | map('basename') | zip(serveur_icinga_supervision_brute.results | map(attribute='content') | map('b64decode') | map('from_yaml') | map(attribute='sondes'))) }} - name: Deployer le compte d'API du depot de sauvegarde ansible.builtin.template: src: setops-api-users.conf.j2 dest: "{{ serveur_icinga_api_conf }}" owner: root group: nagios mode: "0640" no_log: true notify: Redemarrer icinga2 # LES HOTES D'ABORD : les fichiers de service s'y attachent, et Icinga refuse un service # dont l'hote n'existe pas. L'ordre dans `conf.d` n'est pas garanti par le nom, mais # Icinga charge tout le repertoire avant de resoudre — l'ordre de deploiement suffit. # L'HOTE D'EXEMPLE LIVRE PAR ICINGA : RETIRE (mesure du 2026-09-09). # # `conf.d/hosts.conf` definit `object Host NodeName` — un `localhost` de demonstration # avec `vars.disks`, `vars.http_vhosts`, `vars.os`. Les `apply Service` de # `conf.d/services.conf` s'y accrochent : `disk`, `http`, `swap`, `apt`, `load`, `procs`, # `users`. Aucun ne decrit cet ecosysteme, et trois etaient ROUGES EN PERMANENCE : # # swap : SWAP CRITICAL - 0% free (une VM sans swap) # http : connect to 127.0.0.1:80 (rien n'ecoute la) # apt : 1 package upgradable # # Trois alarmes qui ne peuvent que rester rouges, dans le seul endroit qui doit rester # lisible. C'est deja une raison suffisante — une supervision creuse est pire qu'aucune. # # MAIS LE QUATRIEME NE FAISAIT PAS QUE MENTIR, IL NUISAIT. `check_disk` # 2.4.0-3+deb13u1 ne rend jamais la main sur cet hote (etat `R`, boucle, quels que soient # ses arguments). Icinga en relancait un a CHAQUE intervalle, et aucun ne mourait : # # 8 processus check_disk, 70 a 99 % de CPU chacun, jusqu'a 28 minutes de vie # charge 5,10 sur 4 coeurs — l'hote de supervision sature par sa propre demonstration # # On retire l'HOTE plutot que les services : sans lui, les `apply` ne s'accrochent a rien, # et on ne touche pas a un fichier que le paquet remplacera a la prochaine mise a jour. # Les hotes de cet ecosysteme sont declares par `setops-hotes.conf`. - name: Retirer l'hote de demonstration livre par Icinga ansible.builtin.file: path: /etc/icinga2/conf.d/hosts.conf state: absent notify: Redemarrer icinga2 # LES SONDES DECLAREES PAR LES ROLES (docs/supervision-conception.md). # # On lit les `meta/supervision.yml` sur le CONTROLEUR, pas sur la cible : c'est le depot # qui fait foi, et la cible n'a aucune raison de porter les declarations des autres. # # Lecture explicite plutot que dependance a l'ordre de chargement des roles : un # `client_pki_*` visible ici parce qu'un autre role l'a charge avant serait un couplage # invisible, et il se romprait le jour ou l'ordre change. - name: Deployer les hotes supervises (definis une seule fois) ansible.builtin.template: src: setops-hotes.conf.j2 dest: "{{ serveur_icinga_hotes_conf }}" owner: root group: nagios mode: "0640" notify: Redemarrer icinga2 - name: Deployer les objets de supervision des sauvegardes ansible.builtin.template: src: setops-sauvegardes.conf.j2 dest: "{{ serveur_icinga_setops_conf }}" owner: root group: nagios mode: "0640" notify: Redemarrer icinga2 - name: Deployer les objets de supervision de la sante des noeuds ansible.builtin.template: src: setops-sante.conf.j2 dest: "{{ serveur_icinga_sante_conf }}" owner: root group: nagios mode: "0640" notify: Redemarrer icinga2 - name: Deployer les objets de supervision declares par les roles ansible.builtin.template: src: setops-sondes.conf.j2 dest: "{{ serveur_icinga_sondes_conf }}" owner: root group: nagios mode: "0640" notify: Redemarrer icinga2 # --- LE MATERIEL (2026-09-17) ------------------------------------------------------------ # Hyperviseurs et frontiere : Icinga juge ce que Prometheus a tire, avec le catalogue du # tableau Grafana. Voir `scripts/materiel.py`. - name: Assurer le repertoire des controles actifs Set-OPS ansible.builtin.file: path: "{{ serveur_icinga_materiel_sonde | dirname }}" state: directory owner: root group: root mode: "0755" when: setops_materiel_hotes is defined - name: Deposer le controle du materiel ansible.builtin.copy: src: check_materiel.py dest: "{{ serveur_icinga_materiel_sonde }}" owner: root group: root mode: "0755" when: setops_materiel_hotes is defined - name: Deposer le catalogue des capteurs ansible.builtin.copy: content: "{{ setops_materiel | to_nice_json }}\n" dest: "{{ serveur_icinga_materiel_catalogue }}" owner: root group: nagios mode: "0640" validate: "python3 -c \"import json,sys; json.load(open(sys.argv[1]))\" %s" when: setops_materiel_hotes is defined - name: Deployer les objets de supervision du materiel ansible.builtin.template: src: setops-materiel.conf.j2 dest: "{{ serveur_icinga_materiel_conf }}" owner: root group: nagios mode: "0640" when: setops_materiel_hotes is defined notify: Redemarrer icinga2 - name: Valider la configuration Icinga 2 avant de la rendre vivante ansible.builtin.command: cmd: icinga2 daemon -C changed_when: false - name: Activer et demarrer Icinga 2 et Icinga DB when: not ansible_check_mode ansible.builtin.systemd: name: "{{ item }}" enabled: true state: started loop: - "{{ serveur_icinga_service_icinga2 }}" - "{{ serveur_icinga_service_icingadb }}" # --- Publier l'AC vers le depot de sauvegarde --- # Le depot rapporte l'etat des instantanes a cette API et doit VERIFIER le pair. Il lui # faut donc cette AC — mais lui est un SERVICE et nous une APPLICATION : il se deploie # AVANT nous, et exiger son attente inverserait le graphe des couches (refuse par P08 le # 2026-08-12). C'est donc a NOUS de la lui porter, une fois que `icinga2 api setup` l'a # creee. Sens correct : l'application rejoint le service, jamais l'inverse. - name: Publier l'AC d'Icinga vers le depot de sauvegarde when: groups['serveur_backup'] | default([]) | length > 0 ansible.builtin.slurp: src: "{{ serveur_icinga_ca }}" register: serveur_icinga_ca_contenu # LE REPERTOIRE N'EXISTE PAS AU PREMIER JOUR (2026-09-12). `/etc/setops` est cree par # `client_sante`, qui vit dans une couche POSTERIEURE. Sur une machine deja construite il # est la ; a froid, non — et `copy` ne cree pas ses parents : # # Destination directory /etc/setops does not exist # # On le pose donc ici, avec les memes droits que `client_sante` lui donnerait. Deux roles # qui creent le meme repertoire ne se genent pas ; un role qui suppose qu'un autre est # deja passe, si. - name: Assurer le repertoire d'accueil sur le depot de sauvegarde when: groups['serveur_backup'] | default([]) | length > 0 ansible.builtin.file: path: "{{ serveur_icinga_ca_destination_depot | dirname }}" state: directory owner: root group: root mode: "0755" delegate_to: "{{ groups['serveur_backup'] | first }}" - name: Deposer l'AC sur le depot de sauvegarde when: groups['serveur_backup'] | default([]) | length > 0 ansible.builtin.copy: content: "{{ serveur_icinga_ca_contenu.content | b64decode }}" dest: "{{ serveur_icinga_ca_destination_depot }}" owner: root group: root mode: "0644" delegate_to: "{{ groups['serveur_backup'] | first }}" # --- A QUI PARLER ------------------------------------------------------------- - name: Déclarer le destinataire des alertes ansible.builtin.template: src: setops-users.conf.j2 dest: /etc/icinga2/conf.d/setops-users.conf owner: root group: nagios mode: "0640" when: serveur_icinga_destinataire | length > 0 notify: Redemarrer icinga2 # DIRE CE QU'ON NE FAIT PAS. Sans destinataire, Icinga notifie `root@localhost` — une # adresse que personne ne lit. Le silence alerterait alors dans le vide. - name: Dire que personne ne recevra les alertes ansible.builtin.debug: msg: >- `serveur_icinga_destinataire` n'est pas renseigne : les notifications iront a `root@localhost`, que personne ne lit. La supervision VERRA les defauts sans pouvoir les dire. Declarer l'adresse au plan. when: serveur_icinga_destinataire | length == 0 # --- Sonde de supervision (docs/supervision-conception.md) -------------------------- # Le role qui possede la verite depose sa propre sonde ; le porteur (`client_sante`) la # fait tourner et pousse le verdict, sans savoir ce qu'elle mesure. - name: Assurer le repertoire des sondes de supervision ansible.builtin.file: path: /usr/local/lib/setops/sondes state: directory owner: root group: root mode: "0755" - name: Deposer la sonde « moteur » ansible.builtin.template: src: sonde-moteur.sh.j2 dest: /usr/local/lib/setops/sondes/moteur.sh owner: root group: root mode: "0750"