From 1141d55baef1c065054bb3c9646eaae73d01fbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20=C3=81lvaro?= Date: Sun, 16 Dec 2018 19:54:38 +0100 Subject: [PATCH 1/5] Add new slack returner based on webhooks --- salt/returners/slack_webhook_return.py | 333 +++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 salt/returners/slack_webhook_return.py diff --git a/salt/returners/slack_webhook_return.py b/salt/returners/slack_webhook_return.py new file mode 100644 index 000000000000..c16482a2bc66 --- /dev/null +++ b/salt/returners/slack_webhook_return.py @@ -0,0 +1,333 @@ +# -*- coding: utf-8 -*- +''' +Return salt data via slack + +.. versionadded:: 2018.3.4 + +The following fields can be set in the minion conf file: + +.. code-block:: yaml + + slack_webhook.webhook (required, the webhook id. Just the part after: 'https://hooks.slack.com/services/') + slack_webhook.success_title (optional, short title for succeeded states. By default: '{id} | Succeeded') + slack_webhook.failure_title (optional, short title for failed states. By default: '{id} | Failed') + slack_webhook.author_icon (optional, a URL that with a small 16x16px image. Must be of type: GIF, JPEG, PNG, and BMP) + slack_webhook.show_tasks (optional, show identifiers for changed and failed tasks. By default: False) + +Alternative configuration values can be used by prefacing the configuration. +Any values not found in the alternative configuration will be pulled from +the default location: + +.. code-block:: yaml + + slack_webhook.webhook + slack_webhook.success_title + slack_webhook.failure_title + slack_webhook.author_icon + slack_webhook.show_tasks + +Slack settings may also be configured as: + +.. code-block:: yaml + + slack_webhook: + webhook: T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX + success_title: [{id}] | Success + failure_title: [{id}] | Failure + author_icon: https://platform.slack-edge.com/img/default_application_icon.png + show_tasks: true + + alternative.slack_webhook: + webhook: T00000000/C00000000/YYYYYYYYYYYYYYYYYYYYYYYY + show_tasks: false + +To use the Slack returner, append '--return slack_webhook' to the salt command. + +.. code-block:: bash + + salt '*' test.ping --return slack_webhook + +To use the alternative configuration, append '--return_config alternative' to the salt command. + +.. code-block:: bash + + salt '*' test.ping --return slack_webhook --return_config alternative + +''' +from __future__ import absolute_import, print_function, unicode_literals + +# Import Python libs +import pprint +import logging + +# pylint: disable=import-error,no-name-in-module,redefined-builtin +import salt.ext.six.moves.http_client +from salt.ext.six.moves.urllib.parse import urlencode as _urlencode +from salt.ext import six +# pylint: enable=import-error,no-name-in-module,redefined-builtin + +# Import Salt Libs +import salt.returners +import salt.utils.http +import salt.utils.yaml +import json + +log = logging.getLogger(__name__) + +__virtualname__ = 'slack_webhook' + + +def _get_options(ret=None): + ''' + Get the slack_webhook options from salt. + :param ret: Salt return dictionary + :return: A dictionary with options + ''' + + defaults = { + 'success_title': '{id} | Succeeded', + 'failure_title': '{id} | Failed', + 'author_icon': '', + 'show_tasks': False + } + + attrs = { + 'webhook': 'webhook', + 'success_title': 'success_title', + 'failure_title': 'failure_title', + 'author_icon': 'author_icon', + 'show_tasks': 'show_tasks' + } + + _options = salt.returners.get_returner_options(__virtualname__, + ret, + attrs, + __salt__=__salt__, + __opts__=__opts__, + defaults=defaults) + return _options + + +def __virtual__(): + ''' + Return virtual name of the module. + + :return: The virtual name of the module. + ''' + return __virtualname__ + + +def _sprinkle(config_str): + ''' + Sprinkle with grains of salt, that is + convert 'test {id} test {host} ' types of strings + :param config_str: The string to be sprinkled + :return: The string sprinkled + ''' + parts = [x for sub in config_str.split('{') for x in sub.split('}')] + for i in range(1, len(parts), 2): + parts[i] = six.text_type(__grains__.get(parts[i], '')) + return ''.join(parts) + + +def _format_task(task): + ''' + Return a dictionary with the task ready for slack fileds + :param task: The name of the task + + :return: A dictionary ready to be inserted in Slack fields array + ''' + return {'value': task, 'short': False} + + +def _generate_payload(author_icon, title, report): + ''' + Prepare the payload for Slack + :param author_icon: The url for the thumbnail to be displayed + :param title: The title of the message + :param report: A dictionary with the report of the Salt function + :return: The payload ready for Slack + ''' + + title = _sprinkle(title) + + unchanged = { + 'color': 'good', + 'title': 'Unchanged: ' + str(report['unchanged'].get('counter', None)) + } + + changed = { + 'color': 'warning', + 'title': 'Changed: ' + str(report['changed'].get('counter', None)) + } + + if len(report['changed'].get('tasks', [])) > 0: + changed['fields'] = map(_format_task, report['changed'].get('tasks')) + + failed = { + 'color': 'danger', + 'title': 'Failed: ' + str(report['failed'].get('counter', None)) + } + + if len(report['failed'].get('tasks', [])) > 0: + failed['fields'] = map(_format_task, report['failed'].get('tasks')) + + text = "Function: %s\n" % (report.get('function')) + if len(report.get('arguments', [])) > 0: + text += 'Function Args: ' + \ + str(map(str, report.get('arguments'))) + '\n' + + text += "JID: %s\n" % (report.get('jid')) + text += "Total: %s\n" % (report.get('total')) + text += "Duration: %0.2f secs" % (float(report.get('duration', None))) + + payload = { + 'attachments': [ + { + 'fallback': title, + 'color': "#272727", + 'author_name': _sprinkle('{id}'), + 'author_link': _sprinkle('{localhost}'), + 'author_icon': author_icon, + 'title': "Success: %s" % (str(report.get('success'))), + 'text': text + }, + unchanged, + changed, + failed + ] + } + + return payload + + +def _generate_report(ret, show_tasks): + ''' + Generate a report of the Salt function + :param ret: The Salt return + :param show_tasks: Flag to show the name of the changed and failed states + :return: The report + ''' + + returns = ret.get('return') + + sorted_data = sorted( + returns.items(), + key=lambda s: s[1].get('__run_num__', 0) + ) + + total = 0 + failed = 0 + changed = 0 + duration = 0.0 + + changed_tasks = [] + failed_tasks = [] + + # gather stats + for state, data in sorted_data: + # state: module, stateid, name, function + _, stateid, _, _ = state.split('_|-') + task = "%s.sls | %s" % (str(data.get('__sls__')), stateid) + + if not data.get('result', True): + failed += 1 + failed_tasks.append(task) + + if data.get('changes', {}): + changed += 1 + changed_tasks.append(task) + + total += 1 + try: + duration += float(data.get('duration', 0.0)) + except ValueError: + pass + + unchanged = total - failed - changed + + log.debug('%s total: %s', __virtualname__, total) + log.debug('%s failed: %s', __virtualname__, failed) + log.debug('%s unchanged: %s', __virtualname__, unchanged) + log.debug('%s changed: %s', __virtualname__, changed) + + report = { + 'id': ret.get('id'), + 'success': True if failed == 0 else False, + 'total': total, + 'function': ret.get('fun'), + 'arguments': ret.get('fun_args', []), + 'jid': ret.get('jid'), + 'duration': duration / 1000, + 'unchanged': { + 'counter': unchanged + }, + 'changed': { + 'counter': changed, + 'tasks': changed_tasks if show_tasks else [] + }, + 'failed': { + 'counter': failed, + 'tasks': failed_tasks if show_tasks else [] + } + } + + return report + + +def _post_message(webhook, author_icon, title, report): + ''' + Send a message to a Slack room through a webhook + :param webhook: The url of the incoming webhook + :param author_icon: The thumbnail image to be displayed on the right side of the message + :param title: The title of the message + :param report: The report of the function state + :return: Boolean if message was sent successfully + ''' + + payload = _generate_payload(author_icon, title, report) + + data = _urlencode({ + 'payload': json.dumps(payload, ensure_ascii=False) + }) + + webhook_url = "https://hooks.slack.com/services/%s" % (webhook) + query_result = salt.utils.http.query(webhook_url, 'POST', data=data) + + if query_result['body'] == 'ok' or query_result['status'] <= 201: + return True + else: + log.error('Slack message post result: %s', query_result) + return { + 'res': False, + 'message': query_result.get('body', query_result['status']) + } + + +def returner(ret): + ''' + Send a slack message with the data through a webhook + :param ret: The Salt return + :return: The result of the post + ''' + + _options = _get_options(ret) + + webhook = _options.get('webhook') + show_tasks = _options.get('show_tasks') + author_icon = _options.get('author_icon') + + if not webhook: + log.error('%s.webhook not defined in salt config', __virtualname__) + return + + report = _generate_report(ret, show_tasks) + + if report.get('success'): + title = _options.get('success_title') + else: + title = _options.get('failure_title') + + slack = _post_message(webhook, author_icon, title, report) + + return slack From 4af5dd2ba985632f010250f67b7f7220e32339c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20=C3=81lvaro?= Date: Sun, 16 Dec 2018 20:25:49 +0100 Subject: [PATCH 2/5] Fix import warnings --- salt/returners/slack_webhook_return.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/salt/returners/slack_webhook_return.py b/salt/returners/slack_webhook_return.py index c16482a2bc66..0741df662a57 100644 --- a/salt/returners/slack_webhook_return.py +++ b/salt/returners/slack_webhook_return.py @@ -57,20 +57,21 @@ from __future__ import absolute_import, print_function, unicode_literals # Import Python libs -import pprint import logging +import json # pylint: disable=import-error,no-name-in-module,redefined-builtin import salt.ext.six.moves.http_client from salt.ext.six.moves.urllib.parse import urlencode as _urlencode from salt.ext import six +from salt.ext.six.moves import map +from salt.ext.six.moves import range # pylint: enable=import-error,no-name-in-module,redefined-builtin # Import Salt Libs import salt.returners import salt.utils.http import salt.utils.yaml -import json log = logging.getLogger(__name__) From 222ab676645ce8620b3390193311b931d4523c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20=C3=81lvaro?= Date: Sun, 16 Dec 2018 20:26:36 +0100 Subject: [PATCH 3/5] Fix string interpolation warnings --- salt/returners/slack_webhook_return.py | 32 +++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/salt/returners/slack_webhook_return.py b/salt/returners/slack_webhook_return.py index 0741df662a57..057fc4a89468 100644 --- a/salt/returners/slack_webhook_return.py +++ b/salt/returners/slack_webhook_return.py @@ -154,33 +154,36 @@ def _generate_payload(author_icon, title, report): unchanged = { 'color': 'good', - 'title': 'Unchanged: ' + str(report['unchanged'].get('counter', None)) + 'title': 'Unchanged: {unchanged}'.format(unchanged=report['unchanged'].get('counter', None)) } changed = { 'color': 'warning', - 'title': 'Changed: ' + str(report['changed'].get('counter', None)) + 'title': 'Changed: {changed}'.format(changed=report['changed'].get('counter', None)) } if len(report['changed'].get('tasks', [])) > 0: - changed['fields'] = map(_format_task, report['changed'].get('tasks')) + changed['fields'] = list( + map(_format_task, report['changed'].get('tasks'))) failed = { 'color': 'danger', - 'title': 'Failed: ' + str(report['failed'].get('counter', None)) + 'title': 'Failed: {failed}'.format(failed=report['failed'].get('counter', None)) } if len(report['failed'].get('tasks', [])) > 0: - failed['fields'] = map(_format_task, report['failed'].get('tasks')) + failed['fields'] = list( + map(_format_task, report['failed'].get('tasks'))) - text = "Function: %s\n" % (report.get('function')) + text = 'Function: {function}\n'.format(function=report.get('function')) if len(report.get('arguments', [])) > 0: - text += 'Function Args: ' + \ - str(map(str, report.get('arguments'))) + '\n' + text += 'Function Args: {arguments}\n'.format( + arguments=str(list(map(str, report.get('arguments'))))) - text += "JID: %s\n" % (report.get('jid')) - text += "Total: %s\n" % (report.get('total')) - text += "Duration: %0.2f secs" % (float(report.get('duration', None))) + text += 'JID: {jid}\n'.format(jid=report.get('jid')) + text += 'Total: {total}\n'.format(total=report.get('total')) + text += 'Duration: {duration:.2f} secs'.format( + duration=float(report.get('duration'))) payload = { 'attachments': [ @@ -190,7 +193,7 @@ def _generate_payload(author_icon, title, report): 'author_name': _sprinkle('{id}'), 'author_link': _sprinkle('{localhost}'), 'author_icon': author_icon, - 'title': "Success: %s" % (str(report.get('success'))), + 'title': 'Success: {success}'.format(success=str(report.get('success'))), 'text': text }, unchanged, @@ -229,7 +232,8 @@ def _generate_report(ret, show_tasks): for state, data in sorted_data: # state: module, stateid, name, function _, stateid, _, _ = state.split('_|-') - task = "%s.sls | %s" % (str(data.get('__sls__')), stateid) + task = '{filename}.sls | {taskname}'.format( + filename=str(data.get('__sls__')), taskname=stateid) if not data.get('result', True): failed += 1 @@ -298,7 +302,7 @@ def _post_message(webhook, author_icon, title, report): if query_result['body'] == 'ok' or query_result['status'] <= 201: return True else: - log.error('Slack message post result: %s', query_result) + log.error('Slack incoming webhook message post result: %s', query_result) return { 'res': False, 'message': query_result.get('body', query_result['status']) From a746a9ce38712c39192da2cbe079948b433ad3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20=C3=81lvaro?= Date: Sun, 16 Dec 2018 20:41:57 +0100 Subject: [PATCH 4/5] Fix one more string interpolation --- salt/returners/slack_webhook_return.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/returners/slack_webhook_return.py b/salt/returners/slack_webhook_return.py index 057fc4a89468..057014618279 100644 --- a/salt/returners/slack_webhook_return.py +++ b/salt/returners/slack_webhook_return.py @@ -296,7 +296,7 @@ def _post_message(webhook, author_icon, title, report): 'payload': json.dumps(payload, ensure_ascii=False) }) - webhook_url = "https://hooks.slack.com/services/%s" % (webhook) + webhook_url = 'https://hooks.slack.com/services/{webhook}'.format(webhook) query_result = salt.utils.http.query(webhook_url, 'POST', data=data) if query_result['body'] == 'ok' or query_result['status'] <= 201: From 1e25c058d694ad20bc36dd8b46e99cecc05306e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20=C3=81lvaro?= Date: Sun, 16 Dec 2018 20:45:38 +0100 Subject: [PATCH 5/5] Bugfix: webhook string interpolation --- salt/returners/slack_webhook_return.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/salt/returners/slack_webhook_return.py b/salt/returners/slack_webhook_return.py index 057014618279..fe6277d3b8cd 100644 --- a/salt/returners/slack_webhook_return.py +++ b/salt/returners/slack_webhook_return.py @@ -296,7 +296,7 @@ def _post_message(webhook, author_icon, title, report): 'payload': json.dumps(payload, ensure_ascii=False) }) - webhook_url = 'https://hooks.slack.com/services/{webhook}'.format(webhook) + webhook_url = 'https://hooks.slack.com/services/{webhook}'.format(webhook=webhook) query_result = salt.utils.http.query(webhook_url, 'POST', data=data) if query_result['body'] == 'ok' or query_result['status'] <= 201: