From 76999747e9d78c76670aab44638bf4a5e3a029e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20=C3=81lvaro?= Date: Mon, 18 Nov 2019 08:14:17 +0100 Subject: [PATCH] feat: Merge slack webhook returner from develop branch --- salt/returners/slack_webhook_return.py | 338 ++++++++++++++++++ .../returners/test_slack_webhook_return.py | 153 ++++++++ 2 files changed, 491 insertions(+) create mode 100644 salt/returners/slack_webhook_return.py create mode 100644 tests/unit/returners/test_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..aad1cdf656ab --- /dev/null +++ b/salt/returners/slack_webhook_return.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +''' +Return salt data via Slack using Incoming Webhooks + +:codeauthor: `Carlos D. Álvaro ` + +The following fields can be set in the minion conf file: + +.. code-block:: none + + 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:: none + + 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:: none + + 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 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 + +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: {unchanged}'.format(unchanged=report['unchanged'].get('counter', None)) + } + + changed = { + 'color': 'warning', + 'title': 'Changed: {changed}'.format(changed=report['changed'].get('counter', None)) + } + + if report['changed'].get('tasks'): + changed['fields'] = list( + map(_format_task, report['changed'].get('tasks'))) + + failed = { + 'color': 'danger', + 'title': 'Failed: {failed}'.format(failed=report['failed'].get('counter', None)) + } + + if report['failed'].get('tasks'): + failed['fields'] = list( + map(_format_task, report['failed'].get('tasks'))) + + text = 'Function: {function}\n'.format(function=report.get('function')) + if report.get('arguments'): + text += 'Function Args: {arguments}\n'.format( + arguments=str(list(map(str, report.get('arguments'))))) + + 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': [ + { + 'fallback': title, + 'color': "#272727", + 'author_name': _sprinkle('{id}'), + 'author_link': _sprinkle('{localhost}'), + 'author_icon': author_icon, + 'title': 'Success: {success}'.format(success=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 = '{filename}.sls | {taskname}'.format( + filename=str(data.get('__sls__')), taskname=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/{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: + return True + else: + log.error('Slack incoming webhook 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', None) + show_tasks = _options.get('show_tasks') + author_icon = _options.get('author_icon') + + if not webhook or webhook is '': + 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 diff --git a/tests/unit/returners/test_slack_webhook_return.py b/tests/unit/returners/test_slack_webhook_return.py new file mode 100644 index 000000000000..68c33c58442d --- /dev/null +++ b/tests/unit/returners/test_slack_webhook_return.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +''' + :codeauthor: :email:`Carlos D. Álvaro ` + + tests.unit.returners.test_slack_webhook_return + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Unit tests for the Slack Webhook Returner. +''' + +# Import Python libs +from __future__ import absolute_import + +# Import Salt Testing libs +from tests.support.mixins import LoaderModuleMockMixin +from tests.support.unit import TestCase, skipIf +from tests.support.mock import NO_MOCK, NO_MOCK_REASON, patch + +# Import Salt libs +import salt.returners.slack_webhook_return as slack_webhook + + +@skipIf(NO_MOCK, NO_MOCK_REASON) +class SlackWebhookReturnerTestCase(TestCase, LoaderModuleMockMixin): + ''' + Test slack_webhook returner + ''' + _WEBHOOK = 'T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' + _AUTHOR_ICON = 'https://platform.slack-edge.com/img/default_application_icon.png' + _SHOW_TASKS = True + _MINION_NAME = 'MacPro' + + _RET = { + 'fun_args': ['config.vim'], + 'jid': '20181227105933129338', + 'return': + {'file_|-vim files present_|-/Users/cdalvaro/_|-recurse': + {'comment': 'The directory /Users/cdalvaro/ is in the correct state', + 'pchanges': {}, + 'name': '/Users/cdalvaro/', + 'start_time': '10:59:52.252830', + 'result': True, + 'duration': 373.25, + '__run_num__': 3, + '__sls__': 'config.vim', + 'changes': {}, + '__id__': 'vim files present'}, + 'pkg_|-vim present_|-vim_|-installed': + {'comment': 'All specified packages are already installed', + 'name': 'vim', + 'start_time': '10:59:36.830591', + 'result': True, + 'duration': 1280.127, + '__run_num__': 0, + '__sls__': 'config.vim', + 'changes': {}, + '__id__': 'vim present'}, + 'git_|-salt vim plugin updated_|-https://github.com/saltstack/salt-vim.git_|-latest': + {'comment': 'https://github.com/saltstack/salt-vim.git cloned to /Users/cdalvaro/.vim/pack/git-plugins/start/salt', + 'name': 'https://github.com/saltstack/salt-vim.git', + 'start_time': '11:00:01.892757', + 'result': True, + 'duration': 11243.445, + '__run_num__': 6, + '__sls__': 'config.vim', + 'changes': + {'new': 'https://github.com/saltstack/salt-vim.git => /Users/cdalvaro/.vim/pack/git-plugins/start/salt', + 'revision': {'new': '6ca9e3500cc39dd417b411435d58a1b720b331cc', 'old': None}}, + '__id__': 'salt vim plugin updated'}, + 'pkg_|-macvim present_|-caskroom/cask/macvim_|-installed': + {'comment': 'The following packages failed to install/update: caskroom/cask/macvim', + 'name': 'caskroom/cask/macvim', + 'start_time': '10:59:38.111119', + 'result': False, + 'duration': 14135.45, + '__run_num__': 1, + '__sls__': 'config.vim', + 'changes': {}, + '__id__': 'macvim present'}}, + 'retcode': 2, + 'success': True, + 'fun': 'state.apply', + 'id': _MINION_NAME, + 'out': 'highstate' + } + + _EXPECTED_PAYLOAD = { + u'attachments': [ + {u'title': u'Success: False', + u'color': u'#272727', + u'text': u"Function: state.apply\nFunction Args: ['config.vim']\nJID: 20181227105933129338\nTotal: 4\nDuration: 27.03 secs", + u'author_link': u'{}'.format(_MINION_NAME), + u'author_name': u'{}'.format(_MINION_NAME), + u'fallback': u'{} | Failed'.format(_MINION_NAME), + u'author_icon': _AUTHOR_ICON}, + {u'color': u'good', + u'title': u'Unchanged: 2'}, + {u'color': u'warning', + u'fields': [ + {u'short': False, + u'value': u'config.vim.sls | salt vim plugin updated'} + ], + u'title': u'Changed: 1'}, + {u'color': u'danger', + u'fields': [ + {u'short': False, + u'value': u'config.vim.sls | macvim present'} + ], + u'title': u'Failed: 1'} + ] + } + + def setup_loader_modules(self): + return {slack_webhook: {'__opts__': { + 'slack_webhook.webhook': self._WEBHOOK, + 'slack_webhook.author_icon': self._AUTHOR_ICON, + 'slack_webhook.success_title': '{id} | Succeeded', + 'slack_webhook.failure_title': '{id} | Failed', + 'slack_webhook.show_tasks': self._SHOW_TASKS + }}} + + def test_no_webhook(self): + ''' + Test returner stops if no webhook is defined + ''' + with patch.dict(slack_webhook.__opts__, {'slack_webhook.webhook': ''}): + self.assertEqual(slack_webhook.returner(self._RET), None) + + def test_returner(self): + ''' + Test to see if the Slack Webhook returner sends a message + ''' + query_ret = {'body': 'ok', 'status': 200} + with patch('salt.utils.http.query', return_value=query_ret): + self.assertTrue(slack_webhook.returner(self._RET)) + + def test_generate_payload(self): + ''' + Test _generate_payload private method + ''' + test_title = '{} | Failed'.format(self._MINION_NAME) + test_report = slack_webhook._generate_report( + self._RET, self._SHOW_TASKS) + + custom_grains = slack_webhook.__grains__ + custom_grains['id'] = self._MINION_NAME + custom_grains['localhost'] = self._MINION_NAME + + with patch.dict(slack_webhook.__grains__, custom_grains): + test_payload = slack_webhook._generate_payload( + self._AUTHOR_ICON, test_title, test_report) + + self.assertDictEqual(test_payload, self._EXPECTED_PAYLOAD)