diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b3d4e4c2b6..fdddc9ba22 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4237,6 +4237,8 @@ class NotificationTemplateSerializer(BaseSerializer): continue if field_type == "password" and field_val == "$encrypted$" and object_actual is not None: attrs['notification_configuration'][field] = object_actual.notification_configuration[field] + if field == "http_method" and field_val.lower() not in ['put', 'post']: + error_list.append(_("HTTP method must be either 'POST' or 'PUT'.")) if missing_fields: error_list.append(_("Missing required fields for Notification Configuration: {}.").format(missing_fields)) if incorrect_type_fields: diff --git a/awx/main/migrations/0082_v360_webhook_http_method.py b/awx/main/migrations/0082_v360_webhook_http_method.py new file mode 100644 index 0000000000..3dc93ff970 --- /dev/null +++ b/awx/main/migrations/0082_v360_webhook_http_method.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +def add_webhook_notification_template_fields(apps, schema_editor): + # loop over all existing webhook notification templates and make + # sure they have the new "http_method" field filled in with "POST" + NotificationTemplate = apps.get_model('main', 'notificationtemplate') + webhooks = NotificationTemplate.objects.filter(notification_type='webhook') + for w in webhooks: + w.notification_configuration['http_method'] = 'POST' + w.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0081_v360_notify_on_start'), + ] + + operations = [ + migrations.RunPython(add_webhook_notification_template_fields, migrations.RunPython.noop), + ] diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index cdec14f953..fc99caf37c 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -118,9 +118,10 @@ class NotificationTemplate(CommonModelNameNotUnique): def send(self, subject, body): for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password", self.notification_class.init_parameters): - self.notification_configuration[field] = decrypt_field(self, - 'notification_configuration', - subfield=field) + if field in self.notification_configuration: + self.notification_configuration[field] = decrypt_field(self, + 'notification_configuration', + subfield=field) recipients = self.notification_configuration.pop(self.notification_class.recipient_parameter) if not isinstance(recipients, list): recipients = [recipients] diff --git a/awx/main/notifications/webhook_backend.py b/awx/main/notifications/webhook_backend.py index 7a68587621..91a6f15118 100644 --- a/awx/main/notifications/webhook_backend.py +++ b/awx/main/notifications/webhook_backend.py @@ -15,14 +15,20 @@ logger = logging.getLogger('awx.main.notifications.webhook_backend') class WebhookBackend(AWXBaseEmailBackend): init_parameters = {"url": {"label": "Target URL", "type": "string"}, + "http_method": {"label": "HTTP Method", "type": "string", "default": "POST"}, "disable_ssl_verification": {"label": "Verify SSL", "type": "bool", "default": False}, + "username": {"label": "Username", "type": "string", "default": ""}, + "password": {"label": "Password", "type": "password", "default": ""}, "headers": {"label": "HTTP Headers", "type": "object"}} recipient_parameter = "url" sender_parameter = None - def __init__(self, headers, disable_ssl_verification=False, fail_silently=False, **kwargs): + def __init__(self, http_method, headers, disable_ssl_verification=False, fail_silently=False, username=None, password=None, **kwargs): + self.http_method = http_method self.disable_ssl_verification = disable_ssl_verification self.headers = headers + self.username = username + self.password = password super(WebhookBackend, self).__init__(fail_silently=fail_silently) def format_body(self, body): @@ -32,8 +38,15 @@ class WebhookBackend(AWXBaseEmailBackend): sent_messages = 0 if 'User-Agent' not in self.headers: self.headers['User-Agent'] = "Tower {}".format(get_awx_version()) + if self.http_method.lower() not in ['put','post']: + raise ValueError("HTTP method must be either 'POST' or 'PUT'.") + chosen_method = getattr(requests, self.http_method.lower(), None) for m in messages: - r = requests.post("{}".format(m.recipients()[0]), + auth = None + if self.username or self.password: + auth = (self.username, self.password) + r = chosen_method("{}".format(m.recipients()[0]), + auth=auth, json=m.body, headers=self.headers, verify=(not self.disable_ssl_verification)) diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index d4ad255e4a..fe61410908 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -117,7 +117,7 @@ def mk_credential(name, credential_type='ssh', persisted=True): def mk_notification_template(name, notification_type='webhook', configuration=None, organization=None, persisted=True): nt = NotificationTemplate(name=name) nt.notification_type = notification_type - nt.notification_configuration = configuration or dict(url="http://localhost", headers={"Test": "Header"}) + nt.notification_configuration = configuration or dict(url="http://localhost", username="", password="", headers={"Test": "Header"}) if organization is not None: nt.organization = organization @@ -216,7 +216,7 @@ def mk_workflow_job_template(name, extra_vars='', spec=None, organization=None, def mk_workflow_job_template_node(workflow_job_template=None, - unified_job_template=None, + unified_job_template=None, success_nodes=None, failure_nodes=None, always_nodes=None, @@ -231,11 +231,11 @@ def mk_workflow_job_template_node(workflow_job_template=None, return workflow_node -def mk_workflow_job_node(unified_job_template=None, +def mk_workflow_job_node(unified_job_template=None, success_nodes=None, failure_nodes=None, always_nodes=None, - workflow_job=None, + workflow_job=None, job=None, persisted=True): workflow_node = WorkflowJobNode(unified_job_template=unified_job_template, @@ -247,4 +247,3 @@ def mk_workflow_job_node(unified_job_template=None, if persisted: workflow_node.save() return workflow_node - diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index c924034bdd..314ef94713 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -385,7 +385,9 @@ def notification_template(organization): organization=organization, notification_type="webhook", notification_configuration=dict(url="http://localhost", - headers={"Test": "Header"})) + username="", + password="", + headers={"Test": "Header",})) @pytest.fixture diff --git a/awx/main/tests/functional/test_notifications.py b/awx/main/tests/functional/test_notifications.py index 576bc71a7a..308e52c2c7 100644 --- a/awx/main/tests/functional/test_notifications.py +++ b/awx/main/tests/functional/test_notifications.py @@ -92,7 +92,7 @@ def test_inherited_notification_templates(get, post, user, organization, project isrc.save() jt = JobTemplate.objects.create(name='test', inventory=i, project=project, playbook='debug.yml') jt.save() - + @pytest.mark.django_db def test_notification_template_simple_patch(patch, notification_template, admin): @@ -124,7 +124,7 @@ def test_custom_environment_injection(post, user, organization): organization=organization.id, notification_type="webhook", notification_configuration=dict(url="https://example.org", disable_ssl_verification=False, - headers={"Test": "Header"})), + http_method="POST", headers={"Test": "Header"})), u) assert response.status_code == 201 template = NotificationTemplate.objects.get(pk=response.data['id']) diff --git a/awx/ui/client/src/notifications/add/add.controller.js b/awx/ui/client/src/notifications/add/add.controller.js index 7093a80680..3197dd310c 100644 --- a/awx/ui/client/src/notifications/add/add.controller.js +++ b/awx/ui/client/src/notifications/add/add.controller.js @@ -87,6 +87,15 @@ export default ['Rest', 'Wait', 'NotificationsFormObject', element: '#notification_template_color', multiple: false }); + + $scope.httpMethodChoices = [ + {'id': 'POST', 'name': i18n._('POST')}, + {'id': 'PUT', 'name': i18n._('PUT')}, + ]; + CreateSelect2({ + element: '#notification_template_http_method', + multiple: false, + }); }); $scope.$watch('headers', function validate_headers(str) { diff --git a/awx/ui/client/src/notifications/edit/edit.controller.js b/awx/ui/client/src/notifications/edit/edit.controller.js index a90d025953..b8470e3c22 100644 --- a/awx/ui/client/src/notifications/edit/edit.controller.js +++ b/awx/ui/client/src/notifications/edit/edit.controller.js @@ -138,6 +138,16 @@ export default ['Rest', 'Wait', element: '#notification_template_color', multiple: false }); + + $scope.httpMethodChoices = [ + {'id': 'POST', 'name': i18n._('POST')}, + {'id': 'PUT', 'name': i18n._('PUT')}, + ]; + CreateSelect2({ + element: '#notification_template_http_method', + multiple: false, + }); + NotificationsTypeChange.getDetailFields($scope.notification_type.value).forEach(function(field) { $scope[field[0]] = field[1]; }); diff --git a/awx/ui/client/src/notifications/main.js b/awx/ui/client/src/notifications/main.js index c1a2a559be..138862bbef 100644 --- a/awx/ui/client/src/notifications/main.js +++ b/awx/ui/client/src/notifications/main.js @@ -68,8 +68,12 @@ angular.module('notifications', [ var url = getBasePath('notification_templates') + notificationTemplateId + '/'; rest.setUrl(url); return rest.get() - .then(function(data) { - return data.data; + .then(function(res) { + if (_.get(res, ['data', 'notification_type'] === 'webhook') && + _.get(res, ['data', 'notification_configuration', 'http_method'])) { + res.data.notification_configuration.http_method = res.data.notification_configuration.http_method.toUpperCase(); + } + return res.data; }).catch(function(response) { ProcessErrors(null, response.data, response.status, null, { hdr: 'Error!', diff --git a/awx/ui/client/src/notifications/notificationTemplates.form.js b/awx/ui/client/src/notifications/notificationTemplates.form.js index fa291d7654..15b8629f1d 100644 --- a/awx/ui/client/src/notifications/notificationTemplates.form.js +++ b/awx/ui/client/src/notifications/notificationTemplates.form.js @@ -63,7 +63,7 @@ export default ['i18n', function(i18n) { username: { label: i18n._('Username'), type: 'text', - ngShow: "notification_type.value == 'email' ", + ngShow: "notification_type.value == 'email' || notification_type.value == 'webhook' ", subForm: 'typeSubForm', ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)' }, @@ -75,7 +75,7 @@ export default ['i18n', function(i18n) { reqExpression: "password_required" , init: "false" }, - ngShow: "notification_type.value == 'email' || notification_type.value == 'irc' ", + ngShow: "notification_type.value == 'email' || notification_type.value == 'irc' || notification_type.value == 'webhook' ", subForm: 'typeSubForm', ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)' }, @@ -423,6 +423,21 @@ export default ['i18n', function(i18n) { subForm: 'typeSubForm', ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)' }, + http_method: { + label: i18n._('HTTP Method'), + dataTitle: i18n._('HTTP Method'), + type: 'select', + ngOptions: 'choice.id as choice.name for choice in httpMethodChoices', + default: 'post', + awPopOver: i18n._('Specify an HTTP method for the webhook. Acceptable choices are: POST or PUT'), + awRequiredWhen: { + reqExpression: "webhook_required", + init: "false" + }, + ngShow: "notification_type.value == 'webhook' ", + subForm: 'typeSubForm', + ngDisabled: '!(notification_template.summary_fields.user_capabilities.edit || canAdd)' + }, mattermost_url: { label: i18n._('Target URL'), type: 'text', diff --git a/awx/ui/client/src/notifications/shared/type-change.service.js b/awx/ui/client/src/notifications/shared/type-change.service.js index 5695a4ca10..86e2baa35b 100644 --- a/awx/ui/client/src/notifications/shared/type-change.service.js +++ b/awx/ui/client/src/notifications/shared/type-change.service.js @@ -53,6 +53,8 @@ function (i18n) { break; case 'webhook': obj.webhook_required = true; + obj.passwordLabel = ' ' + i18n._('Basic Auth Password'); + obj.password_required = false; break; case 'mattermost': obj.mattermost_required = true; diff --git a/docs/notification_system.md b/docs/notification_system.md index 7ef17d2cef..65b05607b3 100644 --- a/docs/notification_system.md +++ b/docs/notification_system.md @@ -218,6 +218,14 @@ https://gist.github.com/matburt/73bfbf85c2443f39d272 The link below shows how to define an endpoint and parse headers and json content. It doesn't show how to configure Flask for HTTPS, but is fairly straightforward: http://flask.pocoo.org/snippets/111/ +You can also link an `httpbin` service to the development environment for testing webhooks using: + +``` +docker run --network="tools_default" --name httpbin -p 8204:80 kennethreitz/httpbin +``` + +This will create an `httpbin` service reachable from the AWX container at `http://httpbin/post`, `http://httpbin/put`, etc. Outside of the container, you can reach the service at `http://localhost:8204`. + ## Grafana