diff options
Diffstat (limited to 'ansible_collections/community/grafana/plugins')
17 files changed, 4247 insertions, 0 deletions
diff --git a/ansible_collections/community/grafana/plugins/callback/__init__.py b/ansible_collections/community/grafana/plugins/callback/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/grafana/plugins/callback/__init__.py diff --git a/ansible_collections/community/grafana/plugins/callback/grafana_annotations.py b/ansible_collections/community/grafana/plugins/callback/grafana_annotations.py new file mode 100644 index 000000000..04555eae0 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/callback/grafana_annotations.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' + name: grafana_annotations + type: notification + short_description: send ansible events as annotations on charts to grafana over http api. + author: "Rémi REY (@rrey)" + description: + - This callback will report start, failed and stats events to Grafana as annotations (https://grafana.com) + requirements: + - whitelisting in configuration + options: + grafana_url: + description: Grafana annotations api URL + required: True + env: + - name: GRAFANA_URL + ini: + - section: callback_grafana_annotations + key: grafana_url + type: string + validate_certs: + description: validate the SSL certificate of the Grafana server. (For HTTPS url) + env: + - name: GRAFANA_VALIDATE_CERT + ini: + - section: callback_grafana_annotations + key: validate_grafana_certs + - section: callback_grafana_annotations + key: validate_certs + default: True + type: bool + aliases: [ validate_grafana_certs ] + http_agent: + description: The HTTP 'User-agent' value to set in HTTP requets. + env: + - name: HTTP_AGENT + ini: + - section: callback_grafana_annotations + key: http_agent + default: 'Ansible (grafana_annotations callback)' + type: string + grafana_api_key: + description: Grafana API key, allowing to authenticate when posting on the HTTP API. + If not provided, grafana_login and grafana_password will + be required. + env: + - name: GRAFANA_API_KEY + ini: + - section: callback_grafana_annotations + key: grafana_api_key + type: string + grafana_user: + description: Grafana user used for authentication. Ignored if grafana_api_key is provided. + env: + - name: GRAFANA_USER + ini: + - section: callback_grafana_annotations + key: grafana_user + default: ansible + type: string + grafana_password: + description: Grafana password used for authentication. Ignored if grafana_api_key is provided. + env: + - name: GRAFANA_PASSWORD + ini: + - section: callback_grafana_annotations + key: grafana_password + default: ansible + type: string + grafana_dashboard_id: + description: The grafana dashboard id where the annotation shall be created. + env: + - name: GRAFANA_DASHBOARD_ID + ini: + - section: callback_grafana_annotations + key: grafana_dashboard_id + type: integer + grafana_panel_ids: + description: The grafana panel ids where the annotation shall be created. + Give a single integer or a comma-separated list of integers. + env: + - name: GRAFANA_PANEL_IDS + ini: + - section: callback_grafana_annotations + key: grafana_panel_ids + default: [] + type: list + elements: integer +''' + +import json +import socket +import getpass +from datetime import datetime + +from ansible.module_utils._text import to_text +from ansible.module_utils.urls import open_url +from ansible.plugins.callback import CallbackBase + + +PLAYBOOK_START_TXT = """\ +Started playbook {playbook} + +From '{hostname}' +By user '{username}' +""" + +PLAYBOOK_ERROR_TXT = """\ +Playbook {playbook} Failure ! + +From '{hostname}' +By user '{username}' + +'{task}' failed on {host} + +debug: {result} +""" + +PLAYBOOK_STATS_TXT = """\ +Playbook {playbook} +Duration: {duration} +Status: {status} + +From '{hostname}' +By user '{username}' + +Result: +{summary} +""" + + +def to_millis(dt): + return int(dt.strftime('%s')) * 1000 + + +class CallbackModule(CallbackBase): + """ + ansible grafana callback plugin + ansible.cfg: + callback_plugins = <path_to_callback_plugins_folder> + callback_whitelist = grafana_annotations + and put the plugin in <path_to_callback_plugins_folder> + """ + + CALLBACK_VERSION = 2.0 + CALLBACK_TYPE = 'aggregate' + CALLBACK_NAME = 'community.grafana.grafana_annotations' + CALLBACK_NEEDS_WHITELIST = True + + def __init__(self, display=None): + + super(CallbackModule, self).__init__(display=display) + + self.headers = {'Content-Type': 'application/json'} + self.force_basic_auth = False + self.hostname = socket.gethostname() + self.username = getpass.getuser() + self.start_time = datetime.now() + self.errors = 0 + + 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.grafana_api_key = self.get_option('grafana_api_key') + self.grafana_url = self.get_option('grafana_url') + self.validate_grafana_certs = self.get_option('validate_certs') + self.http_agent = self.get_option('http_agent') + self.grafana_user = self.get_option('grafana_user') + self.grafana_password = self.get_option('grafana_password') + self.dashboard_id = self.get_option('grafana_dashboard_id') + self.panel_ids = self.get_option('grafana_panel_ids') + + if self.grafana_api_key: + self.headers['Authorization'] = "Bearer %s" % self.grafana_api_key + else: + self.force_basic_auth = True + + if self.grafana_url is None: + self.disabled = True + self._display.warning('Grafana URL was not provided. The ' + 'Grafana URL can be provided using ' + 'the `GRAFANA_URL` environment variable.') + self._display.debug('Grafana URL: %s' % self.grafana_url) + + def v2_playbook_on_start(self, playbook): + self.playbook = playbook._file_name + text = PLAYBOOK_START_TXT.format(playbook=self.playbook, hostname=self.hostname, + username=self.username) + data = { + 'time': to_millis(self.start_time), + 'text': text, + 'tags': ['ansible', 'ansible_event_start', self.playbook, self.hostname] + } + self._send_annotation(data) + + def v2_playbook_on_stats(self, stats): + end_time = datetime.now() + duration = end_time - self.start_time + summarize_stat = {} + for host in stats.processed.keys(): + summarize_stat[host] = stats.summarize(host) + + status = "FAILED" + if self.errors == 0: + status = "OK" + + text = PLAYBOOK_STATS_TXT.format(playbook=self.playbook, hostname=self.hostname, + duration=duration.total_seconds(), + status=status, username=self.username, + summary=json.dumps(summarize_stat)) + + data = { + 'time': to_millis(self.start_time), + 'timeEnd': to_millis(end_time), + 'isRegion': True, + 'text': text, + 'tags': ['ansible', 'ansible_report', self.playbook, self.hostname] + } + self._send_annotations(data) + + def v2_runner_on_failed(self, result, ignore_errors=False, **kwargs): + text = PLAYBOOK_ERROR_TXT.format(playbook=self.playbook, hostname=self.hostname, + username=self.username, task=result._task, + host=result._host.name, result=self._dump_results(result._result)) + if ignore_errors: + return + data = { + 'time': to_millis(datetime.now()), + 'text': text, + 'tags': ['ansible', 'ansible_event_failure', self.playbook, self.hostname] + } + self.errors += 1 + self._send_annotations(data) + + def _send_annotations(self, data): + if self.dashboard_id: + data["dashboardId"] = int(self.dashboard_id) + if self.panel_ids: + for panel_id in self.panel_ids: + data["panelId"] = int(panel_id) + self._send_annotation(data) + else: + self._send_annotation(data) + + def _send_annotation(self, annotation): + try: + open_url(self.grafana_url, data=json.dumps(annotation), headers=self.headers, + method="POST", + validate_certs=self.validate_grafana_certs, + url_username=self.grafana_user, url_password=self.grafana_password, + http_agent=self.http_agent, force_basic_auth=self.force_basic_auth) + except Exception as e: + self._display.error(u'Could not submit message to Grafana: %s' % to_text(e)) diff --git a/ansible_collections/community/grafana/plugins/doc_fragments/__init__.py b/ansible_collections/community/grafana/plugins/doc_fragments/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/grafana/plugins/doc_fragments/__init__.py diff --git a/ansible_collections/community/grafana/plugins/doc_fragments/api_key.py b/ansible_collections/community/grafana/plugins/doc_fragments/api_key.py new file mode 100644 index 000000000..ffea714e5 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/doc_fragments/api_key.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Rémi REY (@rrey) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + + +class ModuleDocFragment(object): + + DOCUMENTATION = r'''options: + grafana_api_key: + description: + - The Grafana API key. + - If set, C(url_username) and C(url_password) will be ignored. + type: str + ''' diff --git a/ansible_collections/community/grafana/plugins/doc_fragments/basic_auth.py b/ansible_collections/community/grafana/plugins/doc_fragments/basic_auth.py new file mode 100644 index 000000000..8c41acdbe --- /dev/null +++ b/ansible_collections/community/grafana/plugins/doc_fragments/basic_auth.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Rémi REY (@rrey) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) + +__metaclass__ = type + + +class ModuleDocFragment(object): + + DOCUMENTATION = r'''options: + url: + description: + - The Grafana URL. + required: true + type: str + aliases: [ grafana_url ] + url_username: + description: + - The Grafana user for API authentication. + default: admin + type: str + aliases: [ grafana_user ] + url_password: + description: + - The Grafana password for API authentication. + default: admin + type: str + aliases: [ grafana_password ] + use_proxy: + description: + - If C(no), it will not use a proxy, even if one is defined in an environment variable on the target hosts. + type: bool + default: yes + client_cert: + description: + - PEM formatted certificate chain file to be used for SSL client authentication. + - This file can also include the key as well, and if the key is included, I(client_key) is not required + type: path + client_key: + description: + - PEM formatted file that contains your private key to be used for SSL client authentication. + - If I(client_cert) contains both the certificate and key, this option is not required. + type: path + validate_certs: + description: + - If C(no), SSL certificates will not be validated. + - This should only set to C(no) used on personally controlled sites using self-signed certificates. + type: bool + default: yes + ''' diff --git a/ansible_collections/community/grafana/plugins/lookup/__init__.py b/ansible_collections/community/grafana/plugins/lookup/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/grafana/plugins/lookup/__init__.py diff --git a/ansible_collections/community/grafana/plugins/lookup/grafana_dashboard.py b/ansible_collections/community/grafana/plugins/lookup/grafana_dashboard.py new file mode 100644 index 000000000..ff288a1f3 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/lookup/grafana_dashboard.py @@ -0,0 +1,174 @@ +# (c) 2018 Ansible Project +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +DOCUMENTATION = ''' +name: grafana_dashboard +author: Thierry Salle (@seuf) +short_description: list or search grafana dashboards +description: + - This lookup returns a list of grafana dashboards with possibility to filter them by query. +options: + grafana_url: + description: url of grafana. + env: + - name: GRAFANA_URL + default: http://127.0.0.1:3000 + grafana_api_key: + description: + - Grafana API key. + - When C(grafana_api_key) is set, the options C(grafana_user), C(grafana_password) and C(grafana_org_id) are ignored. + env: + - name: GRAFANA_API_KEY + grafana_user: + description: grafana authentication user. + env: + - name: GRAFANA_USER + default: admin + grafana_password: + description: grafana authentication password. + env: + - name: GRAFANA_PASSWORD + default: admin + grafana_org_id: + description: grafana organisation id. + env: + - name: GRAFANA_ORG_ID + default: 1 + search: + description: optional filter for dashboard search. + env: + - name: GRAFANA_DASHBOARD_SEARCH +''' + +EXAMPLES = """ +- name: get project foo grafana dashboards + set_fact: + grafana_dashboards: "{{ lookup('grafana_dashboard', 'grafana_url=http://grafana.company.com grafana_user=admin grafana_password=admin search=foo') }}" + +- name: get all grafana dashboards + set_fact: + grafana_dashboards: "{{ lookup('grafana_dashboard', 'grafana_url=http://grafana.company.com grafana_api_key=' ~ grafana_api_key) }}" +""" + +import json +import os +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase +from ansible.module_utils.urls import basic_auth_header, open_url +from ansible.module_utils._text import to_native +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.utils.display import Display + +display = Display() + + +ANSIBLE_GRAFANA_URL = 'http://127.0.0.1:3000' +ANSIBLE_GRAFANA_API_KEY = None +ANSIBLE_GRAFANA_USER = 'admin' +ANSIBLE_GRAFANA_PASSWORD = 'admin' +ANSIBLE_GRAFANA_ORG_ID = 1 +ANSIBLE_GRAFANA_DASHBOARD_SEARCH = None + +if os.getenv('GRAFANA_URL') is not None: + ANSIBLE_GRAFANA_URL = os.environ['GRAFANA_URL'] + +if os.getenv('GRAFANA_API_KEY') is not None: + ANSIBLE_GRAFANA_API_KEY = os.environ['GRAFANA_API_KEY'] + +if os.getenv('GRAFANA_USER') is not None: + ANSIBLE_GRAFANA_USER = os.environ['GRAFANA_USER'] + +if os.getenv('GRAFANA_PASSWORD') is not None: + ANSIBLE_GRAFANA_PASSWORD = os.environ['GRAFANA_PASSWORD'] + +if os.getenv('GRAFANA_ORG_ID') is not None: + ANSIBLE_GRAFANA_ORG_ID = os.environ['GRAFANA_ORG_ID'] + +if os.getenv('GRAFANA_DASHBOARD_SEARCH') is not None: + ANSIBLE_GRAFANA_DASHBOARD_SEARCH = os.environ['GRAFANA_DASHBOARD_SEARCH'] + + +class GrafanaAPIException(Exception): + pass + + +class GrafanaAPI: + def __init__(self, **kwargs): + self.grafana_url = kwargs.get('grafana_url', ANSIBLE_GRAFANA_URL) + self.grafana_api_key = kwargs.get('grafana_api_key', ANSIBLE_GRAFANA_API_KEY) + self.grafana_user = kwargs.get('grafana_user', ANSIBLE_GRAFANA_USER) + self.grafana_password = kwargs.get('grafana_password', ANSIBLE_GRAFANA_PASSWORD) + self.grafana_org_id = kwargs.get('grafana_org_id', ANSIBLE_GRAFANA_ORG_ID) + self.search = kwargs.get('search', ANSIBLE_GRAFANA_DASHBOARD_SEARCH) + + def grafana_switch_organisation(self, headers): + try: + r = open_url('%s/api/user/using/%s' % (self.grafana_url, self.grafana_org_id), headers=headers, method='POST') + except HTTPError as e: + raise GrafanaAPIException('Unable to switch to organization %s : %s' % (self.grafana_org_id, to_native(e))) + if r.getcode() != 200: + raise GrafanaAPIException('Unable to switch to organization %s : %s' % (self.grafana_org_id, str(r.getcode()))) + + def grafana_headers(self): + headers = {'content-type': 'application/json; charset=utf8'} + if self.grafana_api_key: + api_key = self.grafana_api_key + if len(api_key) % 4 == 2: + display.deprecated( + "Passing a mangled version of the API key to the grafana_dashboard lookup is no longer necessary and should not be done.", + "2.0.0", + collection_name='community.grafana', + ) + api_key += '==' + headers['Authorization'] = "Bearer %s" % api_key + else: + headers['Authorization'] = basic_auth_header(self.grafana_user, self.grafana_password) + self.grafana_switch_organisation(headers) + + return headers + + def grafana_list_dashboards(self): + # define http headers + headers = self.grafana_headers() + + dashboard_list = [] + try: + if self.search: + r = open_url('%s/api/search?query=%s' % (self.grafana_url, self.search), headers=headers, method='GET') + else: + r = open_url('%s/api/search/' % self.grafana_url, headers=headers, method='GET') + except HTTPError as e: + raise GrafanaAPIException('Unable to search dashboards : %s' % to_native(e)) + if r.getcode() == 200: + try: + dashboard_list = json.loads(r.read()) + except Exception as e: + raise GrafanaAPIException('Unable to parse json list %s' % to_native(e)) + else: + raise GrafanaAPIException('Unable to list grafana dashboards : %s' % str(r.getcode())) + + return dashboard_list + + +class LookupModule(LookupBase): + + def run(self, terms, variables=None, **kwargs): + + grafana_args = terms[0].split(' ') + grafana_dict = {} + ret = [] + + for param in grafana_args: + try: + key, value = param.split('=', 1) + except ValueError: + raise AnsibleError("grafana_dashboard lookup plugin needs key=value pairs, but received %s" % terms) + grafana_dict[key] = value + + grafana = GrafanaAPI(**grafana_dict) + + ret = grafana.grafana_list_dashboards() + + return ret diff --git a/ansible_collections/community/grafana/plugins/module_utils/base.py b/ansible_collections/community/grafana/plugins/module_utils/base.py new file mode 100644 index 000000000..3a0174bbd --- /dev/null +++ b/ansible_collections/community/grafana/plugins/module_utils/base.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. +# +# Copyright: (c) 2019, Rémi REY (@rrey) + +from __future__ import (absolute_import, division, print_function) +from ansible.module_utils.urls import url_argument_spec + +__metaclass__ = type + + +def clean_url(url): + return url.rstrip("/") + + +def grafana_argument_spec(): + argument_spec = url_argument_spec() + + del argument_spec['force'] + del argument_spec['force_basic_auth'] + del argument_spec['http_agent'] + # Avoid sanity error with devel + if "use_gssapi" in argument_spec: + del argument_spec['use_gssapi'] + + argument_spec.update( + state=dict(choices=['present', 'absent'], default='present'), + url=dict(aliases=['grafana_url'], type='str', required=True), + grafana_api_key=dict(type='str', no_log=True), + url_username=dict(aliases=['grafana_user'], default='admin'), + url_password=dict(aliases=['grafana_password'], default='admin', no_log=True), + ) + return argument_spec + + +def grafana_required_together(): + return [['url_username', 'url_password']] + + +def grafana_mutually_exclusive(): + return [['url_username', 'grafana_api_key']] diff --git a/ansible_collections/community/grafana/plugins/modules/__init__.py b/ansible_collections/community/grafana/plugins/modules/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/__init__.py diff --git a/ansible_collections/community/grafana/plugins/modules/grafana_dashboard.py b/ansible_collections/community/grafana/plugins/modules/grafana_dashboard.py new file mode 100644 index 000000000..99801d494 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/grafana_dashboard.py @@ -0,0 +1,559 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Thierry Sallé (@seuf) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +DOCUMENTATION = ''' +--- +module: grafana_dashboard +author: + - Thierry Sallé (@seuf) +version_added: "1.0.0" +short_description: Manage Grafana dashboards +description: + - Create, update, delete, export Grafana dashboards via API. +options: + org_id: + description: + - The Grafana Organisation ID where the dashboard will be imported / exported. + - Not used when I(grafana_api_key) is set, because the grafana_api_key only belongs to one organisation.. + default: 1 + type: int + folder: + description: + - The Grafana folder where this dashboard will be imported to. + default: General + version_added: "1.0.0" + type: str + state: + description: + - State of the dashboard. + choices: [ absent, export, present ] + default: present + type: str + slug: + description: + - Deprecated since Grafana 5. Use grafana dashboard uid instead. + - slug of the dashboard. It's the friendly url name of the dashboard. + - When C(state) is C(present), this parameter can override the slug in the meta section of the json file. + - If you want to import a json dashboard exported directly from the interface (not from the api), + you have to specify the slug parameter because there is no meta section in the exported json. + type: str + uid: + version_added: "1.0.0" + description: + - uid of the dashboard to export when C(state) is C(export) or C(absent). + type: str + path: + description: + - The path to the json file containing the Grafana dashboard to import or export. + - A http URL is also accepted (since 2.10). + - Required if C(state) is C(export) or C(present). + aliases: [ dashboard_url ] + type: str + overwrite: + description: + - Override existing dashboard when state is present. + type: bool + default: 'no' + dashboard_id: + description: + - Public Grafana.com dashboard id to import + version_added: "1.0.0" + type: str + dashboard_revision: + description: + - Revision of the public grafana dashboard to import + default: '1' + version_added: "1.0.0" + type: str + commit_message: + description: + - Set a commit message for the version history. + - Only used when C(state) is C(present). + - C(message) alias is deprecated in Ansible 2.10, since it is used internally by Ansible Core Engine. + aliases: [ 'message' ] + type: str +extends_documentation_fragment: +- community.grafana.basic_auth +- community.grafana.api_key +''' + +EXAMPLES = ''' +- hosts: localhost + connection: local + tasks: + - name: Import Grafana dashboard foo + community.grafana.grafana_dashboard: + grafana_url: http://grafana.company.com + grafana_api_key: "{{ grafana_api_key }}" + state: present + commit_message: Updated by ansible + overwrite: yes + path: /path/to/dashboards/foo.json + + - name: Import Grafana dashboard Zabbix + community.grafana.grafana_dashboard: + grafana_url: http://grafana.company.com + grafana_api_key: "{{ grafana_api_key }}" + folder: zabbix + dashboard_id: 6098 + dashbord_revision: 1 + + - name: Import Grafana dashboard zabbix + community.grafana.grafana_dashboard: + grafana_url: http://grafana.company.com + grafana_api_key: "{{ grafana_api_key }}" + folder: public + dashboard_url: https://grafana.com/api/dashboards/6098/revisions/1/download + + - name: Export dashboard + community.grafana.grafana_dashboard: + grafana_url: http://grafana.company.com + grafana_user: "admin" + grafana_password: "{{ grafana_password }}" + org_id: 1 + state: export + uid: "000000653" + path: "/path/to/dashboards/000000653.json" +''' + +RETURN = ''' +--- +uid: + description: uid or slug of the created / deleted / exported dashboard. + returned: success + type: str + sample: 000000063 +''' + +import json +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.module_utils._text import to_native +from ansible.module_utils._text import to_text +from ansible_collections.community.grafana.plugins.module_utils.base import grafana_argument_spec, clean_url + +__metaclass__ = type + + +class GrafanaAPIException(Exception): + pass + + +class GrafanaMalformedJson(Exception): + pass + + +class GrafanaExportException(Exception): + pass + + +class GrafanaDeleteException(Exception): + pass + + +def grafana_switch_organisation(module, grafana_url, org_id, headers): + r, info = fetch_url(module, '%s/api/user/using/%s' % (grafana_url, org_id), headers=headers, method='POST') + if info['status'] != 200: + raise GrafanaAPIException('Unable to switch to organization %s : %s' % (org_id, info)) + + +def grafana_headers(module, data): + headers = {'content-type': 'application/json; charset=utf8'} + if 'grafana_api_key' in data and data['grafana_api_key']: + headers['Authorization'] = "Bearer %s" % data['grafana_api_key'] + else: + module.params['force_basic_auth'] = True + grafana_switch_organisation(module, data['url'], data['org_id'], headers) + + return headers + + +def get_grafana_version(module, grafana_url, headers): + grafana_version = None + r, info = fetch_url(module, '%s/api/frontend/settings' % grafana_url, headers=headers, method='GET') + if info['status'] == 200: + try: + settings = json.loads(to_text(r.read())) + grafana_version = settings['buildInfo']['version'].split('.')[0] + except UnicodeError as e: + raise GrafanaAPIException('Unable to decode version string to Unicode') + except Exception as e: + raise GrafanaAPIException(e) + else: + raise GrafanaAPIException('Unable to get grafana version : %s' % info) + + return int(grafana_version) + + +def grafana_folder_exists(module, grafana_url, folder_name, headers): + # the 'General' folder is a special case, it's ID is always '0' + if folder_name == 'General': + return True, 0 + + try: + r, info = fetch_url(module, '%s/api/folders' % grafana_url, headers=headers, method='GET') + + if info['status'] != 200: + raise GrafanaAPIException("Unable to query Grafana API for folders (name: %s): %d" % (folder_name, info['status'])) + + folders = json.loads(r.read()) + + for folder in folders: + if folder['title'] == folder_name: + return True, folder['id'] + except Exception as e: + raise GrafanaAPIException(e) + + return False, 0 + + +def grafana_dashboard_exists(module, grafana_url, uid, headers): + dashboard_exists = False + dashboard = {} + + grafana_version = get_grafana_version(module, grafana_url, headers) + if grafana_version >= 5: + uri = '%s/api/dashboards/uid/%s' % (grafana_url, uid) + else: + uri = '%s/api/dashboards/db/%s' % (grafana_url, uid) + + r, info = fetch_url(module, uri, headers=headers, method='GET') + + if info['status'] == 200: + dashboard_exists = True + try: + dashboard = json.loads(r.read()) + except Exception as e: + raise GrafanaAPIException(e) + elif info['status'] == 404: + dashboard_exists = False + else: + raise GrafanaAPIException('Unable to get dashboard %s : %s' % (uid, info)) + + return dashboard_exists, dashboard + + +def grafana_dashboard_search(module, grafana_url, folder_id, title, headers): + + # search by title + uri = '%s/api/search?%s' % (grafana_url, urlencode({ + 'folderIds': folder_id, + 'query': title, + 'type': 'dash-db' + })) + r, info = fetch_url(module, uri, headers=headers, method='GET') + + if info['status'] == 200: + try: + dashboards = json.loads(r.read()) + for d in dashboards: + if d['title'] == title: + return grafana_dashboard_exists(module, grafana_url, d['uid'], headers) + except Exception as e: + raise GrafanaAPIException(e) + else: + raise GrafanaAPIException('Unable to search dashboard %s : %s' % (title, info)) + + return False, None + + +# for comparison, we sometimes need to ignore a few keys +def grafana_dashboard_changed(payload, dashboard): + # you don't need to set the version, but '0' is incremented to '1' by Grafana's API + if 'version' in payload['dashboard']: + del payload['dashboard']['version'] + if 'version' in dashboard['dashboard']: + del dashboard['dashboard']['version'] + + # remove meta key if exists for compare + if 'meta' in dashboard: + del dashboard['meta'] + if 'meta' in payload: + del payload['meta'] + + # if folderId is not provided in dashboard, set default folderId + if 'folderId' not in dashboard: + dashboard['folderId'] = 0 + + # Ignore dashboard ids since real identifier is uuid + if 'id' in dashboard['dashboard']: + del dashboard['dashboard']['id'] + if 'id' in payload['dashboard']: + del payload['dashboard']['id'] + + if payload == dashboard: + return False + return True + + +def grafana_create_dashboard(module, data): + + # define data payload for grafana API + payload = {} + if data.get('dashboard_id'): + data['path'] = "https://grafana.com/api/dashboards/%s/revisions/%s/download" % (data['dashboard_id'], data['dashboard_revision']) + if data['path'].startswith('http'): + r, info = fetch_url(module, data['path']) + if info['status'] != 200: + raise GrafanaAPIException('Unable to download grafana dashboard from url %s : %s' % (data['path'], info)) + payload = json.loads(r.read()) + else: + try: + with open(data['path'], 'r', encoding="utf-8") as json_file: + payload = json.load(json_file) + except Exception as e: + raise GrafanaAPIException("Can't load json file %s" % to_native(e)) + + # Check that the dashboard JSON is nested under the 'dashboard' key + if 'dashboard' not in payload: + payload = {'dashboard': payload} + + # define http header + headers = grafana_headers(module, data) + + grafana_version = get_grafana_version(module, data['url'], headers) + if grafana_version < 5: + if data.get('slug'): + uid = data['slug'] + elif 'meta' in payload and 'slug' in payload['meta']: + uid = payload['meta']['slug'] + else: + raise GrafanaMalformedJson('No slug found in json. Needed with grafana < 5') + else: + if data.get('uid'): + uid = data['uid'] + elif 'uid' in payload['dashboard']: + uid = payload['dashboard']['uid'] + else: + uid = None + + result = {} + + # test if the folder exists + folder_exists = False + if grafana_version >= 5: + folder_exists, folder_id = grafana_folder_exists(module, data['url'], data['folder'], headers) + if folder_exists is False: + raise GrafanaAPIException("Dashboard folder '%s' does not exist." % data['folder']) + + payload['folderId'] = folder_id + + # test if dashboard already exists + if uid: + dashboard_exists, dashboard = grafana_dashboard_exists( + module, data['url'], uid, headers=headers) + else: + dashboard_exists, dashboard = grafana_dashboard_search( + module, data['url'], folder_id, payload['dashboard']['title'], headers=headers) + + if dashboard_exists is True: + if grafana_dashboard_changed(payload, dashboard): + # update + if 'overwrite' in data and data['overwrite']: + payload['overwrite'] = True + if 'commit_message' in data and data['commit_message']: + payload['message'] = data['commit_message'] + + r, info = fetch_url(module, '%s/api/dashboards/db' % data['url'], + data=json.dumps(payload), headers=headers, method='POST') + if info['status'] == 200: + if grafana_version >= 5: + try: + dashboard = json.loads(r.read()) + uid = dashboard['uid'] + except Exception as e: + raise GrafanaAPIException(e) + result['uid'] = uid + result['msg'] = "Dashboard %s updated" % payload['dashboard']['title'] + result['changed'] = True + else: + body = json.loads(info['body']) + raise GrafanaAPIException('Unable to update the dashboard %s : %s (HTTP: %d)' % + (uid, body['message'], info['status'])) + else: + # unchanged + result['uid'] = uid + result['msg'] = "Dashboard %s unchanged." % payload['dashboard']['title'] + result['changed'] = False + else: + # Ensure there is no id in payload + if 'id' in payload['dashboard']: + del payload['dashboard']['id'] + + r, info = fetch_url(module, '%s/api/dashboards/db' % data['url'], + data=json.dumps(payload), headers=headers, method='POST') + if info['status'] == 200: + result['msg'] = "Dashboard %s created" % payload['dashboard']['title'] + result['changed'] = True + if grafana_version >= 5: + try: + dashboard = json.loads(r.read()) + uid = dashboard['uid'] + except Exception as e: + raise GrafanaAPIException(e) + result['uid'] = uid + else: + raise GrafanaAPIException('Unable to create the new dashboard %s : %s - %s. (headers : %s)' % + (payload['dashboard']['title'], info['status'], info, headers)) + + return result + + +def grafana_delete_dashboard(module, data): + + # define http headers + headers = grafana_headers(module, data) + + grafana_version = get_grafana_version(module, data['url'], headers) + if grafana_version < 5: + if data.get('slug'): + uid = data['slug'] + else: + raise GrafanaMalformedJson('No slug parameter. Needed with grafana < 5') + else: + if data.get('uid'): + uid = data['uid'] + else: + raise GrafanaDeleteException('No uid specified %s') + + # test if dashboard already exists + dashboard_exists, dashboard = grafana_dashboard_exists(module, data['url'], uid, headers=headers) + + result = {} + if dashboard_exists is True: + # delete + if grafana_version < 5: + r, info = fetch_url(module, '%s/api/dashboards/db/%s' % (data['url'], uid), headers=headers, method='DELETE') + else: + r, info = fetch_url(module, '%s/api/dashboards/uid/%s' % (data['url'], uid), headers=headers, method='DELETE') + if info['status'] == 200: + result['msg'] = "Dashboard %s deleted" % uid + result['changed'] = True + result['uid'] = uid + else: + raise GrafanaAPIException('Unable to update the dashboard %s : %s' % (uid, info)) + else: + # dashboard does not exist, do nothing + result = {'msg': "Dashboard %s does not exist." % uid, + 'changed': False, + 'uid': uid} + + return result + + +def grafana_export_dashboard(module, data): + + # define http headers + headers = grafana_headers(module, data) + + grafana_version = get_grafana_version(module, data['url'], headers) + if grafana_version < 5: + if data.get('slug'): + uid = data['slug'] + else: + raise GrafanaMalformedJson('No slug parameter. Needed with grafana < 5') + else: + if data.get('uid'): + uid = data['uid'] + else: + raise GrafanaExportException('No uid specified') + + # test if dashboard already exists + dashboard_exists, dashboard = grafana_dashboard_exists(module, data['url'], uid, headers=headers) + + if dashboard_exists is True: + try: + with open(data['path'], 'w', encoding="utf-8") as f: + f.write(json.dumps(dashboard, indent=2)) + except Exception as e: + raise GrafanaExportException("Can't write json file : %s" % to_native(e)) + result = {'msg': "Dashboard %s exported to %s" % (uid, data['path']), + 'uid': uid, + 'changed': True} + else: + result = {'msg': "Dashboard %s does not exist." % uid, + 'uid': uid, + 'changed': False} + + return result + + +def main(): + # use the predefined argument spec for url + argument_spec = grafana_argument_spec() + argument_spec.update( + state=dict(choices=['present', 'absent', 'export'], default='present'), + org_id=dict(default=1, type='int'), + folder=dict(type='str', default='General'), + uid=dict(type='str'), + slug=dict(type='str'), + path=dict(aliases=['dashboard_url'], type='str'), + dashboard_id=dict(type='str'), + dashboard_revision=dict(type='str', default='1'), + overwrite=dict(type='bool', default=False), + commit_message=dict(type='str', aliases=['message'], + deprecated_aliases=[dict(name='message', + version='2.0.0', collection_name="community.grafana")]), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_if=[ + ['state', 'export', ['path']], + ], + required_together=[['url_username', 'url_password', 'org_id']], + mutually_exclusive=[['url_username', 'grafana_api_key'], ['uid', 'slug'], ['path', 'dashboard_id']], + ) + + module.params["url"] = clean_url(module.params["url"]) + + if 'message' in module.params: + module.fail_json(msg="'message' is reserved keyword, please change this parameter to 'commit_message'") + + try: + if module.params['state'] == 'present': + result = grafana_create_dashboard(module, module.params) + elif module.params['state'] == 'absent': + result = grafana_delete_dashboard(module, module.params) + else: + result = grafana_export_dashboard(module, module.params) + except GrafanaAPIException as e: + module.fail_json( + failed=True, + msg="error : %s" % to_native(e) + ) + return + except GrafanaMalformedJson as e: + module.fail_json( + failed=True, + msg="error : %s" % to_native(e) + ) + return + except GrafanaDeleteException as e: + module.fail_json( + failed=True, + msg="error : Can't delete dashboard : %s" % to_native(e) + ) + return + except GrafanaExportException as e: + module.fail_json( + failed=True, + msg="error : Can't export dashboard : %s" % to_native(e) + ) + return + + module.exit_json( + failed=False, + **result + ) + return + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/grafana/plugins/modules/grafana_datasource.py b/ansible_collections/community/grafana/plugins/modules/grafana_datasource.py new file mode 100644 index 000000000..6346038f4 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/grafana_datasource.py @@ -0,0 +1,845 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Thierry Sallé (@seuf) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +module: grafana_datasource +author: +- Thierry Sallé (@seuf) +- Martin Wang (@martinwangjian) +- Rémi REY (@rrey) +short_description: Manage Grafana datasources +description: +- Create/update/delete Grafana datasources via API. +options: + name: + description: + - The name of the datasource. + required: true + type: str + uid: + description: + - The uid of the datasource. + required: false + type: str + ds_type: + description: + - The type of the datasource. + - Required when C(state=present). + choices: + - graphite + - prometheus + - elasticsearch + - influxdb + - opentsdb + - mysql + - postgres + - cloudwatch + - alexanderzobnin-zabbix-datasource + - grafana-azure-monitor-datasource + - sni-thruk-datasource + - camptocamp-prometheus-alertmanager-datasource + - loki + - redis-datasource + type: str + ds_url: + description: + - The URL of the datasource. + - Required when C(state=present). + type: str + access: + description: + - The access mode for this datasource. + choices: + - direct + - proxy + default: proxy + type: str + database: + description: + - Name of the database for the datasource. + - This options is required when the C(ds_type) is C(influxdb), C(elasticsearch) + (index name), C(mysql) or C(postgres). + required: false + type: str + user: + description: + - The datasource login user for influxdb datasources. + type: str + password: + description: + - The datasource password. + - Stored as secure data, see C(enforce_secure_data) and notes! + type: str + basic_auth_user: + description: + - The datasource basic auth user. + - Setting this option with basic_auth_password will enable basic auth. + type: str + basic_auth_password: + description: + - The datasource basic auth password, when C(basic auth) is C(yes). + - Stored as secure data, see C(enforce_secure_data) and notes! + type: str + with_credentials: + description: + - Whether credentials such as cookies or auth headers should be sent with cross-site + requests. + type: bool + default: 'no' + tls_client_cert: + description: + - The client TLS certificate. + - If C(tls_client_cert) and C(tls_client_key) are set, this will enable TLS authentication. + - Starts with ----- BEGIN CERTIFICATE ----- + - Stored as secure data, see C(enforce_secure_data) and notes! + type: str + tls_client_key: + description: + - The client TLS private key + - Starts with ----- BEGIN RSA PRIVATE KEY ----- + - Stored as secure data, see C(enforce_secure_data) and notes! + type: str + tls_ca_cert: + description: + - The TLS CA certificate for self signed certificates. + - Only used when C(tls_client_cert) and C(tls_client_key) are set. + - Stored as secure data, see C(enforce_secure_data) and notes! + type: str + tls_skip_verify: + description: + - Skip the TLS datasource certificate verification. + type: bool + default: false + is_default: + description: + - Make this datasource the default one. + type: bool + default: 'no' + org_id: + description: + - Grafana Organisation ID in which the datasource should be created. + - Not used when C(grafana_api_key) is set, because the C(grafana_api_key) only + belong to one organisation. + default: 1 + type: int + state: + description: + - Status of the datasource + choices: + - absent + - present + default: present + type: str + es_version: + description: + - Elasticsearch version (for C(ds_type = elasticsearch) only) + - Version 56 is for elasticsearch 5.6+ where you can specify the C(max_concurrent_shard_requests) + option. + choices: + - "2" + - "5" + - "56" + - "60" + - "70" + - "7.7+" + - "7.10+" + - "8.0+" + default: "7.10+" + type: str + max_concurrent_shard_requests: + description: + - Starting with elasticsearch 5.6, you can specify the max concurrent shard per + requests. + default: 256 + type: int + time_field: + description: + - Name of the time field in elasticsearch ds. + - For example C(@timestamp). + type: str + default: '@timestamp' + time_interval: + description: + - Minimum group by interval for C(influxdb) or C(elasticsearch) datasources. + - for example C(>10s). + type: str + interval: + description: + - For elasticsearch C(ds_type), this is the index pattern used. + choices: + - '' + - Hourly + - Daily + - Weekly + - Monthly + - Yearly + type: str + tsdb_version: + description: + - The opentsdb version. + - Use C(1) for <=2.1, C(2) for ==2.2, C(3) for ==2.3. + choices: + - 1 + - 2 + - 3 + default: 1 + type: int + tsdb_resolution: + description: + - The opentsdb time resolution. + choices: + - millisecond + - second + default: second + type: str + sslmode: + description: + - SSL mode for C(postgres) datasource type. + choices: + - disable + - require + - verify-ca + - verify-full + type: str + default: disable + trends: + required: false + description: + - Use trends or not for zabbix datasource type. + type: bool + default: False + aws_auth_type: + description: + - Type for AWS authentication for CloudWatch datasource type (authType of grafana + api) + default: keys + choices: + - keys + - credentials + - arn + - default + type: str + aws_default_region: + description: + - AWS default region for CloudWatch datasource type + default: us-east-1 + type: str + choices: + - ap-northeast-1 + - ap-northeast-2 + - ap-southeast-1 + - ap-southeast-2 + - ap-south-1 + - ca-central-1 + - cn-north-1 + - cn-northwest-1 + - eu-central-1 + - eu-west-1 + - eu-west-2 + - eu-west-3 + - sa-east-1 + - us-east-1 + - us-east-2 + - us-gov-west-1 + - us-west-1 + - us-west-2 + aws_credentials_profile: + description: + - Profile for AWS credentials for CloudWatch datasource type when C(aws_auth_type) + is C(credentials) + default: '' + required: false + type: str + aws_access_key: + description: + - AWS access key for CloudWatch datasource type when C(aws_auth_type) is C(keys) + - Stored as secure data, see C(enforce_secure_data) and notes! + default: '' + required: false + type: str + aws_secret_key: + description: + - AWS secret key for CloudWatch datasource type when C(aws_auth_type) is C(keys) + - Stored as secure data, see C(enforce_secure_data) and notes! + default: '' + required: false + type: str + aws_assume_role_arn: + description: + - AWS IAM role arn to assume for CloudWatch datasource type when C(aws_auth_type) + is C(arn) + default: '' + required: false + type: str + aws_custom_metrics_namespaces: + description: + - Namespaces of Custom Metrics for CloudWatch datasource type + default: '' + required: false + type: str + azure_cloud: + description: + - The national cloud for your Azure account + default: 'azuremonitor' + required: false + type: str + choices: + - azuremonitor + - chinaazuremonitor + - govazuremonitor + - germanyazuremonitor + azure_tenant: + description: + - The directory/tenant ID for the Azure AD app registration to use for authentication + required: false + type: str + azure_client: + description: + - The application/client ID for the Azure AD app registration to use for authentication. + required: false + type: str + azure_secret: + description: + - The application client secret for the Azure AD app registration to use for auth + required: false + type: str + zabbix_user: + description: + - User for Zabbix API + required: false + type: str + zabbix_password: + description: + - Password for Zabbix API + required: false + type: str + additional_json_data: + description: + - Defined data is used for datasource jsonData + - Data may be overridden by specifically defined parameters (like zabbix_user) + required: false + type: dict + default: {} + additional_secure_json_data: + description: + - Defined data is used for datasource secureJsonData + - Data may be overridden by specifically defined parameters (like tls_client_cert) + - Stored as secure data, see C(enforce_secure_data) and notes! + required: false + type: dict + default: {} + enforce_secure_data: + description: + - Secure data is not updated per default (see notes!) + - To update secure data you have to enable this option! + - Enabling this, the task will always report changed=True + required: false + type: bool + default: false +extends_documentation_fragment: +- community.grafana.basic_auth +- community.grafana.api_key +notes: +- Secure data will get encrypted by the Grafana API, thus it can not be compared on subsequent runs. To workaround this, secure + data will not be updated after initial creation! To force the secure data update you have to set I(enforce_secure_data=True). +- Hint, with the C(enforce_secure_data) always reporting changed=True, you might just do one Task updating the datasource without + any secure data and make a separate playbook/task also changing the secure data. This way it will not break any workflow. +''' + +EXAMPLES = ''' +--- +- name: Create elasticsearch datasource + community.grafana.grafana_datasource: + name: "datasource-elastic" + grafana_url: "https://grafana.company.com" + grafana_user: "admin" + grafana_password: "xxxxxx" + org_id: "1" + ds_type: "elasticsearch" + ds_url: "https://elastic.company.com:9200" + database: "[logstash_]YYYY.MM.DD" + basic_auth_user: "grafana" + basic_auth_password: "******" + time_field: "@timestamp" + time_interval: "1m" + interval: "Daily" + es_version: 56 + max_concurrent_shard_requests: 42 + tls_ca_cert: "/etc/ssl/certs/ca.pem" + +- name: Create influxdb datasource + community.grafana.grafana_datasource: + name: "datasource-influxdb" + grafana_url: "https://grafana.company.com" + grafana_user: "admin" + grafana_password: "xxxxxx" + org_id: "1" + ds_type: "influxdb" + ds_url: "https://influx.company.com:8086" + database: "telegraf" + time_interval: ">10s" + tls_ca_cert: "/etc/ssl/certs/ca.pem" + +- name: Create postgres datasource + community.grafana.grafana_datasource: + name: "datasource-postgres" + grafana_url: "https://grafana.company.com" + grafana_user: "admin" + grafana_password: "xxxxxx" + org_id: "1" + ds_type: "postgres" + ds_url: "postgres.company.com:5432" + database: "db" + user: "postgres" + sslmode: "verify-full" + additional_json_data: + postgresVersion: 12 + timescaledb: false + additional_secure_json_data: + password: "iampgroot" + +- name: Create cloudwatch datasource + community.grafana.grafana_datasource: + name: "datasource-cloudwatch" + grafana_url: "https://grafana.company.com" + grafana_user: "admin" + grafana_password: "xxxxxx" + org_id: "1" + ds_type: "cloudwatch" + ds_url: "http://monitoring.us-west-1.amazonaws.com" + aws_auth_type: "keys" + aws_default_region: "us-west-1" + aws_access_key: "speakFriendAndEnter" + aws_secret_key: "mel10n" + aws_custom_metrics_namespaces: "n1,n2" + +- name: grafana - add thruk datasource + community.grafana.grafana_datasource: + name: "datasource-thruk" + grafana_url: "https://grafana.company.com" + grafana_user: "admin" + grafana_password: "xxxxxx" + org_id: "1" + ds_type: "sni-thruk-datasource" + ds_url: "https://thruk.company.com/sitename/thruk" + basic_auth_user: "thruk-user" + basic_auth_password: "******" + +# handle secure data - workflow example +# this will create/update the datasource but dont update the secure data on updates +# so you can assert if all tasks are changed=False +- name: create prometheus datasource + community.grafana.grafana_datasource: + name: openshift_prometheus + ds_type: prometheus + ds_url: https://openshift-monitoring.company.com + access: proxy + tls_skip_verify: true + additional_json_data: + httpHeaderName1: "Authorization" + additional_secure_json_data: + httpHeaderValue1: "Bearer ihavenogroot" + +# in a separate task or even play you then can force to update +# and assert if each datasource is reporting changed=True +- name: update prometheus datasource + community.grafana.grafana_datasource: + name: openshift_prometheus + ds_type: prometheus + ds_url: https://openshift-monitoring.company.com + access: proxy + tls_skip_verify: true + additional_json_data: + httpHeaderName1: "Authorization" + additional_secure_json_data: + httpHeaderValue1: "Bearer ihavenogroot" + enforce_secure_data: true +''' + +RETURN = ''' +--- +datasource: + description: datasource created/updated by module + returned: changed + type: dict + sample: { "access": "proxy", + "basicAuth": false, + "database": "test_*", + "id": 1035, + "isDefault": false, + "jsonData": { + "esVersion": 5, + "timeField": "@timestamp", + "timeInterval": "10s", + }, + "secureJsonFields": { + "JustASecureTest": true, + }, + "name": "grafana_datasource_test", + "orgId": 1, + "type": "elasticsearch", + "url": "http://elastic.company.com:9200", + "user": "", + "password": "", + "withCredentials": false } +''' + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import quote +from ansible.module_utils.urls import fetch_url, url_argument_spec, basic_auth_header +from ansible_collections.community.grafana.plugins.module_utils import base + + +ES_VERSION_MAPPING = { + "7.7+": "7.7.0", + "7.10+": "7.10.0", + "8.0+": "8.0.0", +} + + +def compare_datasources(new, current, compareSecureData=True): + if new['uid'] is None: + del current['uid'] + del new['uid'] + del current['typeLogoUrl'] + del current['id'] + if 'version' in current: + del current['version'] + if 'readOnly' in current: + del current['readOnly'] + if current['basicAuth'] is False: + del current['basicAuthUser'] + if 'password' in current: + del current['password'] + if 'basicAuthPassword' in current: + del current['basicAuthPassword'] + + # check if secureJsonData should be compared + if not compareSecureData: + # if we should ignore it just drop alltogether + new.pop('secureJsonData', None) + new.pop('secureJsonFields', None) + current.pop('secureJsonData', None) + current.pop('secureJsonFields', None) + else: + # handle secureJsonData/secureJsonFields, some current facts: + # - secureJsonFields is reporting each field set as true + # - secureJsonFields once set cant be removed (DS has to be deleted) + if not new.get('secureJsonData'): + # secureJsonData is not provided so just remove both for comparision + new.pop('secureJsonData', None) + current.pop('secureJsonFields', None) + else: + # we have some secure data so just "rename" secureJsonFields for comparison as it will change anyhow everytime + current['secureJsonData'] = current.pop('secureJsonFields') + + return dict(before=current, after=new) + + +def get_datasource_payload(data): + payload = { + 'orgId': data['org_id'], + 'name': data['name'], + 'uid': data['uid'], + 'type': data['ds_type'], + 'access': data['access'], + 'url': data['ds_url'], + 'database': data['database'], + 'withCredentials': data['with_credentials'], + 'isDefault': data['is_default'], + 'user': data['user'], + 'jsonData': data['additional_json_data'], + 'secureJsonData': data['additional_secure_json_data'] + } + + json_data = payload['jsonData'] + secure_json_data = payload['secureJsonData'] + + # define password + if data.get('password'): + secure_json_data['password'] = data['password'] + + # define basic auth + if 'basic_auth_user' in data and data['basic_auth_user'] and 'basic_auth_password' in data and data['basic_auth_password']: + payload['basicAuth'] = True + payload['basicAuthUser'] = data['basic_auth_user'] + secure_json_data['basicAuthPassword'] = data['basic_auth_password'] + else: + payload['basicAuth'] = False + + # define tls auth + if data.get('tls_client_cert') and data.get('tls_client_key'): + json_data['tlsAuth'] = True + if data.get('tls_ca_cert'): + secure_json_data['tlsCACert'] = data['tls_ca_cert'] + secure_json_data['tlsClientCert'] = data['tls_client_cert'] + secure_json_data['tlsClientKey'] = data['tls_client_key'] + json_data['tlsAuthWithCACert'] = True + else: + secure_json_data['tlsClientCert'] = data['tls_client_cert'] + secure_json_data['tlsClientKey'] = data['tls_client_key'] + else: + json_data['tlsAuth'] = False + json_data['tlsAuthWithCACert'] = False + if data.get('tls_ca_cert'): + json_data['tlsAuthWithCACert'] = True + secure_json_data['tlsCACert'] = data['tls_ca_cert'] + + if data.get('tls_skip_verify'): + json_data['tlsSkipVerify'] = True + + # datasource type related parameters + if data['ds_type'] == 'elasticsearch': + + json_data['maxConcurrentShardRequests'] = data['max_concurrent_shard_requests'] + json_data['timeField'] = data['time_field'] + if data.get('interval'): + json_data['interval'] = data['interval'] + + # Handle changes in es_version format in Grafana < 8.x which used to + # be integers and is now semver format + try: + es_version = int(data['es_version']) + if es_version < 56: + json_data.pop('maxConcurrentShardRequests') + except ValueError: + # Retrieve the Semver format expected by API + es_version = ES_VERSION_MAPPING.get(data['es_version']) + json_data['esVersion'] = es_version + + if data['ds_type'] == 'elasticsearch' or data['ds_type'] == 'influxdb': + if data.get('time_interval'): + json_data['timeInterval'] = data['time_interval'] + + if data['ds_type'] == 'opentsdb': + json_data['tsdbVersion'] = data['tsdb_version'] + if data['tsdb_resolution'] == 'second': + json_data['tsdbResolution'] = 1 + else: + json_data['tsdbResolution'] = 2 + + if data['ds_type'] == 'postgres': + json_data['sslmode'] = data['sslmode'] + + if data['ds_type'] == 'alexanderzobnin-zabbix-datasource': + if data.get('trends'): + json_data['trends'] = True + json_data['username'] = data['zabbix_user'] + json_data['password'] = data['zabbix_password'] + + if data['ds_type'] == 'grafana-azure-monitor-datasource': + json_data['tenantId'] = data['azure_tenant'] + json_data['clientId'] = data['azure_client'] + json_data['cloudName'] = data['azure_cloud'] + json_data['clientsecret'] = 'clientsecret' + if data.get('azure_secret'): + secure_json_data['clientSecret'] = data['azure_secret'] + + if data['ds_type'] == 'cloudwatch': + if data.get('aws_credentials_profile'): + payload['database'] = data.get('aws_credentials_profile') + + json_data['authType'] = data['aws_auth_type'] + json_data['defaultRegion'] = data['aws_default_region'] + + if data.get('aws_custom_metrics_namespaces'): + json_data['customMetricsNamespaces'] = data.get('aws_custom_metrics_namespaces') + if data.get('aws_assume_role_arn'): + json_data['assumeRoleArn'] = data.get('aws_assume_role_arn') + if data.get('aws_access_key') and data.get('aws_secret_key'): + secure_json_data['accessKey'] = data.get('aws_access_key') + secure_json_data['secretKey'] = data.get('aws_secret_key') + + payload['jsonData'] = json_data + payload['secureJsonData'] = secure_json_data + return payload + + +class GrafanaInterface(object): + + def __init__(self, module): + self._module = module + self.grafana_url = base.clean_url(module.params.get("url")) + # {{{ Authentication header + self.headers = {"Content-Type": "application/json"} + if module.params.get('grafana_api_key', None): + self.headers["Authorization"] = "Bearer %s" % module.params['grafana_api_key'] + else: + self.headers["Authorization"] = basic_auth_header(module.params['url_username'], module.params['url_password']) + self.switch_organisation(module.params['org_id']) + # }}} + + def _send_request(self, url, data=None, headers=None, method="GET"): + if data is not None: + data = json.dumps(data, sort_keys=True) + if not headers: + headers = [] + + full_url = "{grafana_url}{path}".format(grafana_url=self.grafana_url, path=url) + resp, info = fetch_url(self._module, full_url, data=data, headers=headers, method=method) + status_code = info["status"] + if status_code == 404: + return None + elif status_code == 401: + self._module.fail_json(failed=True, msg="Unauthorized to perform action '%s' on '%s'" % (method, full_url)) + elif status_code == 403: + self._module.fail_json(failed=True, msg="Permission Denied") + elif status_code == 200: + return self._module.from_json(resp.read()) + self._module.fail_json(failed=True, msg="Grafana API answered with HTTP %d for url %s and data %s" % (status_code, url, data)) + + def switch_organisation(self, org_id): + url = "/api/user/using/%d" % org_id + response = self._send_request(url, headers=self.headers, method='POST') + + def datasource_by_name(self, name): + datasource_exists = False + ds = {} + url = "/api/datasources/name/%s" % quote(name, safe='') + return self._send_request(url, headers=self.headers, method='GET') + + def delete_datasource(self, name): + url = "/api/datasources/name/%s" % quote(name, safe='') + self._send_request(url, headers=self.headers, method='DELETE') + + def update_datasource(self, ds_id, data): + url = "/api/datasources/%d" % ds_id + self._send_request(url, data=data, headers=self.headers, method='PUT') + + def create_datasource(self, data): + url = "/api/datasources" + self._send_request(url, data=data, headers=self.headers, method='POST') + + +def setup_module_object(): + argument_spec = base.grafana_argument_spec() + + argument_spec.update( + name=dict(required=True, type='str'), + uid=dict(type='str'), + ds_type=dict(choices=['graphite', + 'prometheus', + 'elasticsearch', + 'influxdb', + 'opentsdb', + 'mysql', + 'postgres', + 'cloudwatch', + 'alexanderzobnin-zabbix-datasource', + 'grafana-azure-monitor-datasource', + 'camptocamp-prometheus-alertmanager-datasource', + 'sni-thruk-datasource', + 'redis-datasource', + 'loki']), + ds_url=dict(type='str'), + access=dict(default='proxy', choices=['proxy', 'direct']), + database=dict(type='str', default=""), + user=dict(default='', type='str'), + password=dict(default='', no_log=True, type='str'), + basic_auth_user=dict(type='str'), + basic_auth_password=dict(type='str', no_log=True), + with_credentials=dict(default=False, type='bool'), + tls_client_cert=dict(type='str', no_log=True), + tls_client_key=dict(type='str', no_log=True), + tls_ca_cert=dict(type='str', no_log=True), + tls_skip_verify=dict(type='bool', default=False), + is_default=dict(default=False, type='bool'), + org_id=dict(default=1, type='int'), + es_version=dict(type='str', default="7.10+", choices=["2", "5", "56", "60", + "70", "7.7+", "7.10+", + "8.0+"]), + max_concurrent_shard_requests=dict(type='int', default=256), + time_field=dict(default='@timestamp', type='str'), + time_interval=dict(type='str'), + interval=dict(type='str', choices=['', 'Hourly', 'Daily', 'Weekly', 'Monthly', 'Yearly'], default=''), + tsdb_version=dict(type='int', default=1, choices=[1, 2, 3]), + tsdb_resolution=dict(type='str', default='second', choices=['second', 'millisecond']), + sslmode=dict(default='disable', choices=['disable', 'require', 'verify-ca', 'verify-full']), + trends=dict(default=False, type='bool'), + aws_auth_type=dict(default='keys', choices=['keys', 'credentials', 'arn', 'default']), + aws_default_region=dict(default='us-east-1', choices=['ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', + 'ca-central-1', + 'cn-north-1', 'cn-northwest-1', + 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', + 'sa-east-1', + 'us-east-1', 'us-east-2', 'us-gov-west-1', 'us-west-1', 'us-west-2']), + aws_access_key=dict(default='', no_log=True, type='str'), + aws_secret_key=dict(default='', no_log=True, type='str'), + aws_credentials_profile=dict(default='', type='str'), + aws_assume_role_arn=dict(default='', type='str'), + aws_custom_metrics_namespaces=dict(type='str'), + azure_cloud=dict(type='str', default='azuremonitor', choices=['azuremonitor', 'chinaazuremonitor', 'govazuremonitor', 'germanyazuremonitor']), + azure_tenant=dict(type='str'), + azure_client=dict(type='str'), + azure_secret=dict(type='str', no_log=True), + zabbix_user=dict(type='str'), + zabbix_password=dict(type='str', no_log=True), + additional_json_data=dict(type='dict', default={}, required=False), + additional_secure_json_data=dict(type='dict', default={}, required=False), + enforce_secure_data=dict(type='bool', default=False, required=False) + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=[['url_username', 'url_password', 'org_id'], ['tls_client_cert', 'tls_client_key']], + mutually_exclusive=[['url_username', 'grafana_api_key'], ['tls_ca_cert', 'tls_skip_verify']], + required_if=[ + ['state', 'present', ['ds_type', 'ds_url']], + ['ds_type', 'opentsdb', ['tsdb_version', 'tsdb_resolution']], + ['ds_type', 'influxdb', ['database']], + ['ds_type', 'elasticsearch', ['database', 'es_version', 'time_field', 'interval']], + ['ds_type', 'mysql', ['database']], + ['ds_type', 'postgres', ['database', 'sslmode']], + ['ds_type', 'cloudwatch', ['aws_auth_type', 'aws_default_region']], + ['es_version', "56", ['max_concurrent_shard_requests']], + ['es_version', "60", ['max_concurrent_shard_requests']], + ['es_version', "70", ['max_concurrent_shard_requests']] + ], + ) + return module + + +def main(): + module = setup_module_object() + + state = module.params['state'] + name = module.params['name'] + enforce_secure_data = module.params['enforce_secure_data'] + + grafana_iface = GrafanaInterface(module) + ds = grafana_iface.datasource_by_name(name) + + if state == 'present': + payload = get_datasource_payload(module.params) + if ds is None: + grafana_iface.create_datasource(payload) + ds = grafana_iface.datasource_by_name(name) + module.exit_json(changed=True, datasource=ds, msg='Datasource %s created' % name) + else: + diff = compare_datasources(payload.copy(), ds.copy(), enforce_secure_data) + if diff.get('before') == diff.get('after'): + module.exit_json(changed=False, datasource=ds, msg='Datasource %s unchanged' % name) + grafana_iface.update_datasource(ds.get('id'), payload) + ds = grafana_iface.datasource_by_name(name) + if diff.get('before') == diff.get('after'): + module.exit_json(changed=False, datasource=ds, msg='Datasource %s unchanged' % name) + + module.exit_json(changed=True, diff=diff, datasource=ds, msg='Datasource %s updated' % name) + else: + if ds is None: + module.exit_json(changed=False, datasource=None, msg='Datasource %s does not exist.' % name) + grafana_iface.delete_datasource(name) + module.exit_json(changed=True, datasource=None, msg='Datasource %s deleted.' % name) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/grafana/plugins/modules/grafana_folder.py b/ansible_collections/community/grafana/plugins/modules/grafana_folder.py new file mode 100644 index 000000000..d39e56e41 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/grafana_folder.py @@ -0,0 +1,294 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. +# +# Copyright: (c) 2019, Rémi REY (@rrey) + +from __future__ import absolute_import, division, print_function + +DOCUMENTATION = ''' +--- +module: grafana_folder +author: + - Rémi REY (@rrey) +version_added: "1.0.0" +short_description: Manage Grafana Folders +description: + - Create/update/delete Grafana Folders through the Folders API. +requirements: + - The Folders API is only available starting Grafana 5 and the module will fail if the server version is lower than version 5. +options: + name: + description: + - The title of the Grafana Folder. + required: true + type: str + aliases: [ title ] + state: + description: + - Delete the members not found in the C(members) parameters from the + - list of members found on the Folder. + default: present + type: str + choices: ["present", "absent"] + skip_version_check: + description: + - Skip Grafana version check and try to reach api endpoint anyway. + - This parameter can be useful if you enabled `hide_version` in grafana.ini + required: False + type: bool + default: False + version_added: "1.2.0" +extends_documentation_fragment: +- community.grafana.basic_auth +- community.grafana.api_key +''' + +EXAMPLES = ''' +--- +- name: Create a folder + community.grafana.grafana_folder: + url: "https://grafana.example.com" + grafana_api_key: "{{ some_api_token_value }}" + title: "grafana_working_group" + state: present + +- name: Delete a folder + community.grafana.grafana_folder: + url: "https://grafana.example.com" + grafana_api_key: "{{ some_api_token_value }}" + title: "grafana_working_group" + state: absent +''' + +RETURN = ''' +--- +folder: + description: Information about the Folder + returned: On success + type: complex + contains: + id: + description: The Folder identifier + returned: always + type: int + sample: + - 42 + uid: + description: The Folder uid + returned: always + type: str + sample: + - "nErXDvCkzz" + title: + description: The Folder title + returned: always + type: str + sample: + - "Department ABC" + url: + description: The Folder url + returned: always + type: str + sample: + - "/dashboards/f/nErXDvCkzz/department-abc" + hasAcl: + description: Boolean specifying if folder has acl + returned: always + type: bool + sample: + - false + canSave: + description: Boolean specifying if current user can save in folder + returned: always + type: bool + sample: + - false + canEdit: + description: Boolean specifying if current user can edit in folder + returned: always + type: bool + sample: + - false + canAdmin: + description: Boolean specifying if current user can admin in folder + returned: always + type: bool + sample: + - false + createdBy: + description: The name of the user who created the folder + returned: always + type: str + sample: + - "admin" + created: + description: The folder creation date + returned: always + type: str + sample: + - "2018-01-31T17:43:12+01:00" + updatedBy: + description: The name of the user who last updated the folder + returned: always + type: str + sample: + - "admin" + updated: + description: The date the folder was last updated + returned: always + type: str + sample: + - "2018-01-31T17:43:12+01:00" + version: + description: The folder version + returned: always + type: int + sample: + - 1 +''' + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url, basic_auth_header +from ansible_collections.community.grafana.plugins.module_utils import base +from ansible.module_utils.six.moves.urllib.parse import quote +from ansible.module_utils._text import to_text + +__metaclass__ = type + + +class GrafanaError(Exception): + pass + + +class GrafanaFolderInterface(object): + + def __init__(self, module): + self._module = module + # {{{ Authentication header + self.headers = {"Content-Type": "application/json"} + if module.params.get('grafana_api_key', None): + self.headers["Authorization"] = "Bearer %s" % module.params['grafana_api_key'] + else: + self.headers["Authorization"] = basic_auth_header(module.params['url_username'], module.params['url_password']) + # }}} + self.grafana_url = base.clean_url(module.params.get("url")) + if module.params.get("skip_version_check") is False: + try: + grafana_version = self.get_version() + except GrafanaError as e: + self._module.fail_json(failed=True, msg=to_text(e)) + if grafana_version["major"] < 5: + self._module.fail_json(failed=True, msg="Folders API is available starting Grafana v5") + + def _send_request(self, url, data=None, headers=None, method="GET"): + if data is not None: + data = json.dumps(data, sort_keys=True) + if not headers: + headers = [] + + full_url = "{grafana_url}{path}".format(grafana_url=self.grafana_url, path=url) + resp, info = fetch_url(self._module, full_url, data=data, headers=headers, method=method) + status_code = info["status"] + if status_code == 404: + return None + elif status_code == 401: + self._module.fail_json(failed=True, msg="Unauthorized to perform action '%s' on '%s'" % (method, full_url)) + elif status_code == 403: + self._module.fail_json(failed=True, msg="Permission Denied") + elif status_code == 412: + error_msg = resp.read()['message'] + self._module.fail_json(failed=True, msg=error_msg) + elif status_code == 200: + return self._module.from_json(resp.read()) + self._module.fail_json(failed=True, msg="Grafana Folders API answered with HTTP %d" % status_code) + + def get_version(self): + url = "/api/health" + response = self._send_request(url, data=None, headers=self.headers, method="GET") + version = response.get("version") + if version is not None: + major, minor, rev = version.split(".") + return {"major": int(major), "minor": int(minor), "rev": int(rev)} + raise GrafanaError("Failed to retrieve version from '%s'" % url) + + def create_folder(self, title): + url = "/api/folders" + folder = dict(title=title) + response = self._send_request(url, data=folder, headers=self.headers, method="POST") + return response + + def get_folder(self, title): + url = "/api/search?type=dash-folder&query={title}".format(title=quote(title)) + response = self._send_request(url, headers=self.headers, method="GET") + for item in response: + if item.get("title") == to_text(title): + return item + return None + + def delete_folder(self, folder_uid): + url = "/api/folders/{folder_uid}".format(folder_uid=folder_uid) + response = self._send_request(url, headers=self.headers, method="DELETE") + return response + + +def setup_module_object(): + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=base.grafana_required_together(), + mutually_exclusive=base.grafana_mutually_exclusive(), + ) + return module + + +argument_spec = base.grafana_argument_spec() +argument_spec.update( + name=dict(type='str', aliases=['title'], required=True), + state=dict(type='str', default='present', choices=['present', 'absent']), + skip_version_check=dict(type='bool', default=False), +) + + +def main(): + + module = setup_module_object() + state = module.params['state'] + title = module.params['name'] + + grafana_iface = GrafanaFolderInterface(module) + + changed = False + if state == 'present': + folder = grafana_iface.get_folder(title) + if folder is None: + grafana_iface.create_folder(title) + folder = grafana_iface.get_folder(title) + changed = True + folder = grafana_iface.get_folder(title) + module.exit_json(changed=changed, folder=folder) + elif state == 'absent': + folder = grafana_iface.get_folder(title) + if folder is None: + module.exit_json(changed=False, message="No folder found") + result = grafana_iface.delete_folder(folder.get("uid")) + module.exit_json(changed=True, message=result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/grafana/plugins/modules/grafana_notification_channel.py b/ansible_collections/community/grafana/plugins/modules/grafana_notification_channel.py new file mode 100644 index 000000000..eb808fa1b --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/grafana_notification_channel.py @@ -0,0 +1,843 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: grafana_notification_channel +notes: +- Notification channels are replaced by contact points starting Grafana 8.3 and this module is currently not able to manage contact points. +- The module will report execution as successful since Grafana maintains backward compatibility with previous alert management, but +- nothing will be visible in the contact points if new alerting mechanism is enabled. +author: + - Aliaksandr Mianzhynski (@amenzhinsky) + - Rémi REY (@rrey) +version_added: "1.1.0" +short_description: Manage Grafana Notification Channels +description: + - Create/Update/Delete Grafana Notification Channels via API. +options: + org_id: + description: + - The Grafana Organisation ID where the dashboard will be imported / exported. + - Not used when I(grafana_api_key) is set, because the grafana_api_key only belongs to one organisation.. + default: 1 + type: int + state: + type: str + default: present + choices: + - present + - absent + description: + - Status of the notification channel. + uid: + type: str + description: + - The channel unique identifier. + name: + type: str + description: + - The name of the notification channel. + - Required when I(state) is C(present). + type: + type: str + choices: + - dingding + - discord + - email + - googlechat + - hipchat + - kafka + - line + - teams + - opsgenie + - pagerduty + - prometheus + - pushover + - sensu + - slack + - telegram + - threema + - victorops + - webhook + description: + - The channel notification type. + - Required when I(state) is C(present). + is_default: + type: bool + default: 'no' + description: + - Use this channel for all alerts. + include_image: + type: bool + default: 'no' + description: + - Capture a visualization image and attach it to notifications. + disable_resolve_message: + type: bool + default: 'no' + description: + - Disable the resolve message. + reminder_frequency: + type: str + description: + - Additional notifications interval for triggered alerts. + - For example C(15m). + dingding_url: + type: str + description: + - DingDing webhook URL. + dingding_message_type: + type: list + elements: str + choices: + - link + - action_card + description: + - DingDing message type. + discord_url: + type: str + description: + - Discord webhook URL. + discord_message_content: + type: str + description: + - Overrides message content. + email_addresses: + type: list + elements: str + description: + - List of recipients. + email_single: + type: bool + description: + - Send single email to all recipients. + googlechat_url: + type: str + description: + - Google Hangouts webhook URL. + hipchat_url: + type: str + description: + - HipChat webhook URL. + hipchat_api_key: + type: str + description: + - HipChat API key. + hipchat_room_id: + type: str + description: + - HipChat room ID. + kafka_url: + type: str + description: + - Kafka REST proxy URL. + kafka_topic: + type: str + description: + - Kafka topic name. + line_token: + type: str + description: + - LINE token. + teams_url: + type: str + description: + - Microsoft Teams webhook URL. + opsgenie_url: + type: str + description: + - OpsGenie webhook URL. + opsgenie_api_key: + type: str + description: + - OpsGenie API key. + opsgenie_auto_close: + type: bool + description: + - Automatically close alerts in OpsGenie once the alert goes back to ok. + opsgenie_override_priority: + type: bool + description: + - Allow the alert priority to be set using the og_priority tag. + pagerduty_integration_key: + type: str + description: + - PagerDuty integration key. + pagerduty_severity: + type: list + elements: str + choices: + - critical + - error + - warning + - info + description: + - Alert severity in PagerDuty. + pagerduty_auto_resolve: + type: bool + description: + - Resolve incidents in PagerDuty once the alert goes back to ok. + pagerduty_message_in_details: + type: bool + description: + - Move the alert message from the PD summary into the custom details. + - This changes the custom details object and may break event rules you have configured. + prometheus_url: + type: str + description: + - Prometheus API URL. + prometheus_username: + type: str + description: + - Prometheus username. + prometheus_password: + type: str + description: + - Prometheus password. + pushover_api_token: + type: str + description: + - Pushover API token. + pushover_user_key: + type: str + description: + - Pushover user key. + pushover_devices: + type: list + elements: str + description: + - Devices list in Pushover. + pushover_priority: + type: list + elements: str + choices: + - emergency + - high + - normal + - low + - lowest + description: + - Alert priority in Pushover. + pushover_retry: + type: int + description: + - Retry in C(n) minutes. + - Only when priority is C(emergency). + pushover_expire: + type: int + description: + - Expire alert in C(n) minutes. + - Only when priority is C(emergency). + pushover_alert_sound: + type: str + description: + - L(Alert sound in Pushover,https://pushover.net/api#sounds) + pushover_ok_sound: + type: str + description: + - L(OK sound in Pushover,https://pushover.net/api#sounds) + sensu_url: + type: str + description: + - Sensu webhook URL. + sensu_source: + type: str + description: + - Source in Sensu. + sensu_handler: + type: str + description: + - Sensu handler name. + sensu_username: + type: str + description: + - Sensu user. + sensu_password: + type: str + description: + - Sensu password. + slack_url: + type: str + description: + - Slack webhook URL. + slack_recipient: + type: str + description: + - Override default Slack channel or user. + slack_username: + type: str + description: + - Set the username for the bot's message. + slack_icon_emoji: + type: str + description: + - An emoji to use for the bot's message. + slack_icon_url: + type: str + description: + - URL to an image to use as the icon for the bot's message + slack_mention_users: + type: list + elements: str + description: + - Mention users list. + slack_mention_groups: + type: list + elements: str + description: + - Mention groups list. + slack_mention_channel: + type: list + elements: str + choices: + - here + - channel + description: + - Mention whole channel or just active members. + slack_token: + type: str + description: + - Slack token. + telegram_bot_token: + type: str + description: + - Telegram bot token; + telegram_chat_id: + type: str + description: + - Telegram chat id. + threema_gateway_id: + type: str + description: + - 8 character Threema Gateway ID (starting with a *). + threema_recipient_id: + type: str + description: + - 8 character Threema ID that should receive the alerts. + threema_api_secret: + type: str + description: + - Threema Gateway API secret. + victorops_url: + type: str + description: + - VictorOps webhook URL. + victorops_auto_resolve: + type: bool + description: + - Resolve incidents in VictorOps once the alert goes back to ok. + webhook_url: + type: str + description: + - Webhook URL + webhook_username: + type: str + description: + - Webhook username. + webhook_password: + type: str + description: + - Webhook password. + webhook_http_method: + type: list + elements: str + choices: + - POST + - PUT + description: + - Webhook HTTP verb to use. + +extends_documentation_fragment: + - community.grafana.basic_auth + - community.grafana.api_key +''' + + +EXAMPLES = ''' +- name: Create slack notification channel + register: result + grafana_notification_channel: + uid: slack + name: slack + type: slack + slack_url: https://hooks.slack.com/services/xxx/yyy/zzz + grafana_url: "{{ grafana_url }}" + grafana_user: "{{ grafana_username }}" + grafana_password: "{{ grafana_password}}" + +- name: Delete slack notification channel + register: result + grafana_notification_channel: + state: absent + uid: slack + grafana_url: "{{ grafana_url }}" + grafana_user: "{{ grafana_username }}" + grafana_password: "{{ grafana_password}}" +''' + +RETURN = ''' +notification_channel: + description: Notification channel created or updated by the module. + returned: changed + type: dict + sample: | + { + "created": "2020-11-10T21:10:19.675308051+03:00", + "disableResolveMessage": false, + "frequency": "", + "id": 37, + "isDefault": false, + "name": "Oops", + "secureFields": {}, + "sendReminder": false, + "settings": { + "uploadImage": false, + "url": "VALUE_SPECIFIED_IN_NO_LOG_PARAMETER" + }, + "type": "slack", + "uid": "slack-oops", + "updated": "2020-11-10T21:10:19.675308112+03:00" + } +''' + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_text +from ansible_collections.community.grafana.plugins.module_utils.base import grafana_argument_spec, clean_url +from ansible.module_utils.urls import basic_auth_header + + +class GrafanaAPIException(Exception): + pass + + +def dingding_channel_payload(data, payload): + payload['settings']['url'] = data['dingding_url'] + if data.get('dingding_message_type'): + payload['settings']['msgType'] = { + 'link': 'link', + 'action_card': 'actionCard', + }[data['dingding_message_type']] + + +def discord_channel_payload(data, payload): + payload['settings']['url'] = data['discord_url'] + if data.get('discord_message_content'): + payload['settings']['content'] = data['discord_message_content'] + + +def email_channel_payload(data, payload): + payload['settings']['addresses'] = ';'.join(data['email_addresses']) + if data.get('email_single'): + payload['settings']['singleEmail'] = data['email_single'] + + +def hipchat_channel_payload(data, payload): + payload['settings']['url'] = data['hipchat_url'] + if data.get('hipchat_api_key'): + payload['settings']['apiKey'] = data['hipchat_api_key'] + if data.get('hipchat_room_id'): + payload['settings']['roomid'] = data['hipchat_room_id'] + + +def pagerduty_channel_payload(data, payload): + payload['settings']['integrationKey'] = data['pagerduty_integration_key'] + if data.get('pagerduty_severity'): + payload['settings']['severity'] = data['pagerduty_severity'] + if data.get('pagerduty_auto_resolve'): + payload['settings']['autoResolve'] = data['pagerduty_auto_resolve'] + if data.get('pagerduty_message_in_details'): + payload['settings']['messageInDetails'] = data['pagerduty_message_in_details'] + + +def prometheus_channel_payload(data, payload): + payload['type'] = 'prometheus-alertmanager' + payload['settings']['url'] = data['prometheus_url'] + if data.get('prometheus_username'): + payload['settings']['basicAuthUser'] = data['prometheus_username'] + if data.get('prometheus_password'): + payload['settings']['basicAuthPassword'] = data['prometheus_password'] + + +def pushover_channel_payload(data, payload): + payload['settings']['apiToken'] = data['pushover_api_token'] + payload['settings']['userKey'] = data['pushover_user_key'] + if data.get('pushover_devices'): + payload['settings']['device'] = ';'.join(data['pushover_devices']) + if data.get('pushover_priority'): + payload['settings']['priority'] = { + 'emergency': '2', + 'high': '1', + 'normal': '0', + 'low': '-1', + 'lowest': '-2' + }[data['pushover_priority']] + if data.get('pushover_retry'): + payload['settings']['retry'] = str(data['pushover_retry']) + if data.get('pushover_expire'): + payload['settings']['expire'] = str(data['pushover_expire']) + if data.get('pushover_alert_sound'): + payload['settings']['sound'] = data['pushover_alert_sound'] + if data.get('pushover_ok_sound'): + payload['settings']['okSound'] = data['pushover_ok_sound'] + + +def sensu_channel_payload(data, payload): + payload['settings']['url'] = data['sensu_url'] + if data.get('sensu_source'): + payload['settings']['source'] = data['sensu_source'] + if data.get('sensu_handler'): + payload['settings']['handler'] = data['sensu_handler'] + if data.get('sensu_username'): + payload['settings']['username'] = data['sensu_username'] + if data.get('sensu_password'): + payload['settings']['password'] = data['sensu_password'] + + +def slack_channel_payload(data, payload): + payload['settings']['url'] = data['slack_url'] + if data.get('slack_recipient'): + payload['settings']['recipient'] = data['slack_recipient'] + if data.get('slack_username'): + payload['settings']['username'] = data['slack_username'] + if data.get('slack_icon_emoji'): + payload['settings']['iconEmoji'] = data['slack_icon_emoji'] + if data.get('slack_icon_url'): + payload['settings']['iconUrl'] = data['slack_icon_url'] + if data.get('slack_mention_users'): + payload['settings']['mentionUsers'] = ','.join(data['slack_mention_users']) + if data.get('slack_mention_groups'): + payload['settings']['mentionGroups'] = ','.join(data['slack_mention_groups']) + if data.get('slack_mention_channel'): + payload['settings']['mentionChannel'] = data['slack_mention_channel'] + if data.get('slack_token'): + payload['settings']['token'] = data['slack_token'] + + +def webhook_channel_payload(data, payload): + payload['settings']['url'] = data['webhook_url'] + if data.get('webhook_http_method'): + payload['settings']['httpMethod'] = data['webhook_http_method'] + if data.get('webhook_username'): + payload['settings']['username'] = data['webhook_username'] + if data.get('webhook_password'): + payload['settings']['password'] = data['webhook_password'] + + +def grafana_notification_channel_payload(data): + payload = { + 'uid': data['uid'], + 'name': data['name'], + 'type': data['type'], + 'isDefault': data['is_default'], + 'disableResolveMessage': data['disable_resolve_message'], + 'settings': { + 'uploadImage': data['include_image'] + } + } + + if data.get('reminder_frequency'): + payload['sendReminder'] = True + payload['frequency'] = data['reminder_frequency'] + + if data['type'] == 'dingding': + dingding_channel_payload(data, payload) + elif data['type'] == 'discord': + discord_channel_payload(data, payload) + elif data['type'] == 'email': + email_channel_payload(data, payload) + elif data['type'] == 'googlechat': + payload['settings']['url'] = data['googlechat_url'] + elif data['type'] == 'hipchat': + hipchat_channel_payload(data, payload) + elif data['type'] == 'kafka': + payload['settings']['kafkaRestProxy'] = data['kafka_url'] + payload['settings']['kafkaTopic'] = data['kafka_topic'] + elif data['type'] == 'line': + payload['settings']['token'] = data['line_token'] + elif data['type'] == 'teams': + payload['settings']['url'] = data['teams_url'] + elif data['type'] == 'opsgenie': + payload['settings']['apiUrl'] = data['opsgenie_url'] + payload['settings']['apiKey'] = data['opsgenie_api_key'] + elif data['type'] == 'pagerduty': + pagerduty_channel_payload(data, payload) + elif data['type'] == 'prometheus': + prometheus_channel_payload(data, payload) + elif data['type'] == 'pushover': + pushover_channel_payload(data, payload) + elif data['type'] == 'sensu': + sensu_channel_payload(data, payload) + elif data['type'] == 'slack': + slack_channel_payload(data, payload) + elif data['type'] == 'telegram': + payload['settings']['bottoken'] = data['telegram_bot_token'] + payload['settings']['chatid'] = data['telegram_chat_id'] + elif data['type'] == 'threema': + payload['settings']['gateway_id'] = data['threema_gateway_id'] + payload['settings']['recipient_id'] = data['threema_recipient_id'] + payload['settings']['api_secret'] = data['threema_api_secret'] + elif data['type'] == 'victorops': + payload['settings']['url'] = data['victorops_url'] + if data.get('victorops_auto_resolve'): + payload['settings']['autoResolve'] = data['victorops_auto_resolve'] + elif data['type'] == 'webhook': + webhook_channel_payload(data, payload) + return payload + + +class GrafanaNotificationChannelInterface(object): + + def __init__(self, module): + self._module = module + # {{{ Authentication header + self.headers = {"Content-Type": "application/json"} + if module.params.get('grafana_api_key', None): + self.headers["Authorization"] = "Bearer %s" % module.params['grafana_api_key'] + else: + self.headers["Authorization"] = basic_auth_header(module.params['url_username'], module.params['url_password']) + # }}} + self.grafana_url = clean_url(module.params.get("url")) + + def grafana_switch_organisation(self, grafana_url, org_id): + r, info = fetch_url(self._module, '%s/api/user/using/%s' % (grafana_url, org_id), + headers=self.headers, method='POST') + if info['status'] != 200: + raise GrafanaAPIException('Unable to switch to organization %s : %s' % + (org_id, info)) + + def grafana_create_notification_channel(self, data, payload): + r, info = fetch_url(self._module, '%s/api/alert-notifications' % data['url'], + data=json.dumps(payload), headers=self.headers, method='POST') + if info['status'] == 200: + return { + 'state': 'present', + 'changed': True, + 'channel': json.loads(to_text(r.read())), + } + else: + raise GrafanaAPIException("Unable to create notification channel: %s" % info) + + def grafana_update_notification_channel(self, data, payload, before): + r, info = fetch_url(self._module, '%s/api/alert-notifications/uid/%s' % + (data['url'], data['uid']), + data=json.dumps(payload), headers=self.headers, method='PUT') + if info['status'] == 200: + del before['created'] + del before['updated'] + + channel = json.loads(to_text(r.read())) + after = channel.copy() + del after['created'] + del after['updated'] + + if before == after: + return { + 'changed': False, + 'channel': channel, + } + else: + return { + 'changed': True, + 'diff': { + 'before': before, + 'after': after, + }, + 'channel': channel, + } + else: + raise GrafanaAPIException("Unable to update notification channel %s : %s" % + (data['uid'], info)) + + def grafana_create_or_update_notification_channel(self, data): + payload = grafana_notification_channel_payload(data) + r, info = fetch_url(self._module, '%s/api/alert-notifications/uid/%s' % + (data['url'], data['uid']), headers=self.headers) + if info['status'] == 200: + before = json.loads(to_text(r.read())) + return self.grafana_update_notification_channel(data, payload, before) + elif info['status'] == 404: + return self.grafana_create_notification_channel(data, payload) + else: + raise GrafanaAPIException("Unable to get notification channel %s : %s" % + (data['uid'], info)) + + def grafana_delete_notification_channel(self, data): + r, info = fetch_url(self._module, '%s/api/alert-notifications/uid/%s' % + (data['url'], data['uid']), + headers=self.headers, method='DELETE') + if info['status'] == 200: + return { + 'state': 'absent', + 'changed': True + } + elif info['status'] == 404: + return { + 'changed': False + } + else: + raise GrafanaAPIException("Unable to delete notification channel %s : %s" % + (data['uid'], info)) + + +def main(): + argument_spec = grafana_argument_spec() + argument_spec.update( + org_id=dict(type='int', default=1), + uid=dict(type='str'), + name=dict(type='str'), + type=dict(type='str', + choices=['dingding', 'discord', 'email', 'googlechat', 'hipchat', + 'kafka', 'line', 'teams', 'opsgenie', 'pagerduty', + 'prometheus', 'pushover', 'sensu', 'slack', 'telegram', + 'threema', 'victorops', 'webhook']), + is_default=dict(type='bool', default=False), + include_image=dict(type='bool', default=False), + disable_resolve_message=dict(type='bool', default=False), + reminder_frequency=dict(type='str'), + + dingding_url=dict(type='str'), + dingding_message_type=dict(type='list', elements='str', + choices=['link', 'action_card']), + + discord_url=dict(type='str'), + discord_message_content=dict(type='str'), + + email_addresses=dict(type='list', elements='str'), + email_single=dict(type='bool'), + + googlechat_url=dict(type='str'), + + hipchat_url=dict(type='str'), + hipchat_api_key=dict(type='str', no_log=True), + hipchat_room_id=dict(type='str'), + + kafka_url=dict(type='str'), + kafka_topic=dict(type='str'), + + line_token=dict(type='str', no_log=True), + + teams_url=dict(type='str'), + + opsgenie_url=dict(type='str'), + opsgenie_api_key=dict(type='str', no_log=True), + opsgenie_auto_close=dict(type='bool'), + opsgenie_override_priority=dict(type='bool'), + + pagerduty_integration_key=dict(type='str', no_log=True), + pagerduty_severity=dict(type='list', elements='str', + choices=['critical', 'error', 'warning', 'info']), + pagerduty_auto_resolve=dict(type='bool'), + pagerduty_message_in_details=dict(type='bool'), + + prometheus_url=dict(type='str'), + prometheus_username=dict(type='str'), + prometheus_password=dict(type='str', no_log=True), + + pushover_api_token=dict(type='str', no_log=True), + pushover_user_key=dict(type='str', no_log=True), + pushover_devices=dict(type='list', elements='str'), + pushover_priority=dict(type='list', elements='str', + choices=['emergency', 'high', 'normal', 'low', 'lowest']), + pushover_retry=dict(type='int'), # TODO: only when priority==emergency + pushover_expire=dict(type='int'), # TODO: only when priority==emergency + pushover_alert_sound=dict(type='str'), # TODO: add sound choices + pushover_ok_sound=dict(type='str'), # TODO: add sound choices + + sensu_url=dict(type='str'), + sensu_source=dict(type='str'), + sensu_handler=dict(type='str'), + sensu_username=dict(type='str'), + sensu_password=dict(type='str', no_log=True), + + slack_url=dict(type='str', no_log=True), + slack_recipient=dict(type='str'), + slack_username=dict(type='str'), + slack_icon_emoji=dict(type='str'), + slack_icon_url=dict(type='str'), + slack_mention_users=dict(type='list', elements='str'), + slack_mention_groups=dict(type='list', elements='str'), + slack_mention_channel=dict(type='list', elements='str', + choices=['here', 'channel']), + slack_token=dict(type='str', no_log=True), + + telegram_bot_token=dict(type='str', no_log=True), + telegram_chat_id=dict(type='str'), + + threema_gateway_id=dict(type='str'), + threema_recipient_id=dict(type='str'), + threema_api_secret=dict(type='str', no_log=True), + + victorops_url=dict(type='str'), + victorops_auto_resolve=dict(type='bool'), + + webhook_url=dict(type='str'), + webhook_username=dict(type='str'), + webhook_password=dict(type='str', no_log=True), + webhook_http_method=dict(type='list', elements='str', choices=['POST', 'PUT']) + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=[['url_username', 'url_password', 'org_id'], + ['prometheus_username', 'prometheus_password'], + ['sensu_username', 'sensu_password']], + mutually_exclusive=[['url_username', 'grafana_api_key']], + required_if=[ + ['state', 'present', ['name', 'type']], + ['type', 'dingding', ['dingding_url']], + ['type', 'discord', ['discord_url']], + ['type', 'email', ['email_addresses']], + ['type', 'googlechat', ['googlechat_url']], + ['type', 'hipchat', ['hipchat_url']], + ['type', 'kafka', ['kafka_url', 'kafka_topic']], + ['type', 'line', ['line_token']], + ['type', 'teams', ['teams_url']], + ['type', 'opsgenie', ['opsgenie_url', 'opsgenie_api_key']], + ['type', 'pagerduty', ['pagerduty_integration_key']], + ['type', 'prometheus', ['prometheus_url']], + ['type', 'pushover', ['pushover_api_token', 'pushover_user_key']], + ['type', 'sensu', ['sensu_url']], + ['type', 'slack', ['slack_url']], + ['type', 'telegram', ['telegram_bot_token', 'telegram_chat_id']], + ['type', 'threema', ['threema_gateway_id', 'threema_recipient_id', + 'threema_api_secret']], + ['type', 'victorops', ['victorops_url']], + ['type', 'webhook', ['webhook_url']] + ] + ) + + module.params["url"] = clean_url(module.params["url"]) + alert_channel_iface = GrafanaNotificationChannelInterface(module) + + if module.params['state'] == 'present': + result = alert_channel_iface.grafana_create_or_update_notification_channel(module.params) + module.exit_json(failed=False, **result) + else: + result = alert_channel_iface.grafana_delete_notification_channel(module.params) + module.exit_json(failed=False, **result) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/grafana/plugins/modules/grafana_organization.py b/ansible_collections/community/grafana/plugins/modules/grafana_organization.py new file mode 100644 index 000000000..7fad4c876 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/grafana_organization.py @@ -0,0 +1,205 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. +# +# Copyright: (c) 2021 + +from __future__ import absolute_import, division, print_function + +DOCUMENTATION = ''' +--- +module: grafana_organization +author: + - paytroff (@paytroff) +version_added: "1.3.0" +short_description: Manage Grafana Organization +description: + - Create/delete Grafana organization through org API. + - Tested with Grafana v6.5.0 +options: + name: + description: + - The name of the Grafana Organization. + required: true + type: str + state: + description: + - State if the organization should be present in Grafana or not + default: present + type: str + choices: ["present", "absent"] +extends_documentation_fragment: +- community.grafana.basic_auth +''' + +EXAMPLES = ''' +--- +- name: Create a Grafana organization + community.grafana.grafana_organization: + url: "https://grafana.example.com" + url_username: admin + url_password: changeme + name: orgtest + state: present + +- name: Delete a Grafana organization + community.grafana.grafana_organization: + url: "https://grafana.example.com" + url_username: admin + url_password: changeme + name: orgtest + state: absent +''' + +RETURN = ''' +--- +org: + description: Information about the organization + returned: when state present + type: complex + contains: + id: + description: The org id + returned: always + type: int + sample: + - 42 + name: + description: The org name + returned: always + type: str + sample: + - "org42" + address: + description: The org address + returned: always + type: dict + sample: + address1: "" + address2: "" + city: "" + country: "" + state: "" + zipCode: "" +''' + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url, basic_auth_header +from ansible_collections.community.grafana.plugins.module_utils import base +from ansible.module_utils.six.moves.urllib.parse import quote + +__metaclass__ = type + + +class GrafanaOrgInterface(object): + + def __init__(self, module): + self._module = module + # {{{ Authentication header + self.headers = {"Content-Type": "application/json"} + self.headers["Authorization"] = basic_auth_header(module.params['url_username'], module.params['url_password']) + # }}} + self.grafana_url = base.clean_url(module.params.get("url")) + + def _send_request(self, url, data=None, headers=None, method="GET"): + if data is not None: + data = json.dumps(data, sort_keys=True) + if not headers: + headers = [] + + full_url = "{grafana_url}{path}".format(grafana_url=self.grafana_url, path=url) + resp, info = fetch_url(self._module, full_url, data=data, headers=headers, method=method) + status_code = info["status"] + if status_code == 404: + return None + elif status_code == 401: + self._module.fail_json(failed=True, msg="Unauthorized to perform action '%s' on '%s' header: %s" % (method, full_url, self.headers)) + elif status_code == 403: + self._module.fail_json(failed=True, msg="Permission Denied") + elif status_code == 200: + return self._module.from_json(resp.read()) + if resp is None: + self._module.fail_json(failed=True, msg="Cannot connect to API Grafana %s" % info['msg'], status=status_code, url=info['url']) + else: + self._module.fail_json(failed=True, msg="Grafana Org API answered with HTTP %d" % status_code, body=self._module.from_json(resp.read())) + + def get_actual_org(self, name): + # https://grafana.com/docs/grafana/latest/http_api/org/#get-organization-by-name + url = "/api/orgs/name/{name}".format(name=quote(name)) + return self._send_request(url, headers=self.headers, method="GET") + + def create_org(self, name): + # https://grafana.com/docs/http_api/org/#create-organization + url = "/api/orgs" + org = dict(name=name) + self._send_request(url, data=org, headers=self.headers, method="POST") + return self.get_actual_org(name) + + def delete_org(self, org_id): + # https://grafana.com/docs/http_api/org/#delete-organization + url = "/api/orgs/{org_id}".format(org_id=org_id) + return self._send_request(url, headers=self.headers, method="DELETE") + + +def setup_module_object(): + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=base.grafana_required_together() + ) + return module + + +argument_spec = base.grafana_argument_spec() +argument_spec.update( + state=dict(choices=['present', 'absent'], default='present'), + name=dict(type='str', required=True), +) +argument_spec.pop('grafana_api_key') + + +def main(): + module = setup_module_object() + state = module.params['state'] + name = module.params['name'] + + grafana_iface = GrafanaOrgInterface(module) + + # search org by name + actual_org = grafana_iface.get_actual_org(name) + if state == 'present': + has_changed = False + + if actual_org is None: + # create new org + actual_org = grafana_iface.create_org(name) + has_changed = True + module.exit_json(changed=has_changed, msg='Organization %s created.' % name, org=actual_org) + else: + module.exit_json(changed=has_changed, msg='Organization %s already created.' % name, org=actual_org) + + elif state == 'absent': + if actual_org is None: + module.exit_json(msg="No org found, nothing to do") + # delete org + result = grafana_iface.delete_org(actual_org.get("id")) + module.exit_json(changed=True, msg=result.get("message")) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/grafana/plugins/modules/grafana_plugin.py b/ansible_collections/community/grafana/plugins/modules/grafana_plugin.py new file mode 100644 index 000000000..7fd418760 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/grafana_plugin.py @@ -0,0 +1,270 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2017, Thierry Sallé (@seuf) +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +DOCUMENTATION = '''module: grafana_plugin +author: +- Thierry Sallé (@seuf) +short_description: Manage Grafana plugins via grafana-cli +description: +- Install and remove Grafana plugins. +- See U(https://grafana.com/docs/plugins/installation/) for upstream documentation. +options: + name: + description: + - Name of the plugin. + required: true + type: str + version: + description: + - Version of the plugin to install. + - Defaults to C(latest). + type: str + grafana_plugins_dir: + description: + - Directory where the Grafana plugin will be installed. + - If omitted, defaults to C(/var/lib/grafana/plugins). + type: str + grafana_repo: + description: + - URL to the Grafana plugin repository. + - 'If omitted, grafana-cli will use the default value: U(https://grafana.com/api/plugins).' + type: str + grafana_plugin_url: + description: + - Full URL to the plugin zip file instead of downloading the file from U(https://grafana.com/api/plugins). + - Requires grafana 4.6.x or later. + type: str + state: + description: + - Whether the plugin should be installed. + choices: + - present + - absent + default: present + type: str + validate_certs: + description: + - Boolean variable to include --insecure while installing pluging + default: False + type: bool +''' + +EXAMPLES = ''' +--- +- name: Install/update Grafana piechart panel plugin + community.grafana.grafana_plugin: + name: grafana-piechart-panel + version: latest + state: present +''' + +RETURN = ''' +--- +version: + description: version of the installed/removed/updated plugin. + type: str + returned: always +''' + +import os +from ansible.module_utils.basic import AnsibleModule + +__metaclass__ = type + + +class GrafanaCliException(Exception): + pass + + +def parse_version(string): + name, version = string.split('@') + return name.strip(), version.strip() + + +def grafana_cli_bin(params): + ''' + Get the grafana-cli binary path with global options. + Raise a GrafanaCliException if the grafana-cli is not present or not in PATH + + :param params: ansible module params. Used to fill grafana-cli global params. + ''' + program = 'grafana-cli' + grafana_cli = None + + def is_exe(fpath): + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + + fpath, fname = os.path.split(program) + if fpath: + if is_exe(program): + grafana_cli = program + else: + for path in os.environ["PATH"].split(os.pathsep): + path = path.strip('"') + exe_file = os.path.join(path, program) + if is_exe(exe_file): + grafana_cli = exe_file + break + + if grafana_cli is None: + raise GrafanaCliException('grafana-cli binary is not present or not in PATH') + else: + if 'grafana_plugin_url' in params and params['grafana_plugin_url']: + grafana_cli = '{0} {1} {2}'.format(grafana_cli, '--pluginUrl', params['grafana_plugin_url']) + if 'grafana_plugins_dir' in params and params['grafana_plugins_dir']: + grafana_cli = '{0} {1} {2}'.format(grafana_cli, '--pluginsDir', params['grafana_plugins_dir']) + if 'grafana_repo' in params and params['grafana_repo']: + grafana_cli = '{0} {1} {2}'.format(grafana_cli, '--repo', params['grafana_repo']) + if 'validate_certs' in params and params['validate_certs'] is False: + grafana_cli = '{0} {1}'.format(grafana_cli, '--insecure') + + return '{0} {1}'.format(grafana_cli, 'plugins') + + +def get_grafana_plugin_version(module, params): + ''' + Fetch grafana installed plugin version. Return None if plugin is not installed. + + :param module: ansible module object. used to run system commands. + :param params: ansible module params. + ''' + grafana_cli = grafana_cli_bin(params) + rc, stdout, stderr = module.run_command('{0} ls'.format(grafana_cli)) + stdout_lines = stdout.split("\n") + for line in stdout_lines: + if line.find(' @ ') != -1: + line = line.rstrip() + plugin_name, plugin_version = parse_version(line) + if plugin_name == params['name']: + return plugin_version + return None + + +def get_grafana_plugin_version_latest(module, params): + ''' + Fetch the latest version available from grafana-cli. + Return the newest version number or None not found. + + :param module: ansible module object. used to run system commands. + :param params: ansible module params. + ''' + grafana_cli = grafana_cli_bin(params) + rc, stdout, stderr = module.run_command('{0} list-versions {1}'.format(grafana_cli, + params['name'])) + stdout_lines = stdout.split("\n") + if stdout_lines[0]: + return stdout_lines[0].rstrip() + return None + + +def grafana_plugin(module, params): + ''' + Install update or remove grafana plugin + + :param module: ansible module object. used to run system commands. + :param params: ansible module params. + ''' + grafana_cli = grafana_cli_bin(params) + + if params['state'] == 'present': + grafana_plugin_version = get_grafana_plugin_version(module, params) + if grafana_plugin_version is not None: + if 'version' in params and params['version']: + if params['version'] == grafana_plugin_version: + return {'msg': 'Grafana plugin already installed', + 'changed': False, + 'version': grafana_plugin_version} + else: + if params['version'] == 'latest' or params['version'] is None: + latest_version = get_grafana_plugin_version_latest(module, params) + if latest_version == grafana_plugin_version: + return {'msg': 'Grafana plugin already installed', + 'changed': False, + 'version': grafana_plugin_version} + cmd = '{0} update {1}'.format(grafana_cli, params['name']) + else: + cmd = '{0} install {1} {2}'.format(grafana_cli, params['name'], params['version']) + else: + return {'msg': 'Grafana plugin already installed', + 'changed': False, + 'version': grafana_plugin_version} + else: + if 'version' in params: + if params['version'] == 'latest' or params['version'] is None: + cmd = '{0} install {1}'.format(grafana_cli, params['name']) + else: + cmd = '{0} install {1} {2}'.format(grafana_cli, params['name'], params['version']) + else: + cmd = '{0} install {1}'.format(grafana_cli, params['name']) + else: + cmd = '{0} uninstall {1}'.format(grafana_cli, params['name']) + + rc, stdout, stderr = module.run_command(cmd) + if rc == 0: + stdout_lines = stdout.split("\n") + for line in stdout_lines: + if line.find(params['name']): + if line.find(' @ ') != -1: + line = line.rstrip() + plugin_name, plugin_version = parse_version(line) + else: + plugin_version = None + + if params['state'] == 'present': + return {'msg': 'Grafana plugin {0} installed : {1}'.format(params['name'], cmd), + 'changed': True, + 'version': plugin_version} + else: + return {'msg': 'Grafana plugin {0} uninstalled : {1}'.format(params['name'], cmd), + 'changed': True} + else: + if params['state'] == 'absent' and stdout.find("plugin does not exist"): + return {'msg': 'Grafana plugin {0} already uninstalled : {1}'.format(params['name'], cmd), 'changed': False} + raise GrafanaCliException("'{0}' execution returned an error : [{1}] {2} {3}".format(cmd, rc, stdout, stderr)) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict(required=True, + type='str'), + version=dict(type='str'), + grafana_plugins_dir=dict(type='str'), + grafana_repo=dict(type='str'), + grafana_plugin_url=dict(type='str'), + validate_certs=dict(type='bool', default=False), + state=dict(choices=['present', 'absent'], + default='present') + ), + supports_check_mode=False + ) + + try: + result = grafana_plugin(module, module.params) + except GrafanaCliException as e: + module.fail_json( + failed=True, + msg="{0}".format(e) + ) + return + except Exception as e: + module.fail_json( + failed=True, + msg="{0} : {1} ".format(type(e), e) + ) + return + + module.exit_json( + failed=False, + **result + ) + return + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/grafana/plugins/modules/grafana_team.py b/ansible_collections/community/grafana/plugins/modules/grafana_team.py new file mode 100644 index 000000000..7f8de8457 --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/grafana_team.py @@ -0,0 +1,357 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. +# +# Copyright: (c) 2019, Rémi REY (@rrey) + +from __future__ import absolute_import, division, print_function + +DOCUMENTATION = ''' +--- +module: grafana_team +author: + - Rémi REY (@rrey) +version_added: "1.0.0" +short_description: Manage Grafana Teams +description: + - Create/update/delete Grafana Teams through the Teams API. + - Also allows to add members in the team (if members exists). +requirements: + - The Teams API is only available starting Grafana 5 and the module will fail if the server version is lower than version 5. +options: + name: + description: + - The name of the Grafana Team. + required: true + type: str + email: + description: + - The mail address associated with the Team. + required: true + type: str + members: + description: + - List of team members (emails). + - The list can be enforced with C(enforce_members) parameter. + type: list + elements: str + state: + description: + - Delete the members not found in the C(members) parameters from the + - list of members found on the Team. + default: present + type: str + choices: ["present", "absent"] + enforce_members: + description: + - Delete the members not found in the C(members) parameters from the + - list of members found on the Team. + default: False + type: bool + skip_version_check: + description: + - Skip Grafana version check and try to reach api endpoint anyway. + - This parameter can be useful if you enabled `hide_version` in grafana.ini + required: False + type: bool + default: False + version_added: "1.2.0" +extends_documentation_fragment: +- community.grafana.basic_auth +- community.grafana.api_key +''' + +EXAMPLES = ''' +--- +- name: Create a team + community.grafana.grafana_team: + url: "https://grafana.example.com" + grafana_api_key: "{{ some_api_token_value }}" + name: "grafana_working_group" + email: "foo.bar@example.com" + state: present + +- name: Create a team with members + community.grafana.grafana_team: + url: "https://grafana.example.com" + grafana_api_key: "{{ some_api_token_value }}" + name: "grafana_working_group" + email: "foo.bar@example.com" + members: + - john.doe@example.com + - jane.doe@example.com + state: present + +- name: Create a team with members and enforce the list of members + community.grafana.grafana_team: + url: "https://grafana.example.com" + grafana_api_key: "{{ some_api_token_value }}" + name: "grafana_working_group" + email: "foo.bar@example.com" + members: + - john.doe@example.com + - jane.doe@example.com + enforce_members: yes + state: present + +- name: Delete a team + community.grafana.grafana_team: + url: "https://grafana.example.com" + grafana_api_key: "{{ some_api_token_value }}" + name: "grafana_working_group" + email: "foo.bar@example.com" + state: absent +''' + +RETURN = ''' +--- +team: + description: Information about the Team + returned: On success + type: complex + contains: + avatarUrl: + description: The url of the Team avatar on Grafana server + returned: always + type: str + sample: + - "/avatar/a7440323a684ea47406313a33156e5e9" + email: + description: The Team email address + returned: always + type: str + sample: + - "foo.bar@example.com" + id: + description: The Team email address + returned: always + type: int + sample: + - 42 + memberCount: + description: The number of Team members + returned: always + type: int + sample: + - 42 + name: + description: The name of the team. + returned: always + type: str + sample: + - "grafana_working_group" + members: + description: The list of Team members + returned: always + type: list + sample: + - ["john.doe@exemple.com"] + orgId: + description: The organization id that the team is part of. + returned: always + type: int + sample: + - 1 +''' + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url, basic_auth_header +from ansible.module_utils._text import to_text +from ansible_collections.community.grafana.plugins.module_utils import base +from ansible.module_utils.six.moves.urllib.parse import quote + +__metaclass__ = type + + +class GrafanaError(Exception): + pass + + +class GrafanaTeamInterface(object): + + def __init__(self, module): + self._module = module + # {{{ Authentication header + self.headers = {"Content-Type": "application/json"} + if module.params.get('grafana_api_key', None): + self.headers["Authorization"] = "Bearer %s" % module.params['grafana_api_key'] + else: + self.headers["Authorization"] = basic_auth_header(module.params['url_username'], module.params['url_password']) + # }}} + self.grafana_url = base.clean_url(module.params.get("url")) + if module.params.get("skip_version_check") is False: + try: + grafana_version = self.get_version() + except GrafanaError as e: + self._module.fail_json(failed=True, msg=to_text(e)) + if grafana_version["major"] < 5: + self._module.fail_json(failed=True, msg="Teams API is available starting Grafana v5") + + def _send_request(self, url, data=None, headers=None, method="GET"): + if data is not None: + data = json.dumps(data, sort_keys=True) + if not headers: + headers = [] + + full_url = "{grafana_url}{path}".format(grafana_url=self.grafana_url, path=url) + resp, info = fetch_url(self._module, full_url, data=data, headers=headers, method=method) + status_code = info["status"] + if status_code == 404: + return None + elif status_code == 401: + self._module.fail_json(failed=True, msg="Unauthorized to perform action '%s' on '%s'" % (method, full_url)) + elif status_code == 403: + self._module.fail_json(failed=True, msg="Permission Denied") + elif status_code == 409: + self._module.fail_json(failed=True, msg="Team name is taken") + elif status_code == 200: + return self._module.from_json(resp.read()) + self._module.fail_json(failed=True, msg="Grafana Teams API answered with HTTP %d" % status_code) + + def get_version(self): + url = "/api/health" + response = self._send_request(url, data=None, headers=self.headers, method="GET") + version = response.get("version") + if version is not None: + major, minor, rev = version.split(".") + return {"major": int(major), "minor": int(minor), "rev": int(rev)} + raise GrafanaError("Failed to retrieve version from '%s'" % url) + + def create_team(self, name, email): + url = "/api/teams" + team = dict(email=email, name=name) + response = self._send_request(url, data=team, headers=self.headers, method="POST") + return response + + def get_team(self, name): + url = "/api/teams/search?name={team}".format(team=quote(name)) + response = self._send_request(url, headers=self.headers, method="GET") + if not response.get("totalCount") <= 1: + raise AssertionError("Expected 1 team, got %d" % response["totalCount"]) + + if len(response.get("teams")) == 0: + return None + return response.get("teams")[0] + + def update_team(self, team_id, name, email): + url = "/api/teams/{team_id}".format(team_id=team_id) + team = dict(email=email, name=name) + response = self._send_request(url, data=team, headers=self.headers, method="PUT") + return response + + def delete_team(self, team_id): + url = "/api/teams/{team_id}".format(team_id=team_id) + response = self._send_request(url, headers=self.headers, method="DELETE") + return response + + def get_team_members(self, team_id): + url = "/api/teams/{team_id}/members".format(team_id=team_id) + response = self._send_request(url, headers=self.headers, method="GET") + members = [item.get("email") for item in response] + return members + + def add_team_member(self, team_id, email): + url = "/api/teams/{team_id}/members".format(team_id=team_id) + data = {"userId": self.get_user_id_from_mail(email)} + self._send_request(url, data=data, headers=self.headers, method="POST") + + def delete_team_member(self, team_id, email): + user_id = self.get_user_id_from_mail(email) + url = "/api/teams/{team_id}/members/{user_id}".format(team_id=team_id, user_id=user_id) + self._send_request(url, headers=self.headers, method="DELETE") + + def get_user_id_from_mail(self, email): + url = "/api/users/lookup?loginOrEmail={email}".format(email=quote(email)) + user = self._send_request(url, headers=self.headers, method="GET") + if user is None: + self._module.fail_json(failed=True, msg="User '%s' does not exists" % email) + return user.get("id") + + +def setup_module_object(): + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_together=base.grafana_required_together(), + mutually_exclusive=base.grafana_mutually_exclusive(), + ) + return module + + +argument_spec = base.grafana_argument_spec() +argument_spec.update( + name=dict(type='str', required=True), + email=dict(type='str', required=True), + members=dict(type='list', elements='str', required=False), + enforce_members=dict(type='bool', default=False), + skip_version_check=dict(type='bool', default=False), +) + + +def main(): + + module = setup_module_object() + state = module.params['state'] + name = module.params['name'] + email = module.params['email'] + members = module.params['members'] + enforce_members = module.params['enforce_members'] + + grafana_iface = GrafanaTeamInterface(module) + + changed = False + if state == 'present': + team = grafana_iface.get_team(name) + if team is None: + grafana_iface.create_team(name, email) + team = grafana_iface.get_team(name) + changed = True + if members is not None: + cur_members = grafana_iface.get_team_members(team.get("id")) + plan = diff_members(members, cur_members) + for member in plan.get("to_add"): + grafana_iface.add_team_member(team.get("id"), member) + changed = True + if enforce_members: + for member in plan.get("to_del"): + grafana_iface.delete_team_member(team.get("id"), member) + changed = True + team = grafana_iface.get_team(name) + team['members'] = grafana_iface.get_team_members(team.get("id")) + module.exit_json(failed=False, changed=changed, team=team) + elif state == 'absent': + team = grafana_iface.get_team(name) + if team is None: + module.exit_json(failed=False, changed=False, message="No team found") + result = grafana_iface.delete_team(team.get("id")) + module.exit_json(failed=False, changed=True, message=result.get("message")) + + +def diff_members(target, current): + diff = {"to_del": [], "to_add": []} + for member in target: + if member not in current: + diff["to_add"].append(member) + for member in current: + if member not in target: + diff["to_del"].append(member) + return diff + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/grafana/plugins/modules/grafana_user.py b/ansible_collections/community/grafana/plugins/modules/grafana_user.py new file mode 100644 index 000000000..3247b534a --- /dev/null +++ b/ansible_collections/community/grafana/plugins/modules/grafana_user.py @@ -0,0 +1,304 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. +# +# Copyright: (c) 2020, Antoine Tanzilli (@Tailzip), Hong Viet Lê (@pomverte), Julien Alexandre (@jual), Marc Cyprien (@LeFameux) + +from __future__ import absolute_import, division, print_function + +DOCUMENTATION = ''' +--- +module: grafana_user +author: + - Antoine Tanzilli (@Tailzip) + - Hong Viet LE (@pomverte) + - Julien Alexandre (@jual) + - Marc Cyprien (@LeFameux) +version_added: "1.0.0" +short_description: Manage Grafana User +description: + - Create/update/delete Grafana User through the users and admin API. + - Tested with Grafana v6.4.3 + - Password update is not supported at the time +options: + name: + description: + - The name of the Grafana User. + required: false + type: str + email: + description: + - The email of the Grafana User. + required: false + type: str + login: + description: + - The login of the Grafana User. + required: true + type: str + password: + description: + - The password of the Grafana User. + - At the moment, this field is not updated yet. + required: false + type: str + is_admin: + description: + - The Grafana User is an admin. + required: false + type: bool + default: false + state: + description: + - State if the user should be present in Grafana or not + default: present + type: str + choices: ["present", "absent"] +notes: +- Unlike other modules from the collection, this module does not support `grafana_api_key` authentication type. The Grafana API endpoint for users management + requires basic auth and admin privileges. +extends_documentation_fragment: +- community.grafana.basic_auth +''' + +EXAMPLES = ''' +--- +- name: Create or update a Grafana user + community.grafana.grafana_user: + url: "https://grafana.example.com" + url_username: admin + url_password: changeme + name: "Bruce Wayne" + email: batman@gotham.city + login: batman + password: robin + is_admin: true + state: present + +- name: Delete a Grafana user + community.grafana.grafana_user: + url: "https://grafana.example.com" + url_username: admin + url_password: changeme + login: batman + state: absent +''' + +RETURN = ''' +--- +user: + description: Information about the User + returned: when state present + type: complex + contains: + id: + description: The User id + returned: always + type: int + sample: + - 42 + email: + description: The User email address + returned: always + type: str + sample: + - "foo.bar@example.com" + login: + description: The User login + returned: always + type: str + sample: + - "batman" + theme: + description: The Grafana theme + returned: always + type: str + sample: + - "light" + orgId: + description: The organization id that the team is part of. + returned: always + type: int + sample: + - 1 + isGrafanaAdmin: + description: The Grafana user permission for admin + returned: always + type: bool + sample: + - false + isDisabled: + description: The Grafana account status + returned: always + type: bool + sample: + - false + isExternal: + description: The Grafana account information on external user provider + returned: always + type: bool + sample: + - false +''' + +import json + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url, basic_auth_header +from ansible_collections.community.grafana.plugins.module_utils import base +from ansible.module_utils.six.moves.urllib.parse import quote + +__metaclass__ = type + + +class GrafanaUserInterface(object): + + def __init__(self, module): + self._module = module + # {{{ Authentication header + self.headers = {"Content-Type": "application/json"} + self.headers["Authorization"] = basic_auth_header(module.params['url_username'], module.params['url_password']) + # }}} + self.grafana_url = base.clean_url(module.params.get("url")) + + def _send_request(self, url, data=None, headers=None, method="GET"): + if data is not None: + data = json.dumps(data, sort_keys=True) + if not headers: + headers = [] + + full_url = "{grafana_url}{path}".format(grafana_url=self.grafana_url, path=url) + resp, info = fetch_url(self._module, full_url, data=data, headers=headers, method=method) + status_code = info["status"] + if status_code == 404: + return None + elif status_code == 401: + self._module.fail_json(failed=True, msg="Unauthorized to perform action '%s' on '%s' header: %s" % (method, full_url, self.headers)) + elif status_code == 403: + self._module.fail_json(failed=True, msg="Permission Denied") + elif status_code == 200: + return self._module.from_json(resp.read()) + self._module.fail_json(failed=True, msg="Grafana Users API answered with HTTP %d" % status_code, body=self._module.from_json(resp.read())) + + def create_user(self, name, email, login, password): + # https://grafana.com/docs/http_api/admin/#global-users + if not password: + self._module.fail_json(failed=True, msg="missing required arguments: password") + url = "/api/admin/users" + user = dict(name=name, email=email, login=login, password=password) + self._send_request(url, data=user, headers=self.headers, method="POST") + return self.get_user_from_login(login) + + def get_user_from_login(self, login): + # https://grafana.com/docs/grafana/latest/http_api/user/#get-single-user-by-usernamelogin-or-email + url = "/api/users/lookup?loginOrEmail={login}".format(login=quote(login)) + return self._send_request(url, headers=self.headers, method="GET") + + def update_user(self, user_id, email, name, login): + # https://grafana.com/docs/http_api/user/#user-update + url = "/api/users/{user_id}".format(user_id=user_id) + user = dict(email=email, name=name, login=login) + self._send_request(url, data=user, headers=self.headers, method="PUT") + return self.get_user_from_login(login) + + def update_user_permissions(self, user_id, is_admin): + # https://grafana.com/docs/http_api/admin/#permissions + url = "/api/admin/users/{user_id}/permissions".format(user_id=user_id) + permissions = dict(isGrafanaAdmin=is_admin) + return self._send_request(url, data=permissions, headers=self.headers, method="PUT") + + def delete_user(self, user_id): + # https://grafana.com/docs/http_api/admin/#delete-global-user + url = "/api/admin/users/{user_id}".format(user_id=user_id) + return self._send_request(url, headers=self.headers, method="DELETE") + + +def is_user_update_required(target_user, email, name, login, is_admin): + # compare value before in target_user object and param + target_user_dict = dict( + email=target_user.get("email"), + name=target_user.get("name"), + login=target_user.get("login"), + is_admin=target_user.get("isGrafanaAdmin") + ) + param_dict = dict(email=email, name=name, login=login, is_admin=is_admin) + return target_user_dict != param_dict + + +def setup_module_object(): + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + required_if=[ + ['state', 'present', ['name', 'email']], + ], + required_together=base.grafana_required_together() + ) + return module + + +argument_spec = base.grafana_argument_spec() +argument_spec.update( + state=dict(choices=['present', 'absent'], default='present'), + name=dict(type='str', required=False), + email=dict(type='str', required=False), + login=dict(type='str', required=True), + password=dict(type='str', required=False, no_log=True), + is_admin=dict(type='bool', default=False), +) +argument_spec.pop('grafana_api_key') + + +def main(): + module = setup_module_object() + state = module.params['state'] + name = module.params['name'] + email = module.params['email'] + login = module.params['login'] + password = module.params['password'] + is_admin = module.params['is_admin'] + + grafana_iface = GrafanaUserInterface(module) + + # search user by login + actual_grafana_user = grafana_iface.get_user_from_login(login) + if state == 'present': + has_changed = False + + if actual_grafana_user is None: + # create new user + actual_grafana_user = grafana_iface.create_user(name, email, login, password) + has_changed = True + + if is_user_update_required(actual_grafana_user, email, name, login, is_admin): + # update found user + actual_grafana_user_id = actual_grafana_user.get("id") + if is_admin != actual_grafana_user.get("isGrafanaAdmin"): + grafana_iface.update_user_permissions(actual_grafana_user_id, is_admin) + actual_grafana_user = grafana_iface.update_user(actual_grafana_user_id, email, name, login) + has_changed = True + + module.exit_json(changed=has_changed, user=actual_grafana_user) + + elif state == 'absent': + if actual_grafana_user is None: + module.exit_json(message="No user found, nothing to do") + result = grafana_iface.delete_user(actual_grafana_user.get("id")) + module.exit_json(changed=True, message=result.get("message")) + + +if __name__ == '__main__': + main() |