560 lines
19 KiB
Python
560 lines
19 KiB
Python
#!/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['grafana_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') 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['grafana_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['grafana_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['grafana_url'], uid, headers=headers)
|
|
else:
|
|
dashboard_exists, dashboard = grafana_dashboard_search(
|
|
module, data['grafana_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['grafana_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['grafana_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['grafana_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['grafana_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['grafana_url'], uid), headers=headers, method='DELETE')
|
|
else:
|
|
r, info = fetch_url(module, '%s/api/dashboards/uid/%s' % (data['grafana_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['grafana_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['grafana_url'], uid, headers=headers)
|
|
|
|
if dashboard_exists is True:
|
|
try:
|
|
with open(data['path'], 'w') as f:
|
|
f.write(json.dumps(dashboard))
|
|
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["grafana_url"] = clean_url(module.params["grafana_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()
|