--- - name: Résoudre la base IcingaDB depuis le registre (rôle partagé) ansible.builtin.include_role: name: resoudre_base vars: resoudre_base_groupe: "{{ serveur_icingaweb2_icinga_groupe }}" # ON RECOPIE TOUT DE SUITE, PARCE QU'UN SECOND APPEL ECRASE LE PREMIER (2026-09-15). # # `resoudre_base` rend ses resultats dans des faits PARTAGES — `resoudre_base_entree`, # `resoudre_base_db_host`... Ce role l'appelle DEUX fois : une pour la base du MOTEUR # (icingadb, en lecture) et une pour celle des COMPTES (icingaweb2). Le second appel # remplace les faits du premier, et les gabarits rendus APRES les deux lisaient donc la # seconde base partout. # # CE QUE CA A DONNE, mesure sur la machine : # # [icingadb] dbname = "icingaweb2" <- la base des comptes # [icingaweb_db] dbname = "icingaweb2" # # La console cherchait `icingadb_schema` dans la base des comptes, ne l'y trouvait pas, et # rendait une trace PHP a chaque page. Les 66 tables du moteur etaient intactes a cote. # # UN ROLE PARTAGE REND DES FAITS PARTAGES : celui qui l'appelle deux fois doit nommer ses # resultats avant de le rappeler. C'est la meme lecon que « une liste qui suit une autre », # appliquee au temps plutot qu'a l'espace. - name: Adopter la base du MOTEUR avant de résoudre celle des comptes ansible.builtin.set_fact: serveur_icingaweb2_moteur_host: "{{ resoudre_base_db_host }}" serveur_icingaweb2_moteur_port: "{{ resoudre_base_db_port }}" serveur_icingaweb2_moteur_base: "{{ resoudre_base_entree.base }}" serveur_icingaweb2_moteur_utilisateur: "{{ resoudre_base_entree.proprietaire }}" serveur_icingaweb2_moteur_motdepasse: "{{ resoudre_base_db_password }}" no_log: true # --- MODE `db` : LA BASE DES COMPTES, ET LE COMPTE D'AMORCAGE (2026-09-15) ------------ # # TOUT EN HAUT, ET C'EST L'ORDRE QUI L'EXIGE. `resources.ini` nomme cette base ; il est # rendu quelques taches plus bas. Pose apres lui — la ou vivait l'ancien vestibule — ce # bloc laissait le gabarit lire des variables qui n'existaient pas encore, et l'echec # etait CENSURE par `no_log` : « changed: true » et rien d'autre. Une tache qui rend des # secrets ne peut pas dire ce qui lui manque ; c'est a l'ordre de ne pas la mettre dans # cette situation. # # Icinga Web 2 gere ses comptes NATIVEMENT. La premiere ecriture posait un `auth_basic` # nginx devant le backend `external` — ca marchait, et ca reinventait une page de # connexion devant une application qui en a une, en privant l'exploitant de la gestion # des comptes dans l'interface. Un vestibule n'a de sens que devant une application qui # ne sait pas s'authentifier. - name: Résoudre la base des comptes de la console (rôle partagé) ansible.builtin.include_role: name: resoudre_base vars: resoudre_base_groupe: "icingaweb2" # DANS TOUS LES MODES (2026-10-03), plus seulement `db`. Le cadre de migration d'Icinga # Web 2 veut une base quoi qu'il arrive : sans elle, chaque page qui affiche le menu de # configuration journalise « Failed to load pending migrations : Please check if a db # instance exists at all » — 145 fois par heure chez Chezlepro, vigie ouverte, depuis # la reconstruction. Le site avait recu sa base le 2026-09-15 ; les locataires, en SSO, # etaient restes sans. Le badge est calcule sans condition de permission # (`ConfigMenu::createMigrationBadge`) : aucun reglage de role ne le fait taire. - name: Adopter la base des comptes pour Icinga Web 2 ansible.builtin.set_fact: serveur_icingaweb2_db_host: "{{ resoudre_base_db_host }}" serveur_icingaweb2_db_port: "{{ resoudre_base_db_port }}" serveur_icingaweb2_db_base: "{{ resoudre_base_entree.base }}" serveur_icingaweb2_db_utilisateur: "{{ resoudre_base_entree.proprietaire }}" serveur_icingaweb2_db_motdepasse: "{{ resoudre_base_db_password }}" no_log: true - name: Exiger un mot de passe pour le compte d'amorçage ansible.builtin.assert: that: - serveur_icingaweb2_admin_motdepasse | length > 0 fail_msg: >- `serveur_icingaweb2_auth: db` mais `serveur_icingaweb2_admin_motdepasse` est vide. Une console qui montre l'état de toute une fabric ne s'ouvre pas avec un mot de passe deviné : renseigner la clé dans la voûte de cet écosystème. when: serveur_icingaweb2_auth == 'db' # LE CLIENT POSTGRESQL DES MODULES `community.postgresql`, SUR CETTE MACHINE (2026-10-03). # # Les requetes ci-dessous s'executent sur l'hote de la vigie, pas sur le serveur de base. # Au site, les deux sont la meme machine : `serveur_postgresql` y avait deja pose # `python3-psycopg2`, et l'absence ne se voyait pas. Chez un locataire, la vigie vit sur # `mon-01` et la base sur `data-sql-01` — premier passage apres que la base est devenue # commune a tous les modes : `No module named 'psycopg2'`, masque par `no_log`. - name: Installer le client PostgreSQL de Python (modules community.postgresql) ansible.builtin.apt: name: python3-psycopg2 state: present # LE SCHEMA NE SE CHARGE QU'UNE FOIS. Le rejouer sur une base deja peuplee echouerait sur # les objets existants — on lit donc d'abord si la table des comptes est la. - name: La base de la console est-elle déjà en place ? community.postgresql.postgresql_query: login_host: "{{ serveur_icingaweb2_db_host }}" login_user: "{{ serveur_icingaweb2_db_utilisateur }}" login_password: "{{ serveur_icingaweb2_db_motdepasse }}" login_db: "{{ serveur_icingaweb2_db_base }}" ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" query: "SELECT to_regclass('public.icingaweb_user') IS NOT NULL AS presente" register: serveur_icingaweb2_schema_etat changed_when: false no_log: true when: - not ansible_check_mode - name: Charger le schéma de la console (comptes, préférences, migrations) community.postgresql.postgresql_script: login_host: "{{ serveur_icingaweb2_db_host }}" login_user: "{{ serveur_icingaweb2_db_utilisateur }}" login_password: "{{ serveur_icingaweb2_db_motdepasse }}" login_db: "{{ serveur_icingaweb2_db_base }}" ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" path: "{{ serveur_icingaweb2_schema }}" no_log: true when: - not ansible_check_mode - not (serveur_icingaweb2_schema_etat.query_result[0].presente | default(false)) # LE HACHAGE EST CELUI QUE L'APPLICATION VERIFIE, PAS UN QU'ON CHOISIT. `password_hash()` # de PHP est exactement la fonction qu'Icinga Web 2 appelle a la connexion ; produire un # hachage d'un autre outil donnerait une base valide et une connexion impossible. - name: Hacher le mot de passe du compte d'amorçage ansible.builtin.command: argv: - php - -r - 'echo password_hash($argv[1], PASSWORD_DEFAULT);' - "{{ serveur_icingaweb2_admin_motdepasse }}" register: serveur_icingaweb2_hachage changed_when: false no_log: true when: - serveur_icingaweb2_auth == 'db' - not ansible_check_mode # CREE S'IL MANQUE, JAMAIS ECRASE. Un mot de passe change dans l'interface appartient a # celui qui l'a change : le moteur ne le reprend pas au deploiement suivant. Il ne pose # que le compte qui permet d'entrer la premiere fois. - name: Poser le compte d'amorçage s'il n'existe pas community.postgresql.postgresql_query: login_host: "{{ serveur_icingaweb2_db_host }}" login_user: "{{ serveur_icingaweb2_db_utilisateur }}" login_password: "{{ serveur_icingaweb2_db_motdepasse }}" login_db: "{{ serveur_icingaweb2_db_base }}" ssl_mode: "{{ serveur_icingaweb2_db_sslmode | default('prefer', true) or 'prefer' }}" ca_cert: "{{ serveur_icingaweb2_db_sslrootcert if serveur_icingaweb2_db_sslmode | default('') else omit }}" query: >- INSERT INTO icingaweb_user (name, active, password_hash) VALUES (%s, 1, %s) ON CONFLICT (name) DO NOTHING positional_args: - "{{ serveur_icingaweb2_admin_utilisateur }}" - "{{ serveur_icingaweb2_hachage.stdout }}" no_log: true when: - serveur_icingaweb2_auth == 'db' - not ansible_check_mode # ON NE RESOUT PAS UN ANNUAIRE QUAND ON N'EN A PAS (2026-09-14). # # `resoudre_annuaire` etait appele SANS CONDITION. Au site — qui n'a ni LDAP ni Keycloak — # il tombait sur un plan sans application d'annuaire : # # object of type 'NoneType' has no len() # # Un message qui ne nomme ni l'annuaire, ni le role qui le demandait, ni la raison. Le # mode `locale` existe precisement pour se passer d'annuaire : il n'a rien a resoudre. - name: Résoudre l'annuaire (rôle partagé) ansible.builtin.include_role: name: resoudre_annuaire vars: # SON compte, pas celui de l'administrateur de l'annuaire (2026-09-13). resoudre_annuaire_bind_cn: "icingaweb2" resoudre_annuaire_secret: "vault_ldap_bind_icingaweb2" # LES MODES NOMMES, PAS UNE NEGATION. `!= 'locale'` etait juste tant que `locale` # etait le seul mode sans annuaire ; renomme en `db`, la garde a cesse de garder et # `resoudre_annuaire` s'est remis a exiger un secret de liaison qui n'existe pas. # Une condition qui dit ce qu'elle VEUT survit a un renommage ; une qui dit ce qu'elle # refuse, non. when: serveur_icingaweb2_auth in ('ldap', 'external') - name: Adopter la connexion annuaire pour Icinga Web 2 ansible.builtin.set_fact: serveur_icingaweb2_ldap_url: "{{ resoudre_annuaire_uri }}" serveur_icingaweb2_ldap_port: "{{ resoudre_annuaire_port }}" serveur_icingaweb2_ldap_base: "{{ resoudre_annuaire_base_dn }}" serveur_icingaweb2_ldap_users_dn: "{{ resoudre_annuaire_users_dn }}" serveur_icingaweb2_ldap_bind_dn: "{{ resoudre_annuaire_bind_dn }}" serveur_icingaweb2_ldap_bind_password: "{{ resoudre_annuaire_bind_password }}" no_log: true # LES MODES NOMMES, PAS UNE NEGATION. `!= 'locale'` etait juste tant que `locale` # etait le seul mode sans annuaire ; renomme en `db`, la garde a cesse de garder et # `resoudre_annuaire` s'est remis a exiger un secret de liaison qui n'existe pas. # Une condition qui dit ce qu'elle VEUT survit a un renommage ; une qui dit ce qu'elle # refuse, non. when: serveur_icingaweb2_auth in ('ldap', 'external') # 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. # PHP-FPM ET NGINX AVANT LES PAQUETS TIERS (2026-10-03). Les paquets d'Icinga exigent # « un PHP pour le web », et apt choisit la PREMIERE alternative qu'aucun paquet present ne # satisfait : `libapache2-mod-php`, qui tire apache2. Chez les locataires, tout s'installe # en une transaction avec `php8.4-fpm`, et apt n'a pas a choisir. Au site, les paquets # tiers viennent du cache du controleur, SEULS, avant le reste : `site-mon-01` portait un # apache2 ecoutant sur le port 80 depuis le 2026-09-14, que rien n'utilisait. - name: Installer PHP-FPM et nginx avant les paquets tiers (sinon apt choisit apache2) ansible.builtin.apt: name: "{{ serveur_icingaweb2_web_prealables }}" state: present update_cache: true - name: Poser les paquets tiers depuis le cache du controleur ansible.builtin.include_role: name: paquets_tiers vars: paquets_tiers_noms: "{{ serveur_icingaweb2_paquets }}" - name: Installer Icinga Web 2 + module IcingaDB + PHP-FPM + nginx 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_icingaweb2_paquets | difference((paquets_tiers_disponibles | default({})).keys() | list) }}" state: present update_cache: true when: (serveur_icingaweb2_paquets | difference((paquets_tiers_disponibles | default({})).keys() | list)) | length > 0 # LE RESTE D'UNE INSTALLATION DANS LE MAUVAIS ORDRE (voir plus haut). On ne le retire que # si apt confirme qu'il part SEUL : `state: absent` emporterait aussi tout paquet qui en # dependrait, et ce role n'a pas a decider de leur sort. - name: Simuler le retrait d'apache2 (rien d'autre ne doit partir) ansible.builtin.command: argv: "{{ ['apt-get', '-s', 'purge'] + serveur_icingaweb2_apache_restes }}" register: serveur_icingaweb2_apache_simulation changed_when: false check_mode: false - name: Retirer apache2, que rien n'utilise ansible.builtin.apt: name: "{{ serveur_icingaweb2_apache_restes }}" state: absent purge: true when: - serveur_icingaweb2_apache_simulation.stdout_lines | select('match', '^(Purg|Remv) ') | list | length > 0 - (serveur_icingaweb2_apache_simulation.stdout_lines | select('match', '^(Purg|Remv) ') | map('regex_replace', '^(?:Purg|Remv) (\\S+).*$', '\\1') | list) is subset(serveur_icingaweb2_apache_restes) - name: Ajouter www-data au groupe icingaweb2 (lecture de la config) ansible.builtin.user: name: www-data groups: icingaweb2 append: true notify: Redémarrer php-fpm - name: Assurer le répertoire du module IcingaDB ansible.builtin.file: path: "{{ serveur_icingaweb2_config_dir }}/modules/icingadb" state: directory owner: root group: icingaweb2 mode: "0750" - name: Déployer la configuration icingaweb2 (.ini) ansible.builtin.template: src: "{{ item.s }}" dest: "{{ serveur_icingaweb2_config_dir }}/{{ item.d }}" owner: root group: icingaweb2 mode: "0640" no_log: "{{ item.secret | default(false) }}" loop: - { s: config.ini.j2, d: config.ini } - { s: resources.ini.j2, d: resources.ini, secret: true } - { s: authentication.ini.j2, d: authentication.ini } - { s: groups.ini.j2, d: groups.ini } - { s: roles.ini.j2, d: roles.ini } - { s: icingadb-config.ini.j2, d: modules/icingadb/config.ini } - { s: icingadb-redis.ini.j2, d: modules/icingadb/redis.ini } loop_control: label: "{{ item.d }}" notify: Redémarrer php-fpm - name: Activer les modules icingaweb2 (IcingaDB, BPM) ansible.builtin.command: "icingacli module enable {{ item }}" args: creates: "{{ serveur_icingaweb2_config_dir }}/enabledModules/{{ item }}" loop: "{{ serveur_icingaweb2_modules }}" when: not ansible_check_mode - name: Assurer le répertoire des processus BPM (éditable via l'UI) ansible.builtin.file: path: "{{ serveur_icingaweb2_config_dir }}/modules/businessprocess/processes" state: directory owner: root group: icingaweb2 mode: "2770" when: "'businessprocess' in serveur_icingaweb2_modules" - name: Semer les processus métier BPM (IaC, optionnel) ansible.builtin.copy: content: "{{ item.value }}" dest: "{{ serveur_icingaweb2_config_dir }}/modules/businessprocess/processes/{{ item.key }}.conf" owner: root group: icingaweb2 mode: "0660" loop: "{{ serveur_icingaweb2_bpm_processes | dict2items }}" loop_control: label: "{{ item.key }}" when: serveur_icingaweb2_bpm_processes | length > 0 # --- LES VUES METIER, DERIVEES (2026-10-04) — voir defaults/main.yml ------------------- # # Meme lecture que `serveur_icinga` : les `meta/supervision.yml` de tous les roles, sur le # controleur. Les vues ne nomment ainsi que ce qu'Icinga surveille vraiment. - name: Vues métier — relever les sondes que les rôles déclarent ansible.builtin.find: paths: "{{ role_path }}/.." patterns: supervision.yml recurse: true depth: 3 delegate_to: localhost become: false register: serveur_icingaweb2_metas_supervision when: "'businessprocess' in serveur_icingaweb2_modules" - name: Vues métier — lire chaque déclaration ansible.builtin.slurp: src: "{{ item.path }}" delegate_to: localhost become: false loop: "{{ serveur_icingaweb2_metas_supervision.files | default([]) }}" loop_control: label: "{{ item.path | dirname | dirname | basename }}" register: serveur_icingaweb2_supervision_brute when: "'businessprocess' in serveur_icingaweb2_modules" # Les detenteurs d'etat appartiennent a `client_backup` : on les LIT chez lui, comme # `serveur_icinga` — deux listes divergeraient, et la vue nommerait des sauvegardes # qu'Icinga n'attend pas. - name: Vues métier — lire la liste des détenteurs d'état chez client_backup ansible.builtin.include_vars: file: "{{ role_path }}/../client_backup/vars/main.yml" name: serveur_icingaweb2_catalogue_sauvegarde when: "'businessprocess' in serveur_icingaweb2_modules" - name: Vues métier — assembler le registre des sondes et les sauvegardes attendues ansible.builtin.set_fact: serveur_icingaweb2_sondes: >- {{ dict(serveur_icingaweb2_supervision_brute.results | map(attribute='item.path') | map('dirname') | map('dirname') | map('basename') | zip(serveur_icingaweb2_supervision_brute.results | map(attribute='content') | map('b64decode') | map('from_yaml') | map(attribute='sondes'))) }} serveur_icingaweb2_bpm_hote_sauvegarde: "{{ (groups['serveur_backup'] | default([]) | first) | default('') }}" serveur_icingaweb2_bpm_sauvegarde_attendue: >- {{ (serveur_icingaweb2_catalogue_sauvegarde.client_backup_groupes_etat | default([]) | map('extract', groups) | select('defined') | flatten | unique | list) | intersect(groups['client_backup'] | default([])) }} when: "'businessprocess' in serveur_icingaweb2_modules" - name: Vues métier — calculer les contrôles de chaque service ansible.builtin.set_fact: serveur_icingaweb2_bpm_membres: "{{ lookup('ansible.builtin.template', 'bpm-membres.json.j2') | from_json }}" when: "'businessprocess' in serveur_icingaweb2_modules" - name: Vues métier — retenir les vues qui ont au moins un service ansible.builtin.set_fact: serveur_icingaweb2_bpm_vues_posees: >- {{ serveur_icingaweb2_bpm_vues | dict2items | selectattr('key', 'in', serveur_icingaweb2_bpm_services | selectattr('cle', 'in', serveur_icingaweb2_bpm_membres.keys() | list) | map(attribute='vues') | flatten | unique | list) | rejectattr('value.requiert', 'defined') | map(attribute='key') | list + (serveur_icingaweb2_bpm_vues | dict2items | selectattr('value.requiert', 'defined') | selectattr('value.requiert', 'in', serveur_icingaweb2_bpm_membres.keys() | list) | map(attribute='key') | list) }} when: "'businessprocess' in serveur_icingaweb2_modules" - name: Vues métier — poser chaque vue ansible.builtin.template: src: bpm-vue.conf.j2 dest: "{{ serveur_icingaweb2_config_dir }}/modules/businessprocess/processes/setops-{{ bpm_vue }}.conf" owner: root group: icingaweb2 mode: "0660" loop: "{{ serveur_icingaweb2_bpm_vues_posees }}" loop_control: loop_var: bpm_vue when: "'businessprocess' in serveur_icingaweb2_modules" # UNE VUE QUI N'A PLUS DE SERVICE SE RETIRE, et l'ancien exemple avec elle. Seuls les # fichiers que ce role a poses sont concernes : un processus cree dans l'interface garde # son nom, et le role n'y touche jamais. - name: Vues métier — retirer les vues vides et les processus abandonnés ansible.builtin.file: path: "{{ serveur_icingaweb2_config_dir }}/modules/businessprocess/processes/{{ item }}.conf" state: absent loop: >- {{ (serveur_icingaweb2_bpm_vues.keys() | reject('in', serveur_icingaweb2_bpm_vues_posees | default([])) | map('regex_replace', '^', 'setops-') | list) + serveur_icingaweb2_bpm_retires }} when: "'businessprocess' in serveur_icingaweb2_modules" - name: Déployer le vhost nginx local d'Icinga Web 2 ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/sites-available/icingaweb2.conf owner: root group: root mode: "0644" notify: Recharger nginx - name: Activer le vhost Icinga Web 2 ansible.builtin.file: src: /etc/nginx/sites-available/icingaweb2.conf dest: /etc/nginx/sites-enabled/icingaweb2.conf state: link notify: Recharger nginx - name: Retirer le site nginx par défaut ansible.builtin.file: path: /etc/nginx/sites-enabled/default state: absent notify: Recharger nginx - name: Activer et démarrer PHP-FPM et nginx ansible.builtin.systemd: name: "{{ item }}" enabled: true state: started loop: - "{{ serveur_icingaweb2_php_fpm_service }}" - "{{ serveur_icingaweb2_nginx_service }}" when: not ansible_check_mode # LE ROLE QUI POSSEDE LA VERITE DEPOSE SA PROPRE SONDE. `client_sante` la fait tourner et # pousse le resultat ; les greffons standard, eux, sont poses par le porteur. # # LA SUPERVISION SURVEILLE SA PROPRE VITRINE. Sans cette sonde, une console morte laisse # tous les verdicts au vert — le moteur va bien, les sondes poussent, et personne ne peut # plus rien lire. - 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 « vigie » ansible.builtin.template: src: sonde-vigie.sh.j2 dest: /usr/local/lib/setops/sondes/vigie.sh owner: root group: root mode: "0750"