summaryrefslogtreecommitdiffstats
path: root/ansible_collections/community/grafana/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'ansible_collections/community/grafana/plugins')
-rw-r--r--ansible_collections/community/grafana/plugins/callback/__init__.py0
-rw-r--r--ansible_collections/community/grafana/plugins/callback/grafana_annotations.py272
-rw-r--r--ansible_collections/community/grafana/plugins/doc_fragments/__init__.py0
-rw-r--r--ansible_collections/community/grafana/plugins/doc_fragments/api_key.py18
-rw-r--r--ansible_collections/community/grafana/plugins/doc_fragments/basic_auth.py52
-rw-r--r--ansible_collections/community/grafana/plugins/lookup/__init__.py0
-rw-r--r--ansible_collections/community/grafana/plugins/lookup/grafana_dashboard.py174
-rw-r--r--ansible_collections/community/grafana/plugins/module_utils/base.py54
-rw-r--r--ansible_collections/community/grafana/plugins/modules/__init__.py0
-rw-r--r--ansible_collections/community/grafana/plugins/modules/grafana_dashboard.py559
-rw-r--r--ansible_collections/community/grafana/plugins/modules/grafana_datasource.py845
-rw-r--r--ansible_collections/community/grafana/plugins/modules/grafana_folder.py294
-rw-r--r--ansible_collections/community/grafana/plugins/modules/grafana_notification_channel.py843
-rw-r--r--ansible_collections/community/grafana/plugins/modules/grafana_organization.py205
-rw-r--r--ansible_collections/community/grafana/plugins/modules/grafana_plugin.py270
-rw-r--r--ansible_collections/community/grafana/plugins/modules/grafana_team.py357
-rw-r--r--ansible_collections/community/grafana/plugins/modules/grafana_user.py304
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()