diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index 248df012..2d99263d 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -1,5 +1,5 @@ [defaults] -callbacks_enabled = minimal +callbacks_enabled = minimal, webhook_notifier collections_path = ./collections deprecation_warnings = False host_key_checking = False @@ -7,6 +7,7 @@ interpreter_python = auto_silent inventory = inventory.py library = ./library action_plugins = ./plugins/action +callback_plugins = ./plugins/callback lookup_plugins = ./plugins/lookup roles_path = ./roles stdout_callback = unixy diff --git a/ansible/playbooks/paas/firewall.yml b/ansible/playbooks/paas/firewall.yml index 1a4390b6..2c19943c 100644 --- a/ansible/playbooks/paas/firewall.yml +++ b/ansible/playbooks/paas/firewall.yml @@ -4,5 +4,10 @@ hosts: "{{ hosts_limit | default('infrastructure') }}" gather_facts: true become: true + pre_tasks: + - name: End the play for hosts because ufw_enable is disabled + ansible.builtin.meta: end_host + when: ufw_enable is defined and not ufw_enable + roles: - ansible-ufw diff --git a/ansible/playbooks/paas/nvidia.yml b/ansible/playbooks/paas/nvidia.yml index 81aae179..bd950cf6 100644 --- a/ansible/playbooks/paas/nvidia.yml +++ b/ansible/playbooks/paas/nvidia.yml @@ -20,7 +20,7 @@ - name: End the play for hosts that don't have nvidia gpu ansible.builtin.meta: end_host - when: not nvidia_enable + when: not (nvidia_enable is defined and nvidia_enable) - name: Créer le répertoire du keyring s'il n'existe pas ansible.builtin.file: diff --git a/ansible/playbooks/saas/roles/simplestack_ansible/templates/nomad.hcl b/ansible/playbooks/saas/roles/simplestack_ansible/templates/nomad.hcl index b375584b..c7fa2cd5 100644 --- a/ansible/playbooks/saas/roles/simplestack_ansible/templates/nomad.hcl +++ b/ansible/playbooks/saas/roles/simplestack_ansible/templates/nomad.hcl @@ -44,6 +44,8 @@ job "{{ domain }}" { SIMPLE_STACK_UI_URL = "{{ lookup('simple-stack-ui', type='secret', key=domain, subkey='url', missing='error') }}" GITHUB_API_TOKEN = "{{ lookup('simple-stack-ui', type='secret', key=domain, subkey='github_api_token', missing='error') }}" ANSIBLE_LOOKUP_PLUGINS = "/ansible/plugins/lookup" + ANSIBLE_CALLBACK_PLUGINS = "/ansible/plugins/callback" + ANSIBLE_CALLBACKS_ENABLED = "minimal,webhook_notifier" } config { diff --git a/ansible/plugins/callback/webhook_notifier.py b/ansible/plugins/callback/webhook_notifier.py new file mode 100644 index 00000000..e9e9711c --- /dev/null +++ b/ansible/plugins/callback/webhook_notifier.py @@ -0,0 +1,298 @@ +# -*- coding: utf-8 -*- +from __future__ import (absolute_import, annotations, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +name: webhook_notifier +type: notification +short_description: Envoie des notifications webhook au démarrage et à la fin des playbooks +description: + - Ce plugin de callback envoie des requêtes HTTP POST à un webhook configuré + - Il notifie au démarrage du playbook, en cas de succès et en cas d'échec + - Utilise l'API Simple Stack UI pour envoyer les notifications +options: + webhook_enabled: + description: Active ou désactive les notifications + env: + - name: ANSIBLE_WEBHOOK_ENABLED + ini: + - section: webhook_notifier + key: enabled + default: true + type: bool +''' + +import base64 +import json +import os +import socket +import datetime +import subprocess + +from ansible.plugins.callback import CallbackBase +from ansible.utils.display import Display + +display = Display() + + +class CallbackModule(CallbackBase): + """ + Plugin de callback pour envoyer des notifications webhook + lors des différentes étapes d'exécution d'un playbook Ansible. + + Configuration via variables d'environnement ou variables Ansible: + - SIMPLE_STACK_UI_URL / webhook_api_url + - SIMPLE_STACK_UI_USER / webhook_user + - SIMPLE_STACK_UI_PASSWORD / webhook_password + """ + + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'notification' + CALLBACK_NAME = 'webhook_notifier' + CALLBACK_NEEDS_WHITELIST = True + + def __init__(self): + super(CallbackModule, self).__init__() + self.playbook_name = None + self.playbook_path = None + self.start_time = None + self.hosts = [] + self.disabled = False + self.play_vars = {} + + def set_options(self, task_keys=None, var_options=None, direct=None): + super(CallbackModule, self).set_options(task_keys=task_keys, var_options=var_options, direct=direct) + + self.webhook_enabled = self.get_option('webhook_enabled') + + if not self.webhook_enabled: + display.vvv("webhook_notifier: Plugin désactivé via configuration") + self.disabled = True + + def _get_connection_config(self, variables: dict = None): + """ + Récupère les informations de connexion à l'API. + Priorité: variables d'environnement > variables Ansible > valeur par défaut + """ + variables = variables or self.play_vars or {} + + api_url = ( + os.environ.get("SIMPLE_STACK_UI_URL") + or variables.get("webhook_api_url") + or "http://127.0.0.1:8000" + ) + + username = ( + os.environ.get("SIMPLE_STACK_UI_USER") + or variables.get("webhook_user") + ) + + password = ( + os.environ.get("SIMPLE_STACK_UI_PASSWORD") + or variables.get("webhook_password") + ) + + return api_url, username, password + + def _send_webhook(self, event_type: str, status: str, message: str, extra_data: dict = None): + """ + Envoie une notification au webhook configuré via curl (subprocess). + Utilise curl pour éviter les problèmes de fork sur macOS. + """ + if self.disabled: + return + + try: + api_url, username, password = self._get_connection_config() + + if not api_url: + display.warning("webhook_notifier: URL de l'API non configurée, notification ignorée") + return + + payload = { + "schema": "events_create", + "data": { + "event_type": event_type, + "status": status, + "message": message, + "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "playbook": { + "name": self.playbook_name or "unknown", + "path": self.playbook_path or "unknown", + }, + "execution": { + "hostname": socket.gethostname(), + "user": os.environ.get("USER", "unknown"), + } + } + } + + if self.start_time: + payload["data"]["execution"]["start_time"] = self.start_time.isoformat() + "Z" + payload["data"]["duration_seconds"] = ( + datetime.datetime.utcnow() - self.start_time + ).total_seconds() + + if self.hosts: + payload["data"]["hosts"] = [str(h) for h in self.hosts] + + if extra_data: + payload["data"].update(extra_data) + + webhook_endpoint = f"{api_url}/api" + json_payload = json.dumps(payload) + + display.vvv(f"webhook_notifier: Envoi vers {webhook_endpoint}") + display.vvvv(f"webhook_notifier: Payload: {json.dumps(payload, indent=2)}") + + # Construire la commande curl + curl_cmd = [ + "curl", "-s", "-S", + "-X", "POST", + "-H", "Content-Type: application/json", + "-H", "User-Agent: Ansible-Webhook-Notifier/1.0", + "--connect-timeout", "5", + "--max-time", "10", + "-d", json_payload, + ] + + # Ajouter l'authentification si configurée + if username and password: + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") + curl_cmd.extend(["-H", f"Authorization: Bearer {token}"]) + + curl_cmd.append(webhook_endpoint) + + # Exécuter curl en arrière-plan (non-bloquant) + # start_new_session=True détache le processus du groupe de processus parent + subprocess.Popen( + curl_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + + display.vvv(f"webhook_notifier: Requête envoyée") + + except Exception as e: + display.warning(f"webhook_notifier: Erreur lors de l'envoi du webhook: {str(e)}") + + # ------------------------------------------------------------------------- + # Callback: Démarrage du playbook + # ------------------------------------------------------------------------- + def v2_playbook_on_start(self, playbook): + """Appelée au démarrage du playbook.""" + try: + self.playbook_path = str(playbook._file_name) if playbook._file_name else "unknown" + self.playbook_name = os.path.basename(self.playbook_path) + self.start_time = datetime.datetime.utcnow() + + display.vvv(f"webhook_notifier: Playbook démarré - {self.playbook_name}") + + self._send_webhook( + event_type="playbook_start", + status="started", + message=f"Playbook '{self.playbook_name}' démarré" + ) + except Exception as e: + display.warning(f"webhook_notifier: Erreur dans v2_playbook_on_start: {str(e)}") + + # ------------------------------------------------------------------------- + # Callback: Statistiques finales (succès ou échec) + # ------------------------------------------------------------------------- + def v2_playbook_on_stats(self, stats): + """ + Appelée à la fin du playbook avec les statistiques d'exécution. + Permet de déterminer si le playbook a réussi ou échoué. + """ + try: + hosts_stats = {} + total_failures = 0 + total_unreachable = 0 + total_ok = 0 + total_changed = 0 + total_skipped = 0 + + for host in stats.processed.keys(): + summary = stats.summarize(host) + hosts_stats[str(host)] = dict(summary) + total_failures += summary.get('failures', 0) + total_unreachable += summary.get('unreachable', 0) + total_ok += summary.get('ok', 0) + total_changed += summary.get('changed', 0) + total_skipped += summary.get('skipped', 0) + + has_failures = total_failures > 0 or total_unreachable > 0 + + stats_summary = { + "stats": { + "total_hosts": len(stats.processed), + "ok": total_ok, + "changed": total_changed, + "failures": total_failures, + "unreachable": total_unreachable, + "skipped": total_skipped, + }, + "hosts_details": hosts_stats + } + + if has_failures: + failed_hosts = [ + str(host) for host, summary in hosts_stats.items() + if summary.get('failures', 0) > 0 or summary.get('unreachable', 0) > 0 + ] + + display.vvv(f"webhook_notifier: Playbook échoué - {self.playbook_name}") + + self._send_webhook( + event_type="playbook_failure", + status="failure", + message=f"Playbook '{self.playbook_name}' échoué sur {len(failed_hosts)} hôte(s): {', '.join(failed_hosts)}", + extra_data=stats_summary + ) + else: + display.vvv(f"webhook_notifier: Playbook réussi - {self.playbook_name}") + + self._send_webhook( + event_type="playbook_success", + status="success", + message=f"Playbook '{self.playbook_name}' terminé avec succès sur {len(stats.processed)} hôte(s)", + extra_data=stats_summary + ) + except Exception as e: + display.warning(f"webhook_notifier: Erreur dans v2_playbook_on_stats: {str(e)}") + + # ------------------------------------------------------------------------- + # Callbacks optionnelles pour collecter plus d'informations + # ------------------------------------------------------------------------- + def v2_playbook_on_play_start(self, play): + """Appelée au démarrage de chaque play - collecte les hôtes ciblés et les variables.""" + try: + variable_manager = play.get_variable_manager() + raw_vars = variable_manager.get_vars() or {} + + self.play_vars = {} + for key in ['webhook_api_url', 'webhook_user', 'webhook_password']: + if key in raw_vars: + self.play_vars[key] = str(raw_vars[key]) + + extra_vars = variable_manager.extra_vars or {} + for key in ['webhook_api_url', 'webhook_user', 'webhook_password']: + if key in extra_vars: + self.play_vars[key] = str(extra_vars[key]) + + display.vvvv(f"webhook_notifier: Variables récupérées: {list(self.play_vars.keys())}") + + hosts = raw_vars.get('ansible_play_hosts_all', []) + if hosts: + self.hosts = [str(h) for h in hosts] + else: + self.hosts = [str(play.hosts)] + + except Exception as e: + display.vvvv(f"webhook_notifier: Impossible de récupérer les variables: {e}") + self.play_vars = {} + try: + self.hosts = [str(play.hosts)] + except Exception: + self.hosts = [] diff --git a/ansible/rulebook.yml b/ansible/rulebook.yml index 205b6d2f..5f55dfe1 100644 --- a/ansible/rulebook.yml +++ b/ansible/rulebook.yml @@ -44,6 +44,14 @@ extra_vars: hosts_limit: "{{ event.payload.meta.hosts }}" + - name: paas-scan_exporter + condition: event.payload.type == "paas-scan_exporter" + actions: + - run_playbook: + name: playbooks/paas/scan_exporter.yml + extra_vars: + hosts_limit: "{{ event.payload.meta.hosts }}" + - name: saas-deploy condition: event.payload.type == "saas-deploy" actions: diff --git a/ui/index.js.map b/ui/index.js.map index 187fe57f..bccae48b 100644 --- a/ui/index.js.map +++ b/ui/index.js.map @@ -178,7 +178,7 @@ "url": "/api/", "auth": 1, "id": "events_create", - "input": "*event:{build|saas|paas}, *type:{info|warning|error}, *body:String", + "input": "event_type, status, message, timestamp, playbook:Object, stats:Object, hosts_details:Object", "name": "Create an event" }, { @@ -186,7 +186,6 @@ "url": "/api/", "auth": 1, "id": "events_read", - "input": "*event:{build|saas|paas}", "name": "Read a catalog item" }, { @@ -194,7 +193,6 @@ "url": "/api/", "auth": 1, "id": "events_remove", - "input": "*event:{build|saas|paas}", "name": "Remove a type of event" }, { @@ -479,17 +477,15 @@ }, { "name": "Events/create", - "input": "*event:{build|saas|paas}, *type:{info|warning|error}, *body:String", + "input": "event_type, status, message, timestamp, playbook:Object, stats:Object, hosts_details:Object", "permissions": "events" }, { "name": "Events/read", - "input": "*event:{build|saas|paas}", "permissions": "events" }, { "name": "Events/remove", - "input": "*event:{build|saas|paas}", "permissions": "events" }, { diff --git a/ui/public/forms/catalogs.html b/ui/public/forms/catalogs.html index 622d5203..86aac22b 100644 --- a/ui/public/forms/catalogs.html +++ b/ui/public/forms/catalogs.html @@ -93,7 +93,7 @@ }; exports.refresh_events = function(el) { - exports.tapi('events_read ERROR', { event: 'build' }, function(result){ + exports.tapi('events_read ERROR', function(result){ SET('?.console_logs', result); }); }; @@ -107,13 +107,12 @@ }; exports.close = function() { - console.log('close'); clearInterval(interval); }; exports.clear_events = function(el) { clearInterval(interval); - exports.tapi('events_remove ERROR', { event: 'build' }, function(){ + exports.tapi('events_remove ERROR', function(){ SET('?.console_visible', false); }); }; diff --git a/ui/public/forms/infrastructures.html b/ui/public/forms/infrastructures.html index ebb43621..030e839b 100644 --- a/ui/public/forms/infrastructures.html +++ b/ui/public/forms/infrastructures.html @@ -91,7 +91,7 @@ }; exports.refresh_events = function(el) { - exports.tapi('events_read ERROR', { event: 'paas' }, function(result){ + exports.tapi('events_read ERROR', function(result){ SET('?.console_logs', result); }); }; @@ -105,13 +105,12 @@ }; exports.close = function() { - console.log('close'); clearInterval(interval); }; exports.clear_events = function(el) { clearInterval(interval); - exports.tapi('events_remove ERROR', { event: 'paas' }, function(){ + exports.tapi('events_remove ERROR', function(){ SET('?.console_visible', false); }); }; @@ -133,6 +132,8 @@ options.items.push({ action: 'firewall', path: 'execute', name: 'firewall', icon: 'ti ti-play', comment: '@(Do you want to run firewall playbook ?)'}); options.items.push({ action: 'metrology', path: 'execute', name: 'metrology', icon: 'ti ti-play', comment: '@(Do you want to run metrology playbook ?)'}); options.items.push('-'); + options.items.push({ action: 'scan_exporter', path: 'execute', name: 'Prometheus scan exporter', icon: 'ti ti-play', comment: '@(Do you want to run scan_exporter?)'}); + options.items.push('-'); options.items.push({ action: 'nomad-clean-errors', path: 'execute', name: 'Clean nomad errors', icon: 'ti ti-play orange', comment: '@(Do you want to clean nomad errors ?)'}); options.items.push('-'); options.items.push({ action: 'remove', path: 'remove', name: 'Remove', icon: 'ti ti-trash red', comment: '@(Remove selected infrastructures ?)'}); diff --git a/ui/public/forms/softwares.html b/ui/public/forms/softwares.html index 42dff219..0ee836aa 100644 --- a/ui/public/forms/softwares.html +++ b/ui/public/forms/softwares.html @@ -18,8 +18,9 @@