636201e440
Signed-off-by: Felipe Zipitria <fzipitria@perceptyx.com>
585 lines
20 KiB
Python
585 lines
20 KiB
Python
# -*- coding: utf-8 -*-
|
|
'''
|
|
Manage Grafana v3.0 Dashboards
|
|
|
|
.. versionadded:: 2016.3.0
|
|
|
|
.. code-block:: yaml
|
|
|
|
grafana:
|
|
grafana_timeout: 3
|
|
grafana_token: qwertyuiop
|
|
grafana_url: 'https://url.com'
|
|
|
|
.. code-block:: yaml
|
|
|
|
Ensure minimum dashboard is managed:
|
|
grafana_dashboard.present:
|
|
- name: insightful-dashboard
|
|
- base_dashboards_from_pillar:
|
|
- default_dashboard
|
|
- base_rows_from_pillar:
|
|
- default_row
|
|
- base_panels_from_pillar:
|
|
- default_panel
|
|
- dashboard:
|
|
rows:
|
|
- title: Usage
|
|
panels:
|
|
- targets:
|
|
- target: alias(constantLine(50), 'max')
|
|
title: Imaginary
|
|
type: graph
|
|
|
|
|
|
The behavior of this module is to create dashboards if they do not exist, to
|
|
add rows if they do not exist in existing dashboards, and to update rows if
|
|
they exist in dashboards. The module will not manage rows that are not defined,
|
|
allowing users to manage their own custom rows.
|
|
'''
|
|
|
|
# Import Python libs
|
|
from __future__ import absolute_import
|
|
import copy
|
|
import json
|
|
import requests
|
|
|
|
# Import Salt libs
|
|
import salt.ext.six as six
|
|
from salt.utils.dictdiffer import DictDiffer
|
|
|
|
|
|
def __virtual__():
|
|
'''Only load if grafana v2.0 is configured.'''
|
|
return __salt__['config.get']('grafana_version', 1) == 3
|
|
|
|
|
|
_DEFAULT_DASHBOARD_PILLAR = 'grafana_dashboards:default'
|
|
_DEFAULT_PANEL_PILLAR = 'grafana_panels:default'
|
|
_DEFAULT_ROW_PILLAR = 'grafana_rows:default'
|
|
_PINNED_ROWS_PILLAR = 'grafana_pinned_rows'
|
|
|
|
|
|
def present(name,
|
|
base_dashboards_from_pillar=None,
|
|
base_panels_from_pillar=None,
|
|
base_rows_from_pillar=None,
|
|
dashboard=None,
|
|
dashboard_format='yaml',
|
|
profile='grafana'):
|
|
'''
|
|
Ensure the grafana dashboard exists and is managed.
|
|
|
|
name
|
|
Name of the grafana dashboard.
|
|
|
|
base_dashboards_from_pillar
|
|
A pillar key that contains a list of dashboards to inherit from
|
|
|
|
base_panels_from_pillar
|
|
A pillar key that contains a list of panels to inherit from
|
|
|
|
base_rows_from_pillar
|
|
A pillar key that contains a list of rows to inherit from
|
|
|
|
dashboard
|
|
A dict that defines a dashboard that should be managed.
|
|
|
|
dashboard_format
|
|
You can use two formats for dashboards. You can use the JSON format
|
|
if you provide a complete dashboard in raw JSON or you can use the YAML
|
|
format (this is the default) and provide a description of the
|
|
dashboard in YAML.
|
|
|
|
profile
|
|
A pillar key or dict that contains grafana information
|
|
'''
|
|
ret = {'name': name, 'result': True, 'comment': '', 'changes': {}}
|
|
dashboard = dashboard or {}
|
|
|
|
if isinstance(profile, six.string_types):
|
|
profile = __salt__['config.option'](profile)
|
|
|
|
if dashboard_format == 'json':
|
|
# In this case, a raw JSON of the full dashboard is provided.
|
|
response = _update(dashboard, profile)
|
|
|
|
if response.get('status') == 'success':
|
|
ret['comment'] = 'Dashboard {0} created.'.format(name)
|
|
ret['changes']['new'] = 'Dashboard {0} created.'.format(name)
|
|
else:
|
|
ret['result'] = False
|
|
ret['comment'] = ("Failed to create dashboard {0}, "
|
|
"response={1}").format(name, response)
|
|
|
|
return ret
|
|
|
|
base_dashboards_from_pillar = base_dashboards_from_pillar or []
|
|
base_panels_from_pillar = base_panels_from_pillar or []
|
|
base_rows_from_pillar = base_rows_from_pillar or []
|
|
|
|
# Add pillar keys for default configuration
|
|
base_dashboards_from_pillar = ([_DEFAULT_DASHBOARD_PILLAR] +
|
|
base_dashboards_from_pillar)
|
|
base_panels_from_pillar = ([_DEFAULT_PANEL_PILLAR] +
|
|
base_panels_from_pillar)
|
|
base_rows_from_pillar = [_DEFAULT_ROW_PILLAR] + base_rows_from_pillar
|
|
|
|
# Build out all dashboard fields
|
|
new_dashboard = _inherited_dashboard(
|
|
dashboard, base_dashboards_from_pillar, ret)
|
|
if not new_dashboard.get('title'):
|
|
new_dashboard['title'] = name
|
|
rows = new_dashboard.get('rows', [])
|
|
for i, row in enumerate(rows):
|
|
rows[i] = _inherited_row(row, base_rows_from_pillar, ret)
|
|
for row in rows:
|
|
panels = row.get('panels', [])
|
|
for i, panel in enumerate(sorted(panels)):
|
|
panels[i] = _inherited_panel(panel, base_panels_from_pillar, ret)
|
|
_auto_adjust_panel_spans(new_dashboard)
|
|
_ensure_panel_ids(new_dashboard)
|
|
_ensure_annotations(new_dashboard)
|
|
|
|
# Create dashboard if it does not exist
|
|
url = 'db/{0}'.format(name)
|
|
old_dashboard = _get(url, profile)
|
|
if not old_dashboard:
|
|
if __opts__['test']:
|
|
ret['result'] = None
|
|
ret['comment'] = 'Dashboard {0} is set to be created.'.format(name)
|
|
return ret
|
|
|
|
response = _update(new_dashboard, profile)
|
|
if response.get('status') == 'success':
|
|
ret['comment'] = 'Dashboard {0} created.'.format(name)
|
|
ret['changes']['new'] = 'Dashboard {0} created.'.format(name)
|
|
else:
|
|
ret['result'] = False
|
|
ret['comment'] = ("Failed to create dashboard {0}, "
|
|
"response={1}").format(name, response)
|
|
return ret
|
|
|
|
# Add unmanaged rows to the dashboard. They appear at the top if they are
|
|
# marked as pinned. They appear at the bottom otherwise.
|
|
managed_row_titles = [row.get('title')
|
|
for row in new_dashboard.get('rows', [])]
|
|
new_rows = new_dashboard.get('rows', [])
|
|
for old_row in old_dashboard.get('rows', []):
|
|
if old_row.get('title') not in managed_row_titles:
|
|
new_rows.append(copy.deepcopy(old_row))
|
|
_ensure_pinned_rows(new_dashboard)
|
|
_ensure_panel_ids(new_dashboard)
|
|
|
|
# Update dashboard if it differs
|
|
dashboard_diff = DictDiffer(_cleaned(new_dashboard),
|
|
_cleaned(old_dashboard))
|
|
updated_needed = (dashboard_diff.changed() or
|
|
dashboard_diff.added() or
|
|
dashboard_diff.removed())
|
|
if updated_needed:
|
|
if __opts__['test']:
|
|
ret['result'] = None
|
|
ret['comment'] = ('Dashboard {0} is set to be updated, '
|
|
'changes={1}').format(
|
|
name,
|
|
json.dumps(
|
|
_dashboard_diff(
|
|
_cleaned(new_dashboard),
|
|
_cleaned(old_dashboard)
|
|
),
|
|
indent=4
|
|
))
|
|
return ret
|
|
|
|
response = _update(new_dashboard, profile)
|
|
if response.get('status') == 'success':
|
|
updated_dashboard = _get(url, profile)
|
|
dashboard_diff = DictDiffer(_cleaned(updated_dashboard),
|
|
_cleaned(old_dashboard))
|
|
ret['comment'] = 'Dashboard {0} updated.'.format(name)
|
|
ret['changes'] = _dashboard_diff(_cleaned(new_dashboard),
|
|
_cleaned(old_dashboard))
|
|
else:
|
|
ret['result'] = False
|
|
ret['comment'] = ("Failed to update dashboard {0}, "
|
|
"response={1}").format(name, response)
|
|
return ret
|
|
|
|
ret['comment'] = 'Dashboard present'
|
|
return ret
|
|
|
|
|
|
def absent(name, profile='grafana'):
|
|
'''
|
|
Ensure the named grafana dashboard is absent.
|
|
|
|
name
|
|
Name of the grafana dashboard.
|
|
|
|
profile
|
|
A pillar key or dict that contains grafana information
|
|
'''
|
|
ret = {'name': name, 'result': True, 'comment': '', 'changes': {}}
|
|
|
|
if isinstance(profile, six.string_types):
|
|
profile = __salt__['config.option'](profile)
|
|
|
|
url = 'db/{0}'.format(name)
|
|
existing_dashboard = _get(url, profile)
|
|
if existing_dashboard:
|
|
if __opts__['test']:
|
|
ret['result'] = None
|
|
ret['comment'] = 'Dashboard {0} is set to be deleted.'.format(name)
|
|
return ret
|
|
|
|
_delete(url, profile)
|
|
ret['comment'] = 'Dashboard {0} deleted.'.format(name)
|
|
ret['changes']['new'] = 'Dashboard {0} deleted.'.format(name)
|
|
return ret
|
|
|
|
ret['comment'] = 'Dashboard absent'
|
|
return ret
|
|
|
|
|
|
_IGNORED_DASHBOARD_FIELDS = [
|
|
'id',
|
|
'originalTitle',
|
|
'version',
|
|
]
|
|
_IGNORED_ROW_FIELDS = []
|
|
_IGNORED_PANEL_FIELDS = [
|
|
'grid',
|
|
'mode',
|
|
'tooltip',
|
|
]
|
|
_IGNORED_TARGET_FIELDS = [
|
|
'textEditor',
|
|
]
|
|
|
|
|
|
def _cleaned(_dashboard):
|
|
'''Return a copy without fields that can differ.'''
|
|
dashboard = copy.deepcopy(_dashboard)
|
|
|
|
for ignored_dashboard_field in _IGNORED_DASHBOARD_FIELDS:
|
|
dashboard.pop(ignored_dashboard_field, None)
|
|
for row in dashboard.get('rows', []):
|
|
for ignored_row_field in _IGNORED_ROW_FIELDS:
|
|
row.pop(ignored_row_field, None)
|
|
for i, panel in enumerate(row.get('panels', [])):
|
|
for ignored_panel_field in _IGNORED_PANEL_FIELDS:
|
|
panel.pop(ignored_panel_field, None)
|
|
for target in panel.get('targets', []):
|
|
for ignored_target_field in _IGNORED_TARGET_FIELDS:
|
|
target.pop(ignored_target_field, None)
|
|
row['panels'][i] = _stripped(panel)
|
|
|
|
return dashboard
|
|
|
|
|
|
def _inherited_dashboard(dashboard, base_dashboards_from_pillar, ret):
|
|
'''Return a dashboard with properties from parents.'''
|
|
base_dashboards = []
|
|
for base_dashboard_from_pillar in base_dashboards_from_pillar:
|
|
base_dashboard = __salt__['pillar.get'](base_dashboard_from_pillar)
|
|
if base_dashboard:
|
|
base_dashboards.append(base_dashboard)
|
|
elif base_dashboard_from_pillar != _DEFAULT_DASHBOARD_PILLAR:
|
|
ret.setdefault('warnings', [])
|
|
warning_message = 'Cannot find dashboard pillar "{0}".'.format(
|
|
base_dashboard_from_pillar)
|
|
if warning_message not in ret['warnings']:
|
|
ret['warnings'].append(warning_message)
|
|
base_dashboards.append(dashboard)
|
|
|
|
result_dashboard = {}
|
|
tags = set()
|
|
for dashboard in base_dashboards:
|
|
tags.update(dashboard.get('tags', []))
|
|
result_dashboard.update(dashboard)
|
|
result_dashboard['tags'] = list(tags)
|
|
return result_dashboard
|
|
|
|
|
|
def _inherited_row(row, base_rows_from_pillar, ret):
|
|
'''Return a row with properties from parents.'''
|
|
base_rows = []
|
|
for base_row_from_pillar in base_rows_from_pillar:
|
|
base_row = __salt__['pillar.get'](base_row_from_pillar)
|
|
if base_row:
|
|
base_rows.append(base_row)
|
|
elif base_row_from_pillar != _DEFAULT_ROW_PILLAR:
|
|
ret.setdefault('warnings', [])
|
|
warning_message = 'Cannot find row pillar "{0}".'.format(
|
|
base_row_from_pillar)
|
|
if warning_message not in ret['warnings']:
|
|
ret['warnings'].append(warning_message)
|
|
base_rows.append(row)
|
|
|
|
result_row = {}
|
|
for row in base_rows:
|
|
result_row.update(row)
|
|
return result_row
|
|
|
|
|
|
def _inherited_panel(panel, base_panels_from_pillar, ret):
|
|
'''Return a panel with properties from parents.'''
|
|
base_panels = []
|
|
for base_panel_from_pillar in base_panels_from_pillar:
|
|
base_panel = __salt__['pillar.get'](base_panel_from_pillar)
|
|
if base_panel:
|
|
base_panels.append(base_panel)
|
|
elif base_panel_from_pillar != _DEFAULT_PANEL_PILLAR:
|
|
ret.setdefault('warnings', [])
|
|
warning_message = 'Cannot find panel pillar "{0}".'.format(
|
|
base_panel_from_pillar)
|
|
if warning_message not in ret['warnings']:
|
|
ret['warnings'].append(warning_message)
|
|
base_panels.append(panel)
|
|
|
|
result_panel = {}
|
|
for panel in base_panels:
|
|
result_panel.update(panel)
|
|
return result_panel
|
|
|
|
|
|
_FULL_LEVEL_SPAN = 12
|
|
_DEFAULT_PANEL_SPAN = 2.5
|
|
|
|
|
|
def _auto_adjust_panel_spans(dashboard):
|
|
'''Adjust panel spans to take up the available width.
|
|
|
|
For each group of panels that would be laid out on the same level, scale up
|
|
the unspecified panel spans to fill up the level.
|
|
'''
|
|
for row in dashboard.get('rows', []):
|
|
levels = []
|
|
current_level = []
|
|
levels.append(current_level)
|
|
for panel in row.get('panels', []):
|
|
current_level_span = sum(panel.get('span', _DEFAULT_PANEL_SPAN)
|
|
for panel in current_level)
|
|
span = panel.get('span', _DEFAULT_PANEL_SPAN)
|
|
if current_level_span + span > _FULL_LEVEL_SPAN:
|
|
current_level = [panel]
|
|
levels.append(current_level)
|
|
else:
|
|
current_level.append(panel)
|
|
|
|
for level in levels:
|
|
specified_panels = [panel for panel in level if 'span' in panel]
|
|
unspecified_panels = [panel for panel in level
|
|
if 'span' not in panel]
|
|
if not unspecified_panels:
|
|
continue
|
|
|
|
specified_span = sum(panel['span'] for panel in specified_panels)
|
|
available_span = _FULL_LEVEL_SPAN - specified_span
|
|
auto_span = float(available_span) / len(unspecified_panels)
|
|
for panel in unspecified_panels:
|
|
panel['span'] = auto_span
|
|
|
|
|
|
def _ensure_pinned_rows(dashboard):
|
|
'''Pin rows to the top of the dashboard.'''
|
|
pinned_row_titles = __salt__['pillar.get'](_PINNED_ROWS_PILLAR)
|
|
if not pinned_row_titles:
|
|
return
|
|
|
|
pinned_row_titles_lower = []
|
|
for title in pinned_row_titles:
|
|
pinned_row_titles_lower.append(title.lower())
|
|
rows = dashboard.get('rows', [])
|
|
pinned_rows = []
|
|
for i, row in enumerate(rows):
|
|
if row.get('title', '').lower() in pinned_row_titles_lower:
|
|
del rows[i]
|
|
pinned_rows.append(row)
|
|
rows = pinned_rows + rows
|
|
|
|
|
|
def _ensure_panel_ids(dashboard):
|
|
'''Assign panels auto-incrementing IDs.'''
|
|
panel_id = 1
|
|
for row in dashboard.get('rows', []):
|
|
for panel in row.get('panels', []):
|
|
panel['id'] = panel_id
|
|
panel_id += 1
|
|
|
|
|
|
def _ensure_annotations(dashboard):
|
|
'''Explode annotation_tags into annotations.'''
|
|
if 'annotation_tags' not in dashboard:
|
|
return
|
|
tags = dashboard['annotation_tags']
|
|
annotations = {
|
|
'enable': True,
|
|
'list': [],
|
|
}
|
|
for tag in tags:
|
|
annotations['list'].append({
|
|
'datasource': "graphite",
|
|
'enable': False,
|
|
'iconColor': "#C0C6BE",
|
|
'iconSize': 13,
|
|
'lineColor': "rgba(255, 96, 96, 0.592157)",
|
|
'name': tag,
|
|
'showLine': True,
|
|
'tags': tag,
|
|
})
|
|
del dashboard['annotation_tags']
|
|
dashboard['annotations'] = annotations
|
|
|
|
|
|
def _get(url, profile):
|
|
'''Get a specific dashboard.'''
|
|
request_url = "{0}/api/dashboards/{1}".format(profile.get('grafana_url'),
|
|
url)
|
|
if profile.get('grafana_token', False):
|
|
response = requests.get(
|
|
request_url,
|
|
headers=_get_headers(profile),
|
|
timeout=profile.get('grafana_timeout', 3),
|
|
)
|
|
else:
|
|
response = requests.get(
|
|
request_url,
|
|
auth=_get_auth(profile),
|
|
timeout=profile.get('grafana_timeout', 3),
|
|
)
|
|
data = response.json()
|
|
if data.get('message') == 'Not found':
|
|
return None
|
|
if 'dashboard' not in data:
|
|
return None
|
|
return data['dashboard']
|
|
|
|
|
|
def _delete(url, profile):
|
|
'''Delete a specific dashboard.'''
|
|
request_url = "{0}/api/dashboards/{1}".format(profile.get('grafana_url'),
|
|
url)
|
|
response = requests.delete(
|
|
request_url,
|
|
auth=_get_auth(profile),
|
|
headers=_get_headers(profile),
|
|
timeout=profile.get('grafana_timeout'),
|
|
)
|
|
data = response.json()
|
|
return data
|
|
|
|
|
|
def _update(dashboard, profile):
|
|
'''Update a specific dashboard.'''
|
|
payload = {
|
|
'dashboard': dashboard,
|
|
'overwrite': True
|
|
}
|
|
response = requests.post(
|
|
"{0}/api/dashboards/db".format(profile.get('grafana_url')),
|
|
auth=_get_auth(profile),
|
|
headers=_get_headers(profile),
|
|
json=payload
|
|
)
|
|
return response.json()
|
|
|
|
|
|
def _get_headers(profile):
|
|
headers = {'Content-type': 'application/json'}
|
|
|
|
if profile.get('grafana_token', False):
|
|
headers['Authorization'] = 'Bearer {0}'.format(profile['grafana_token'])
|
|
|
|
return headers
|
|
|
|
|
|
def _get_auth(profile):
|
|
if profile.get('grafana_token', False):
|
|
return None
|
|
|
|
return requests.auth.HTTPBasicAuth(
|
|
profile['grafana_user'],
|
|
profile['grafana_password']
|
|
)
|
|
|
|
|
|
def _dashboard_diff(_new_dashboard, _old_dashboard):
|
|
'''Return a dictionary of changes between dashboards.'''
|
|
diff = {}
|
|
|
|
# Dashboard diff
|
|
new_dashboard = copy.deepcopy(_new_dashboard)
|
|
old_dashboard = copy.deepcopy(_old_dashboard)
|
|
dashboard_diff = DictDiffer(new_dashboard, old_dashboard)
|
|
diff['dashboard'] = _stripped({
|
|
'changed': list(dashboard_diff.changed()) or None,
|
|
'added': list(dashboard_diff.added()) or None,
|
|
'removed': list(dashboard_diff.removed()) or None,
|
|
})
|
|
|
|
# Row diff
|
|
new_rows = new_dashboard.get('rows', [])
|
|
old_rows = old_dashboard.get('rows', [])
|
|
new_rows_by_title = {}
|
|
old_rows_by_title = {}
|
|
for row in new_rows:
|
|
if 'title' in row:
|
|
new_rows_by_title[row['title']] = row
|
|
for row in old_rows:
|
|
if 'title' in row:
|
|
old_rows_by_title[row['title']] = row
|
|
rows_diff = DictDiffer(new_rows_by_title, old_rows_by_title)
|
|
diff['rows'] = _stripped({
|
|
'added': list(rows_diff.added()) or None,
|
|
'removed': list(rows_diff.removed()) or None,
|
|
})
|
|
for changed_row_title in rows_diff.changed():
|
|
old_row = old_rows_by_title[changed_row_title]
|
|
new_row = new_rows_by_title[changed_row_title]
|
|
row_diff = DictDiffer(new_row, old_row)
|
|
diff['rows'].setdefault('changed', {})
|
|
diff['rows']['changed'][changed_row_title] = _stripped({
|
|
'changed': list(row_diff.changed()) or None,
|
|
'added': list(row_diff.added()) or None,
|
|
'removed': list(row_diff.removed()) or None,
|
|
})
|
|
|
|
# Panel diff
|
|
old_panels_by_id = {}
|
|
new_panels_by_id = {}
|
|
for row in old_dashboard.get('rows', []):
|
|
for panel in row.get('panels', []):
|
|
if 'id' in panel:
|
|
old_panels_by_id[panel['id']] = panel
|
|
for row in new_dashboard.get('rows', []):
|
|
for panel in row.get('panels', []):
|
|
if 'id' in panel:
|
|
new_panels_by_id[panel['id']] = panel
|
|
panels_diff = DictDiffer(new_panels_by_id, old_panels_by_id)
|
|
diff['panels'] = _stripped({
|
|
'added': list(panels_diff.added()) or None,
|
|
'removed': list(panels_diff.removed()) or None,
|
|
})
|
|
for changed_panel_id in panels_diff.changed():
|
|
old_panel = old_panels_by_id[changed_panel_id]
|
|
new_panel = new_panels_by_id[changed_panel_id]
|
|
panels_diff = DictDiffer(new_panel, old_panel)
|
|
diff['panels'].setdefault('changed', {})
|
|
diff['panels']['changed'][changed_panel_id] = _stripped({
|
|
'changed': list(panels_diff.changed()) or None,
|
|
'added': list(panels_diff.added()) or None,
|
|
'removed': list(panels_diff.removed()) or None,
|
|
})
|
|
|
|
return diff
|
|
|
|
|
|
def _stripped(d):
|
|
'''Strip falsey entries.'''
|
|
ret = {}
|
|
for k, v in six.items(d):
|
|
if v:
|
|
ret[k] = v
|
|
return ret
|