diff --git a/awx/api/permissions.py b/awx/api/permissions.py index 34ee7f76fb..ecaabc4b91 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -249,3 +249,8 @@ class InstanceGroupTowerPermission(ModelAccessPermission): if request.method == 'DELETE' and obj.name == "tower": return False return super(InstanceGroupTowerPermission, self).has_object_permission(request, view, obj) + + +class WebhookKeyPermission(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + return request.user.can_access(view.model, 'admin', obj, request.data) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 8100a78114..86dda8bb7e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -139,6 +139,7 @@ SUMMARIZABLE_FK_FIELDS = { 'insights_credential': DEFAULT_SUMMARY_FIELDS, 'source_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'), 'target_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud', 'credential_type_id'), + 'webhook_credential': DEFAULT_SUMMARY_FIELDS, } @@ -2826,6 +2827,25 @@ class JobTemplateMixin(object): d['recent_jobs'] = self._recent_jobs(obj) return d + def validate(self, attrs): + webhook_service = attrs.get('webhook_service', getattr(self.instance, 'webhook_service', None)) + webhook_credential = attrs.get('webhook_credential', getattr(self.instance, 'webhook_credential', None)) + + if webhook_credential: + if webhook_credential.credential_type.kind != 'token': + raise serializers.ValidationError({ + 'webhook_credential': _("Must be a Personal Access Token."), + }) + + msg = {'webhook_credential': _("Must match the selected webhook service.")} + if webhook_service: + if webhook_credential.credential_type.namespace != '{}_token'.format(webhook_service): + raise serializers.ValidationError(msg) + else: + raise serializers.ValidationError(msg) + + return super().validate(attrs) + class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobOptionsSerializer): show_capabilities = ['start', 'schedule', 'copy', 'edit', 'delete'] @@ -2838,30 +2858,39 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO class Meta: model = JobTemplate - fields = ('*', 'host_config_key', 'ask_scm_branch_on_launch', 'ask_diff_mode_on_launch', 'ask_variables_on_launch', - 'ask_limit_on_launch', 'ask_tags_on_launch', - 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch', 'ask_inventory_on_launch', - 'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'diff_mode', - 'allow_simultaneous', 'custom_virtualenv', 'job_slice_count') + fields = ( + '*', 'host_config_key', 'ask_scm_branch_on_launch', 'ask_diff_mode_on_launch', + 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch', + 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch', + 'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled', + 'become_enabled', 'diff_mode', 'allow_simultaneous', 'custom_virtualenv', + 'job_slice_count', 'webhook_service', 'webhook_credential', + ) def get_related(self, obj): res = super(JobTemplateSerializer, self).get_related(obj) - res.update(dict( - jobs = self.reverse('api:job_template_jobs_list', kwargs={'pk': obj.pk}), - schedules = self.reverse('api:job_template_schedules_list', kwargs={'pk': obj.pk}), - activity_stream = self.reverse('api:job_template_activity_stream_list', kwargs={'pk': obj.pk}), - launch = self.reverse('api:job_template_launch', kwargs={'pk': obj.pk}), - notification_templates_started = self.reverse('api:job_template_notification_templates_started_list', kwargs={'pk': obj.pk}), - notification_templates_success = self.reverse('api:job_template_notification_templates_success_list', kwargs={'pk': obj.pk}), - notification_templates_error = self.reverse('api:job_template_notification_templates_error_list', kwargs={'pk': obj.pk}), - access_list = self.reverse('api:job_template_access_list', kwargs={'pk': obj.pk}), - survey_spec = self.reverse('api:job_template_survey_spec', kwargs={'pk': obj.pk}), - labels = self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}), - object_roles = self.reverse('api:job_template_object_roles_list', kwargs={'pk': obj.pk}), - instance_groups = self.reverse('api:job_template_instance_groups_list', kwargs={'pk': obj.pk}), - slice_workflow_jobs = self.reverse('api:job_template_slice_workflow_jobs_list', kwargs={'pk': obj.pk}), - copy = self.reverse('api:job_template_copy', kwargs={'pk': obj.pk}), - )) + res.update( + jobs=self.reverse('api:job_template_jobs_list', kwargs={'pk': obj.pk}), + schedules=self.reverse('api:job_template_schedules_list', kwargs={'pk': obj.pk}), + activity_stream=self.reverse('api:job_template_activity_stream_list', kwargs={'pk': obj.pk}), + launch=self.reverse('api:job_template_launch', kwargs={'pk': obj.pk}), + webhook_key=self.reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': obj.pk}), + webhook_receiver=( + self.reverse('api:webhook_receiver_{}'.format(obj.webhook_service), + kwargs={'model_kwarg': 'job_templates', 'pk': obj.pk}) + if obj.webhook_service else '' + ), + notification_templates_started=self.reverse('api:job_template_notification_templates_started_list', kwargs={'pk': obj.pk}), + notification_templates_success=self.reverse('api:job_template_notification_templates_success_list', kwargs={'pk': obj.pk}), + notification_templates_error=self.reverse('api:job_template_notification_templates_error_list', kwargs={'pk': obj.pk}), + access_list=self.reverse('api:job_template_access_list', kwargs={'pk': obj.pk}), + survey_spec=self.reverse('api:job_template_survey_spec', kwargs={'pk': obj.pk}), + labels=self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}), + object_roles=self.reverse('api:job_template_object_roles_list', kwargs={'pk': obj.pk}), + instance_groups=self.reverse('api:job_template_instance_groups_list', kwargs={'pk': obj.pk}), + slice_workflow_jobs=self.reverse('api:job_template_slice_workflow_jobs_list', kwargs={'pk': obj.pk}), + copy=self.reverse('api:job_template_copy', kwargs={'pk': obj.pk}), + ) if obj.host_config_key: res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk}) return res @@ -2889,7 +2918,6 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO def validate_extra_vars(self, value): return vars_validate_or_raise(value) - def get_summary_fields(self, obj): summary_fields = super(JobTemplateSerializer, self).get_summary_fields(obj) all_creds = [] @@ -2930,9 +2958,11 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): class Meta: model = Job - fields = ('*', 'job_template', 'passwords_needed_to_start', - 'allow_simultaneous', 'artifacts', 'scm_revision', - 'instance_group', 'diff_mode', 'job_slice_number', 'job_slice_count') + fields = ( + '*', 'job_template', 'passwords_needed_to_start', 'allow_simultaneous', + 'artifacts', 'scm_revision', 'instance_group', 'diff_mode', 'job_slice_number', + 'job_slice_count', 'webhook_service', 'webhook_credential', 'webhook_guid', + ) def get_related(self, obj): res = super(JobSerializer, self).get_related(obj) @@ -3320,16 +3350,25 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo class Meta: model = WorkflowJobTemplate - fields = ('*', 'extra_vars', 'organization', 'survey_enabled', 'allow_simultaneous', - 'ask_variables_on_launch', 'inventory', 'limit', 'scm_branch', - 'ask_inventory_on_launch', 'ask_scm_branch_on_launch', 'ask_limit_on_launch',) + fields = ( + '*', 'extra_vars', 'organization', 'survey_enabled', 'allow_simultaneous', + 'ask_variables_on_launch', 'inventory', 'limit', 'scm_branch', + 'ask_inventory_on_launch', 'ask_scm_branch_on_launch', 'ask_limit_on_launch', + 'webhook_service', 'webhook_credential', + ) def get_related(self, obj): res = super(WorkflowJobTemplateSerializer, self).get_related(obj) - res.update(dict( + res.update( workflow_jobs = self.reverse('api:workflow_job_template_jobs_list', kwargs={'pk': obj.pk}), schedules = self.reverse('api:workflow_job_template_schedules_list', kwargs={'pk': obj.pk}), launch = self.reverse('api:workflow_job_template_launch', kwargs={'pk': obj.pk}), + webhook_key=self.reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': obj.pk}), + webhook_receiver=( + self.reverse('api:webhook_receiver_{}'.format(obj.webhook_service), + kwargs={'model_kwarg': 'workflow_job_templates', 'pk': obj.pk}) + if obj.webhook_service else '' + ), workflow_nodes = self.reverse('api:workflow_job_template_workflow_nodes_list', kwargs={'pk': obj.pk}), labels = self.reverse('api:workflow_job_template_label_list', kwargs={'pk': obj.pk}), activity_stream = self.reverse('api:workflow_job_template_activity_stream_list', kwargs={'pk': obj.pk}), @@ -3341,7 +3380,7 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo object_roles = self.reverse('api:workflow_job_template_object_roles_list', kwargs={'pk': obj.pk}), survey_spec = self.reverse('api:workflow_job_template_survey_spec', kwargs={'pk': obj.pk}), copy = self.reverse('api:workflow_job_template_copy', kwargs={'pk': obj.pk}), - )) + ) if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) return res @@ -3382,10 +3421,11 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer): class Meta: model = WorkflowJob - fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous', - 'job_template', 'is_sliced_job', - '-execution_node', '-event_processing_finished', '-controller_node', - 'inventory', 'limit', 'scm_branch',) + fields = ( + '*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous', 'job_template', + 'is_sliced_job', '-execution_node', '-event_processing_finished', '-controller_node', + 'inventory', 'limit', 'scm_branch', 'webhook_service', 'webhook_credential', 'webhook_guid', + ) def get_related(self, obj): res = super(WorkflowJobSerializer, self).get_related(obj) diff --git a/awx/api/templates/api/webhook_key_view.md b/awx/api/templates/api/webhook_key_view.md new file mode 100644 index 0000000000..ec83c4a04a --- /dev/null +++ b/awx/api/templates/api/webhook_key_view.md @@ -0,0 +1,12 @@ +Webhook Secret Key: + +Make a GET request to this resource to obtain the secret key for a job +template or workflow job template configured to be triggered by +webhook events. The response will include the following fields: + +* `webhook_key`: Secret key that needs to be copied and added to the + webhook configuration of the service this template will be receiving + webhook events from (string, read-only) + +Make an empty POST request to this resource to generate a new +replacement `webhook_key`. diff --git a/awx/api/urls/job_template.py b/awx/api/urls/job_template.py index 922019d117..77252eb7e3 100644 --- a/awx/api/urls/job_template.py +++ b/awx/api/urls/job_template.py @@ -1,7 +1,7 @@ # Copyright (c) 2017 Ansible, Inc. # All Rights Reserved. -from django.conf.urls import url +from django.conf.urls import include, url from awx.api.views import ( JobTemplateList, @@ -45,6 +45,7 @@ urls = [ url(r'^(?P[0-9]+)/object_roles/$', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'), url(r'^(?P[0-9]+)/labels/$', JobTemplateLabelList.as_view(), name='job_template_label_list'), url(r'^(?P[0-9]+)/copy/$', JobTemplateCopy.as_view(), name='job_template_copy'), + url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'job_templates'}), ] __all__ = ['urls'] diff --git a/awx/api/urls/webhooks.py b/awx/api/urls/webhooks.py new file mode 100644 index 0000000000..1a168d3baa --- /dev/null +++ b/awx/api/urls/webhooks.py @@ -0,0 +1,14 @@ +from django.conf.urls import url + +from awx.api.views import ( + WebhookKeyView, + GithubWebhookReceiver, + GitlabWebhookReceiver, +) + + +urlpatterns = [ + url(r'^webhook_key/$', WebhookKeyView.as_view(), name='webhook_key'), + url(r'^github/$', GithubWebhookReceiver.as_view(), name='webhook_receiver_github'), + url(r'^gitlab/$', GitlabWebhookReceiver.as_view(), name='webhook_receiver_gitlab'), +] diff --git a/awx/api/urls/workflow_job_template.py b/awx/api/urls/workflow_job_template.py index 349dad1aa5..b9deda499a 100644 --- a/awx/api/urls/workflow_job_template.py +++ b/awx/api/urls/workflow_job_template.py @@ -1,7 +1,7 @@ # Copyright (c) 2017 Ansible, Inc. # All Rights Reserved. -from django.conf.urls import url +from django.conf.urls import include, url from awx.api.views import ( WorkflowJobTemplateList, @@ -44,6 +44,7 @@ urls = [ url(r'^(?P[0-9]+)/access_list/$', WorkflowJobTemplateAccessList.as_view(), name='workflow_job_template_access_list'), url(r'^(?P[0-9]+)/object_roles/$', WorkflowJobTemplateObjectRolesList.as_view(), name='workflow_job_template_object_roles_list'), url(r'^(?P[0-9]+)/labels/$', WorkflowJobTemplateLabelList.as_view(), name='workflow_job_template_label_list'), + url(r'^(?P[0-9]+)/', include('awx.api.urls.webhooks'), {'model_kwarg': 'workflow_job_templates'}), ] __all__ = ['urls'] diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 7b831edfa5..4c4ff93cac 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -150,6 +150,11 @@ from awx.api.views.root import ( # noqa ApiV2ConfigView, ApiV2SubscriptionView, ) +from awx.api.views.webhooks import ( # noqa + WebhookKeyView, + GithubWebhookReceiver, + GitlabWebhookReceiver, +) logger = logging.getLogger('awx.api.views') diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py new file mode 100644 index 0000000000..e3ed6e64c9 --- /dev/null +++ b/awx/api/views/webhooks.py @@ -0,0 +1,247 @@ +from hashlib import sha1 +import hmac +import json +import logging +import urllib.parse + +from django.utils.encoding import force_bytes +from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt + +from rest_framework import status +from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from awx.api import serializers +from awx.api.generics import APIView, GenericAPIView +from awx.api.permissions import WebhookKeyPermission +from awx.main.models import Job, JobTemplate, WorkflowJob, WorkflowJobTemplate + + +logger = logging.getLogger('awx.api.views.webhooks') + + +class WebhookKeyView(GenericAPIView): + serializer_class = serializers.EmptySerializer + permission_classes = (WebhookKeyPermission,) + + def get_queryset(self): + qs_models = { + 'job_templates': JobTemplate, + 'workflow_job_templates': WorkflowJobTemplate, + } + self.model = qs_models.get(self.kwargs['model_kwarg']) + + return super().get_queryset() + + def get(self, request, *args, **kwargs): + obj = self.get_object() + + return Response({'webhook_key': obj.webhook_key}) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + obj.rotate_webhook_key() + obj.save(update_fields=['webhook_key']) + + return Response({'webhook_key': obj.webhook_key}, status=status.HTTP_201_CREATED) + + +class WebhookReceiverBase(APIView): + lookup_url_kwarg = None + lookup_field = 'pk' + + permission_classes = (AllowAny,) + authentication_classes = () + + ref_keys = {} + + def get_queryset(self): + qs_models = { + 'job_templates': JobTemplate, + 'workflow_job_templates': WorkflowJobTemplate, + } + model = qs_models.get(self.kwargs['model_kwarg']) + if model is None: + raise PermissionDenied + + return model.objects.filter(webhook_service=self.service).exclude(webhook_key='') + + def get_object(self): + queryset = self.get_queryset() + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]} + + obj = queryset.filter(**filter_kwargs).first() + if obj is None: + raise PermissionDenied + + return obj + + def get_event_type(self): + raise NotImplementedError + + def get_event_guid(self): + raise NotImplementedError + + def get_event_status_api(self): + raise NotImplementedError + + def get_event_ref(self): + key = self.ref_keys.get(self.get_event_type(), '') + value = self.request.data + for element in key.split('.'): + try: + if element.isdigit(): + value = value[int(element)] + else: + value = (value or {}).get(element) + except Exception: + value = None + if value == '0000000000000000000000000000000000000000': # a deleted ref + value = None + return value + + def get_signature(self): + raise NotImplementedError + + def check_signature(self, obj): + if not obj.webhook_key: + raise PermissionDenied + + mac = hmac.new(force_bytes(obj.webhook_key), msg=force_bytes(self.request.body), digestmod=sha1) + logger.debug("header signature: %s", self.get_signature()) + logger.debug("calculated signature: %s", force_bytes(mac.hexdigest())) + if not hmac.compare_digest(force_bytes(mac.hexdigest()), self.get_signature()): + raise PermissionDenied + + @csrf_exempt + def post(self, request, *args, **kwargs): + # Ensure that the full contents of the request are captured for multiple uses. + request.body + + logger.debug( + "headers: {}\n" + "data: {}\n".format(request.headers, request.data) + ) + obj = self.get_object() + self.check_signature(obj) + + event_type = self.get_event_type() + event_guid = self.get_event_guid() + event_ref = self.get_event_ref() + status_api = self.get_event_status_api() + + kwargs = { + 'unified_job_template_id': obj.id, + 'webhook_service': obj.webhook_service, + 'webhook_guid': event_guid, + } + if WorkflowJob.objects.filter(**kwargs).exists() or Job.objects.filter(**kwargs).exists(): + # Short circuit if this webhook has already been received and acted upon. + logger.debug("Webhook previously received, returning without action.") + return Response({'message': _("Webhook previously received, aborting.")}, + status=status.HTTP_202_ACCEPTED) + + kwargs = { + '_eager_fields': { + 'launch_type': 'webhook', + 'webhook_service': obj.webhook_service, + 'webhook_credential': obj.webhook_credential, + 'webhook_guid': event_guid, + }, + 'extra_vars': json.dumps({ + 'tower_webhook_event_type': event_type, + 'tower_webhook_event_guid': event_guid, + 'tower_webhook_event_ref': event_ref, + 'tower_webhook_status_api': status_api, + 'tower_webhook_payload': request.data, + }) + } + + new_job = obj.create_unified_job(**kwargs) + new_job.signal_start() + + return Response({'message': "Job queued."}, status=status.HTTP_202_ACCEPTED) + + +class GithubWebhookReceiver(WebhookReceiverBase): + service = 'github' + + ref_keys = { + 'pull_request': 'pull_request.head.sha', + 'pull_request_review': 'pull_request.head.sha', + 'pull_request_review_comment': 'pull_request.head.sha', + 'push': 'after', + 'release': 'release.tag_name', + 'commit_comment': 'comment.commit_id', + 'create': 'ref', + 'page_build': 'build.commit', + } + + def get_event_type(self): + return self.request.META.get('HTTP_X_GITHUB_EVENT') + + def get_event_guid(self): + return self.request.META.get('HTTP_X_GITHUB_DELIVERY') + + def get_event_status_api(self): + if self.get_event_type() != 'pull_request': + return + return self.request.data.get('pull_request', {}).get('statuses_url') + + def get_signature(self): + header_sig = self.request.META.get('HTTP_X_HUB_SIGNATURE') + if not header_sig: + logger.debug("Expected signature missing from header key HTTP_X_HUB_SIGNATURE") + raise PermissionDenied + hash_alg, signature = header_sig.split('=') + if hash_alg != 'sha1': + logger.debug("Unsupported signature type, expected: sha1, received: {}".format(hash_alg)) + raise PermissionDenied + return force_bytes(signature) + + +class GitlabWebhookReceiver(WebhookReceiverBase): + service = 'gitlab' + + ref_keys = { + 'Push Hook': 'checkout_sha', + 'Tag Push Hook': 'checkout_sha', + 'Merge Request Hook': 'object_attributes.last_commit.id', + } + + def get_event_type(self): + return self.request.META.get('HTTP_X_GITLAB_EVENT') + + def get_event_guid(self): + # GitLab does not provide a unique identifier on events, so construct one. + h = sha1() + h.update(force_bytes(self.request.body)) + return h.hexdigest() + + def get_event_status_api(self): + if self.get_event_type() != 'Merge Request Hook': + return + project = self.request.data.get('project', {}) + repo_url = project.get('web_url') + if not repo_url: + return + parsed = urllib.parse.urlparse(repo_url) + + return "{}://{}/api/v4/projects/{}/statuses/{}".format( + parsed.scheme, parsed.netloc, project['id'], self.get_event_ref()) + + def get_signature(self): + return force_bytes(self.request.META.get('HTTP_X_GITLAB_TOKEN') or '') + + def check_signature(self, obj): + if not obj.webhook_key: + raise PermissionDenied + + # GitLab only returns the secret token, not an hmac hash. Use + # the hmac `compare_digest` helper function to prevent timing + # analysis by attackers. + if not hmac.compare_digest(force_bytes(obj.webhook_key), self.get_signature()): + raise PermissionDenied diff --git a/awx/main/migrations/0092_v360_webhook_mixin.py b/awx/main/migrations/0092_v360_webhook_mixin.py new file mode 100644 index 0000000000..c2887c6b1b --- /dev/null +++ b/awx/main/migrations/0092_v360_webhook_mixin.py @@ -0,0 +1,49 @@ +# Generated by Django 2.2.4 on 2019-09-12 14:49 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0091_v360_approval_node_notifications'), + ] + + operations = [ + migrations.AddField( + model_name='jobtemplate', + name='webhook_credential', + field=models.ForeignKey(blank=True, help_text='Personal Access Token for posting back the status to the service API', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobtemplates', to='main.Credential'), + ), + migrations.AddField( + model_name='jobtemplate', + name='webhook_key', + field=models.CharField(blank=True, help_text='Shared secret that the webhook service will use to sign requests', max_length=64), + ), + migrations.AddField( + model_name='jobtemplate', + name='webhook_service', + field=models.CharField(blank=True, choices=[('github', 'GitHub'), ('gitlab', 'GitLab')], help_text='Service that webhook requests will be accepted from', max_length=16), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='webhook_credential', + field=models.ForeignKey(blank=True, help_text='Personal Access Token for posting back the status to the service API', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflowjobtemplates', to='main.Credential'), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='webhook_key', + field=models.CharField(blank=True, help_text='Shared secret that the webhook service will use to sign requests', max_length=64), + ), + migrations.AddField( + model_name='workflowjobtemplate', + name='webhook_service', + field=models.CharField(blank=True, choices=[('github', 'GitHub'), ('gitlab', 'GitLab')], help_text='Service that webhook requests will be accepted from', max_length=16), + ), + migrations.AlterField( + model_name='unifiedjob', + name='launch_type', + field=models.CharField(choices=[('manual', 'Manual'), ('relaunch', 'Relaunch'), ('callback', 'Callback'), ('scheduled', 'Scheduled'), ('dependency', 'Dependency'), ('workflow', 'Workflow'), ('webhook', 'Webhook'), ('sync', 'Sync'), ('scm', 'SCM Update')], db_index=True, default='manual', editable=False, max_length=20), + ), + ] diff --git a/awx/main/migrations/0093_v360_personal_access_tokens.py b/awx/main/migrations/0093_v360_personal_access_tokens.py new file mode 100644 index 0000000000..1dd1bbc094 --- /dev/null +++ b/awx/main/migrations/0093_v360_personal_access_tokens.py @@ -0,0 +1,27 @@ +# Generated by Django 2.2.4 on 2019-09-12 14:50 + +from django.db import migrations, models + +from awx.main.models import CredentialType +from awx.main.utils.common import set_current_apps + + +def setup_tower_managed_defaults(apps, schema_editor): + set_current_apps(apps) + CredentialType.setup_tower_managed_defaults() + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0092_v360_webhook_mixin'), + ] + + operations = [ + migrations.AlterField( + model_name='credentialtype', + name='kind', + field=models.CharField(choices=[('ssh', 'Machine'), ('vault', 'Vault'), ('net', 'Network'), ('scm', 'Source Control'), ('cloud', 'Cloud'), ('token', 'Personal Access Token'), ('insights', 'Insights'), ('external', 'External')], max_length=32), + ), + migrations.RunPython(setup_tower_managed_defaults), + ] diff --git a/awx/main/migrations/0094_v360_webhook_mixin2.py b/awx/main/migrations/0094_v360_webhook_mixin2.py new file mode 100644 index 0000000000..8b9dd17f1a --- /dev/null +++ b/awx/main/migrations/0094_v360_webhook_mixin2.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.4 on 2019-09-12 14:52 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0093_v360_personal_access_tokens'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='webhook_credential', + field=models.ForeignKey(blank=True, help_text='Personal Access Token for posting back the status to the service API', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='jobs', to='main.Credential'), + ), + migrations.AddField( + model_name='job', + name='webhook_guid', + field=models.CharField(blank=True, help_text='Unique identifier of the event that triggered this webhook', max_length=128), + ), + migrations.AddField( + model_name='job', + name='webhook_service', + field=models.CharField(blank=True, choices=[('github', 'GitHub'), ('gitlab', 'GitLab')], help_text='Service that webhook requests will be accepted from', max_length=16), + ), + migrations.AddField( + model_name='workflowjob', + name='webhook_credential', + field=models.ForeignKey(blank=True, help_text='Personal Access Token for posting back the status to the service API', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workflowjobs', to='main.Credential'), + ), + migrations.AddField( + model_name='workflowjob', + name='webhook_guid', + field=models.CharField(blank=True, help_text='Unique identifier of the event that triggered this webhook', max_length=128), + ), + migrations.AddField( + model_name='workflowjob', + name='webhook_service', + field=models.CharField(blank=True, choices=[('github', 'GitHub'), ('gitlab', 'GitLab')], help_text='Service that webhook requests will be accepted from', max_length=16), + ), + ] diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 07bfe645d8..e8b4315abd 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -322,6 +322,7 @@ class CredentialType(CommonModelNameNotUnique): ('net', _('Network')), ('scm', _('Source Control')), ('cloud', _('Cloud')), + ('token', _('Personal Access Token')), ('insights', _('Insights')), ('external', _('External')), ) @@ -968,6 +969,40 @@ ManagedCredentialType( } ) +ManagedCredentialType( + namespace='github_token', + kind='token', + name=ugettext_noop('GitHub Personal Access Token'), + managed_by_tower=True, + inputs={ + 'fields': [{ + 'id': 'token', + 'label': ugettext_noop('Token'), + 'type': 'string', + 'secret': True, + 'help_text': ugettext_noop('This token needs to come from your profile settings in GitHub') + }], + 'required': ['token'], + }, +) + +ManagedCredentialType( + namespace='gitlab_token', + kind='token', + name=ugettext_noop('GitLab Personal Access Token'), + managed_by_tower=True, + inputs={ + 'fields': [{ + 'id': 'token', + 'label': ugettext_noop('Token'), + 'type': 'string', + 'secret': True, + 'help_text': ugettext_noop('This token needs to come from your profile settings in GitLab') + }], + 'required': ['token'], + }, +) + ManagedCredentialType( namespace='insights', kind='insights', diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index a6395c495b..40f60c7705 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -48,6 +48,8 @@ from awx.main.models.mixins import ( TaskManagerJobMixin, CustomVirtualEnvMixin, RelatedJobsMixin, + WebhookMixin, + WebhookTemplateMixin, ) @@ -187,7 +189,7 @@ class JobOptions(BaseModel): return needed -class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin): +class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin, WebhookTemplateMixin): ''' A job template is a reusable job definition for applying a project (with playbook) to an inventory source with a given credential. @@ -484,7 +486,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour return UnifiedJob.objects.filter(unified_job_template=self) -class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskManagerJobMixin, CustomVirtualEnvMixin): +class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskManagerJobMixin, CustomVirtualEnvMixin, WebhookMixin): ''' A job applies a project (with playbook) to an inventory source with a given credential. It represents a single invocation of ansible-playbook with the diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index d63ec5eb5d..913750930c 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -1,31 +1,37 @@ # Python -import os -import json from copy import copy, deepcopy +import json +import logging +import os +import requests # Django from django.apps import apps from django.conf import settings -from django.db import models -from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User # noqa -from django.utils.translation import ugettext_lazy as _ +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.db import models from django.db.models.query import QuerySet +from django.utils.crypto import get_random_string +from django.utils.translation import ugettext_lazy as _ # AWX from awx.main.models.base import prevent_search from awx.main.models.rbac import ( Role, RoleAncestorEntry, get_roles_on_resource ) -from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices +from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices, get_licenser from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted from awx.main.utils.polymorphic import build_polymorphic_ctypes_map from awx.main.fields import JSONField, AskForField from awx.main.constants import ACTIVE_STATES +logger = logging.getLogger('awx.main.models.mixins') + + __all__ = ['ResourceMixin', 'SurveyJobTemplateMixin', 'SurveyJobMixin', 'TaskManagerUnifiedJobMixin', 'TaskManagerJobMixin', 'TaskManagerProjectUpdateMixin', 'TaskManagerInventoryUpdateMixin', 'CustomVirtualEnvMixin'] @@ -483,3 +489,139 @@ class RelatedJobsMixin(object): raise RuntimeError("Programmer error. Expected _get_active_jobs() to return a QuerySet.") return [dict(id=t[0], type=mapping[t[1]]) for t in jobs.values_list('id', 'polymorphic_ctype_id')] + + +class WebhookTemplateMixin(models.Model): + class Meta: + abstract = True + + SERVICES = [ + ('github', "GitHub"), + ('gitlab', "GitLab"), + ] + + webhook_service = models.CharField( + max_length=16, + choices=SERVICES, + blank=True, + help_text=_('Service that webhook requests will be accepted from') + ) + webhook_key = prevent_search(models.CharField( + max_length=64, + blank=True, + help_text=_('Shared secret that the webhook service will use to sign requests') + )) + webhook_credential = models.ForeignKey( + 'Credential', + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='%(class)ss', + help_text=_('Personal Access Token for posting back the status to the service API') + ) + + def rotate_webhook_key(self): + self.webhook_key = get_random_string(length=50) + + def save(self, *args, **kwargs): + update_fields = kwargs.get('update_fields') + + if not self.pk or self._values_have_edits({'webhook_service': self.webhook_service}): + if self.webhook_service: + self.rotate_webhook_key() + else: + self.webhook_key = '' + + if update_fields and 'webhook_service' in update_fields: + update_fields.add('webhook_key') + + super().save(*args, **kwargs) + + +class WebhookMixin(models.Model): + class Meta: + abstract = True + + SERVICES = WebhookTemplateMixin.SERVICES + + webhook_service = models.CharField( + max_length=16, + choices=SERVICES, + blank=True, + help_text=_('Service that webhook requests will be accepted from') + ) + webhook_credential = models.ForeignKey( + 'Credential', + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='%(class)ss', + help_text=_('Personal Access Token for posting back the status to the service API') + ) + webhook_guid = models.CharField( + blank=True, + max_length=128, + help_text=_('Unique identifier of the event that triggered this webhook') + ) + + def update_webhook_status(self, status): + if not self.webhook_credential: + logger.debug("No credential configured to post back webhook status, skipping.") + return + + status_api = self.extra_vars_dict.get('tower_webhook_status_api') + if not status_api: + logger.debug("Webhook event did not have a status API endpoint associated, skipping.") + return + + service_header = { + 'github': ('Authorization', 'token {}'), + 'gitlab': ('PRIVATE-TOKEN', '{}'), + } + service_statuses = { + 'github': { + 'pending': 'pending', + 'successful': 'success', + 'failed': 'failure', + 'canceled': 'failure', # GitHub doesn't have a 'canceled' status :( + 'error': 'error', + }, + 'gitlab': { + 'pending': 'pending', + 'running': 'running', + 'successful': 'success', + 'failed': 'failed', + 'error': 'failed', # GitLab doesn't have an 'error' status distinct from 'failed' :( + 'canceled': 'canceled', + }, + } + + statuses = service_statuses[self.webhook_service] + if status not in statuses: + logger.debug("Skipping webhook job status change: '{}'".format(status)) + return + try: + license_type = get_licenser().validate().get('license_type') + data = { + 'state': statuses[status], + 'context': 'ansible/awx' if license_type == 'open' else 'ansible/tower', + 'target_url': self.get_ui_url(), + } + k, v = service_header[self.webhook_service] + headers = { + k: v.format(self.webhook_credential.get_input('token')), + 'Content-Type': 'application/json' + } + response = requests.post(status_api, data=json.dumps(data), headers=headers, timeout=30) + except Exception: + logger.exception("Posting webhook status caused an error.") + return + + if response.status_code < 400: + logger.debug("Webhook status update sent.") + else: + logger.error( + "Posting webhook status failed, code: {}\n" + "{}\n" + "Payload sent: {}".format(response.status_code, response.text, json.dumps(data)) + ) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index b8dd1d1cf8..36ec2a9989 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -532,6 +532,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique ('scheduled', _('Scheduled')), # Job was started from a schedule. ('dependency', _('Dependency')), # Job was started as a dependency of another job. ('workflow', _('Workflow')), # Job was started from a workflow job. + ('webhook', _('Webhook')), # Job was started from a webhook event. ('sync', _('Sync')), # Job was started from a project sync. ('scm', _('SCM Update')) # Job was created as an Inventory SCM sync. ] @@ -1206,6 +1207,8 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique def websocket_emit_status(self, status): connection.on_commit(lambda: self._websocket_emit_status(status)) + if hasattr(self, 'update_webhook_status'): + connection.on_commit(lambda: self.update_webhook_status(status)) def notification_data(self): return dict(id=self.id, diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 29715d4cc8..e85e531aaf 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -32,6 +32,8 @@ from awx.main.models.mixins import ( SurveyJobTemplateMixin, SurveyJobMixin, RelatedJobsMixin, + WebhookMixin, + WebhookTemplateMixin, ) from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemplate from awx.main.models.credential import Credential @@ -358,7 +360,7 @@ class WorkflowJobOptions(LaunchTimeConfigBase): return new_workflow_job -class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin): +class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin, WebhookTemplateMixin): SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] FIELDS_TO_PRESERVE_AT_COPY = [ @@ -530,7 +532,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl return WorkflowJob.objects.filter(workflow_job_template=self) -class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin): +class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin, WebhookMixin): class Meta: app_label = 'main' ordering = ('id',) diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index fe61410908..2f8cbe6934 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -154,12 +154,12 @@ def mk_job_template(name, job_type='run', organization=None, inventory=None, credential=None, network_credential=None, cloud_credential=None, persisted=True, extra_vars='', - project=None, spec=None): + project=None, spec=None, webhook_service=''): if extra_vars: extra_vars = json.dumps(extra_vars) jt = JobTemplate(name=name, job_type=job_type, extra_vars=extra_vars, - playbook='helloworld.yml') + webhook_service=webhook_service, playbook='helloworld.yml') jt.inventory = inventory if jt.inventory is None: @@ -200,11 +200,13 @@ def mk_workflow_job(status='new', workflow_job_template=None, extra_vars={}, return job -def mk_workflow_job_template(name, extra_vars='', spec=None, organization=None, persisted=True): +def mk_workflow_job_template(name, extra_vars='', spec=None, organization=None, persisted=True, + webhook_service=''): if extra_vars: extra_vars = json.dumps(extra_vars) - wfjt = WorkflowJobTemplate(name=name, extra_vars=extra_vars, organization=organization) + wfjt = WorkflowJobTemplate(name=name, extra_vars=extra_vars, organization=organization, + webhook_service=webhook_service) wfjt.survey_spec = spec if wfjt.survey_spec: diff --git a/awx/main/tests/factories/tower.py b/awx/main/tests/factories/tower.py index e8b0cc6e42..bfa7f9fc1b 100644 --- a/awx/main/tests/factories/tower.py +++ b/awx/main/tests/factories/tower.py @@ -197,7 +197,7 @@ def create_survey_spec(variables=None, default_type='integer', required=True, mi # -def create_job_template(name, roles=None, persisted=True, **kwargs): +def create_job_template(name, roles=None, persisted=True, webhook_service='', **kwargs): Objects = generate_objects(["job_template", "jobs", "organization", "inventory", @@ -252,11 +252,10 @@ def create_job_template(name, roles=None, persisted=True, **kwargs): else: spec = None - jt = mk_job_template(name, project=proj, - inventory=inv, credential=cred, + jt = mk_job_template(name, project=proj, inventory=inv, credential=cred, network_credential=net_cred, cloud_credential=cloud_cred, job_type=job_type, spec=spec, extra_vars=extra_vars, - persisted=persisted) + persisted=persisted, webhook_service=webhook_service) if 'jobs' in kwargs: for i in kwargs['jobs']: @@ -401,7 +400,7 @@ def generate_workflow_job_template_nodes(workflow_job_template, # TODO: Implement survey and jobs -def create_workflow_job_template(name, organization=None, persisted=True, **kwargs): +def create_workflow_job_template(name, organization=None, persisted=True, webhook_service='', **kwargs): Objects = generate_objects(["workflow_job_template", "workflow_job_template_nodes", "survey",], kwargs) @@ -418,7 +417,8 @@ def create_workflow_job_template(name, organization=None, persisted=True, **kwar organization=organization, spec=spec, extra_vars=extra_vars, - persisted=persisted) + persisted=persisted, + webhook_service=webhook_service) diff --git a/awx/main/tests/functional/api/test_webhooks.py b/awx/main/tests/functional/api/test_webhooks.py new file mode 100644 index 0000000000..971ea22b4f --- /dev/null +++ b/awx/main/tests/functional/api/test_webhooks.py @@ -0,0 +1,260 @@ +import pytest + +from awx.api.versioning import reverse +from awx.main.models.mixins import WebhookTemplateMixin +from awx.main.models.credential import Credential, CredentialType + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "user_role, expect", [ + ('superuser', 200), + ('org admin', 200), + ('jt admin', 200), + ('jt execute', 403), + ('org member', 403), + ] +) +def test_get_webhook_key_jt(organization_factory, job_template_factory, get, user_role, expect): + objs = organization_factory("org", superusers=['admin'], users=['user']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + if user_role == 'superuser': + user = objs.superusers.admin + else: + user = objs.users.user + grant_obj = objs.organization if user_role.startswith('org') else jt + getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user) + + url = reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': jt.pk}) + response = get(url, user=user, expect=expect) + if expect < 400: + assert response.data == {'webhook_key': ''} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "user_role, expect", [ + ('superuser', 200), + ('org admin', 200), + ('jt admin', 200), + ('jt execute', 403), + ('org member', 403), + ] +) +def test_get_webhook_key_wfjt(organization_factory, workflow_job_template_factory, get, user_role, expect): + objs = organization_factory("org", superusers=['admin'], users=['user']) + wfjt = workflow_job_template_factory("wfjt", organization=objs.organization).workflow_job_template + if user_role == 'superuser': + user = objs.superusers.admin + else: + user = objs.users.user + grant_obj = objs.organization if user_role.startswith('org') else wfjt + getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user) + + url = reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': wfjt.pk}) + response = get(url, user=user, expect=expect) + if expect < 400: + assert response.data == {'webhook_key': ''} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "user_role, expect", [ + ('superuser', 201), + ('org admin', 201), + ('jt admin', 201), + ('jt execute', 403), + ('org member', 403), + ] +) +def test_post_webhook_key_jt(organization_factory, job_template_factory, post, user_role, expect): + objs = organization_factory("org", superusers=['admin'], users=['user']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + if user_role == 'superuser': + user = objs.superusers.admin + else: + user = objs.users.user + grant_obj = objs.organization if user_role.startswith('org') else jt + getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user) + + url = reverse('api:webhook_key', kwargs={'model_kwarg': 'job_templates', 'pk': jt.pk}) + response = post(url, {}, user=user, expect=expect) + if expect < 400: + assert bool(response.data.get('webhook_key')) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "user_role, expect", [ + ('superuser', 201), + ('org admin', 201), + ('jt admin', 201), + ('jt execute', 403), + ('org member', 403), + ] +) +def test_post_webhook_key_wfjt(organization_factory, workflow_job_template_factory, post, user_role, expect): + objs = organization_factory("org", superusers=['admin'], users=['user']) + wfjt = workflow_job_template_factory("wfjt", organization=objs.organization).workflow_job_template + if user_role == 'superuser': + user = objs.superusers.admin + else: + user = objs.users.user + grant_obj = objs.organization if user_role.startswith('org') else wfjt + getattr(grant_obj, '{}_role'.format(user_role.split()[1])).members.add(user) + + url = reverse('api:webhook_key', kwargs={'model_kwarg': 'workflow_job_templates', 'pk': wfjt.pk}) + response = post(url, {}, user=user, expect=expect) + if expect < 400: + assert bool(response.data.get('webhook_key')) + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "service", [s for s, _ in WebhookTemplateMixin.SERVICES] +) +def test_set_webhook_service(organization_factory, job_template_factory, patch, service): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + admin = objs.superusers.admin + assert (jt.webhook_service, jt.webhook_key) == ('', '') + + url = reverse('api:job_template_detail', kwargs={'pk': jt.pk}) + patch(url, {'webhook_service': service}, user=admin, expect=200) + jt.refresh_from_db() + + assert jt.webhook_service == service + assert jt.webhook_key != '' + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "service", [s for s, _ in WebhookTemplateMixin.SERVICES] +) +def test_unset_webhook_service(organization_factory, job_template_factory, patch, service): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, webhook_service=service, + inventory='test_inv', project='test_proj').job_template + admin = objs.superusers.admin + assert jt.webhook_service == service + assert jt.webhook_key != '' + + url = reverse('api:job_template_detail', kwargs={'pk': jt.pk}) + patch(url, {'webhook_service': ''}, user=admin, expect=200) + jt.refresh_from_db() + + assert (jt.webhook_service, jt.webhook_key) == ('', '') + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "service", [s for s, _ in WebhookTemplateMixin.SERVICES] +) +def test_set_webhook_credential(organization_factory, job_template_factory, patch, service): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, webhook_service=service, + inventory='test_inv', project='test_proj').job_template + admin = objs.superusers.admin + assert jt.webhook_service == service + assert jt.webhook_key != '' + + cred_type = CredentialType.defaults['{}_token'.format(service)]() + cred_type.save() + cred = Credential.objects.create(credential_type=cred_type, name='test-cred', + inputs={'token': 'secret'}) + + url = reverse('api:job_template_detail', kwargs={'pk': jt.pk}) + patch(url, {'webhook_credential': cred.pk}, user=admin, expect=200) + jt.refresh_from_db() + + assert jt.webhook_service == service + assert jt.webhook_key != '' + assert jt.webhook_credential == cred + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "service,token", [ + (s, WebhookTemplateMixin.SERVICES[i - 1][0]) for i, (s, _) in enumerate(WebhookTemplateMixin.SERVICES) + ] +) +def test_set_wrong_service_webhook_credential(organization_factory, job_template_factory, patch, service, token): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, webhook_service=service, + inventory='test_inv', project='test_proj').job_template + admin = objs.superusers.admin + assert jt.webhook_service == service + assert jt.webhook_key != '' + + cred_type = CredentialType.defaults['{}_token'.format(token)]() + cred_type.save() + cred = Credential.objects.create(credential_type=cred_type, name='test-cred', + inputs={'token': 'secret'}) + + url = reverse('api:job_template_detail', kwargs={'pk': jt.pk}) + response = patch(url, {'webhook_credential': cred.pk}, user=admin, expect=400) + jt.refresh_from_db() + + assert jt.webhook_service == service + assert jt.webhook_key != '' + assert jt.webhook_credential is None + assert response.data == {'webhook_credential': ["Must match the selected webhook service."]} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "service", [s for s, _ in WebhookTemplateMixin.SERVICES] +) +def test_set_webhook_credential_without_service(organization_factory, job_template_factory, patch, service): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + admin = objs.superusers.admin + assert jt.webhook_service == '' + assert jt.webhook_key == '' + + cred_type = CredentialType.defaults['{}_token'.format(service)]() + cred_type.save() + cred = Credential.objects.create(credential_type=cred_type, name='test-cred', + inputs={'token': 'secret'}) + + url = reverse('api:job_template_detail', kwargs={'pk': jt.pk}) + response = patch(url, {'webhook_credential': cred.pk}, user=admin, expect=400) + jt.refresh_from_db() + + assert jt.webhook_service == '' + assert jt.webhook_key == '' + assert jt.webhook_credential is None + assert response.data == {'webhook_credential': ["Must match the selected webhook service."]} + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "service", [s for s, _ in WebhookTemplateMixin.SERVICES] +) +def test_unset_webhook_service_with_credential(organization_factory, job_template_factory, patch, service): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, webhook_service=service, + inventory='test_inv', project='test_proj').job_template + admin = objs.superusers.admin + assert jt.webhook_service == service + assert jt.webhook_key != '' + + cred_type = CredentialType.defaults['{}_token'.format(service)]() + cred_type.save() + cred = Credential.objects.create(credential_type=cred_type, name='test-cred', + inputs={'token': 'secret'}) + jt.webhook_credential = cred + jt.save() + + url = reverse('api:job_template_detail', kwargs={'pk': jt.pk}) + response = patch(url, {'webhook_service': ''}, user=admin, expect=400) + jt.refresh_from_db() + + assert jt.webhook_service == service + assert jt.webhook_key != '' + assert jt.webhook_credential == cred + assert response.data == {'webhook_credential': ["Must match the selected webhook service."]} diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index c95817199a..c55d97d44e 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -82,6 +82,8 @@ def test_default_cred_types(): 'cloudforms', 'conjur', 'gce', + 'github_token', + 'gitlab_token', 'hashivault_kv', 'hashivault_ssh', 'insights', diff --git a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py index 698d27cb7c..4c0751ffbe 100644 --- a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py @@ -29,6 +29,7 @@ def job_template(mocker): mock_jt.pk = 5 mock_jt.host_config_key = '9283920492' mock_jt.validation_errors = mock_JT_resource_data + mock_jt.webhook_service = '' return mock_jt @@ -50,6 +51,7 @@ class TestJobTemplateSerializerGetRelated(): 'schedules', 'activity_stream', 'launch', + 'webhook_key', 'notification_templates_started', 'notification_templates_success', 'notification_templates_error', diff --git a/awx/main/tests/unit/api/serializers/test_workflow_serializers.py b/awx/main/tests/unit/api/serializers/test_workflow_serializers.py index 6cec577129..65837045f8 100644 --- a/awx/main/tests/unit/api/serializers/test_workflow_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_workflow_serializers.py @@ -32,6 +32,7 @@ class TestWorkflowJobTemplateSerializerGetRelated(): 'workflow_jobs', 'launch', 'workflow_nodes', + 'webhook_key', ]) def test_get_related(self, mocker, test_get_related, workflow_job_template, related_resource_name): test_get_related(WorkflowJobTemplateSerializer, diff --git a/awx/ui/client/features/jobs/jobs.strings.js b/awx/ui/client/features/jobs/jobs.strings.js index a5b69df13f..dbdfa0c69c 100644 --- a/awx/ui/client/features/jobs/jobs.strings.js +++ b/awx/ui/client/features/jobs/jobs.strings.js @@ -14,6 +14,7 @@ function JobsStrings (BaseString) { ROW_ITEM_LABEL_INVENTORY: t.s('Inventory'), ROW_ITEM_LABEL_PROJECT: t.s('Project'), ROW_ITEM_LABEL_CREDENTIALS: t.s('Credentials'), + ROW_ITEM_LABEL_WEBHOOK: t.s('Webhook'), NO_RUNNING: t.s('There are no running jobs.'), JOB: t.s('Job'), STATUS_TOOLTIP: status => t.s('Job {{status}}. Click for details.', { status }), diff --git a/awx/ui/client/features/jobs/jobsList.controller.js b/awx/ui/client/features/jobs/jobsList.controller.js index 922212965e..2a7225de6a 100644 --- a/awx/ui/client/features/jobs/jobsList.controller.js +++ b/awx/ui/client/features/jobs/jobsList.controller.js @@ -142,19 +142,13 @@ function ListJobsController ( return { icon, link, value }; }); - vm.getSliceJobDetails = (job) => { - if (!job.job_slice_count) { - return null; - } - - if (job.job_slice_count === 1) { - return null; - } - - if (job.job_slice_number && job.job_slice_count) { + vm.getSecondaryTagLabel = (job) => { + if (job.job_slice_number && job.job_slice_count && job.job_slice_count > 1) { return `${strings.get('list.SLICE_JOB')} ${job.job_slice_number}/${job.job_slice_count}`; } - + if (job.launch_type === 'webhook') { + return strings.get('list.ROW_ITEM_LABEL_WEBHOOK'); + } return null; }; diff --git a/awx/ui/client/features/jobs/jobsList.view.html b/awx/ui/client/features/jobs/jobsList.view.html index 660221968d..50b25c1aff 100644 --- a/awx/ui/client/features/jobs/jobsList.view.html +++ b/awx/ui/client/features/jobs/jobsList.view.html @@ -34,7 +34,7 @@ header-value="{{ job.id }} - {{ job.name }}" header-state="{{ vm.getSref(job) }}" header-tag="{{ vm.jobTypes[job.type] }}" - secondary-tag="{{ vm.getSliceJobDetails(job) }}"> + secondary-tag="{{ vm.getSecondaryTagLabel(job) }}">
diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 6e5353f3b6..71607a5a83 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -251,10 +251,12 @@ function getHostLimitErrorDetails () { function getLaunchedByDetails () { const createdBy = resource.model.get('summary_fields.created_by'); const jobTemplate = resource.model.get('summary_fields.job_template'); + const workflowJobTemplate = resource.model.get('summary_fields.workflow_job_template'); const relatedSchedule = resource.model.get('related.schedule'); const schedule = resource.model.get('summary_fields.schedule'); + const launchType = resource.model.get('launch_type'); - if (!createdBy && !schedule) { + if (!createdBy && !schedule && !launchType) { return null; } @@ -264,7 +266,15 @@ function getLaunchedByDetails () { let tooltip; let value; - if (createdBy) { + if (launchType === 'webhook' && jobTemplate) { + tooltip = strings.get('tooltips.WEBHOOK_JOB_TEMPLATE'); + link = `/#/templates/job_template/${jobTemplate.id}`; + value = strings.get('details.WEBHOOK'); + } else if (launchType === 'webhook' && workflowJobTemplate) { + tooltip = strings.get('tooltips.WEBHOOK_WORKFLOW_JOB_TEMPLATE'); + link = `/#/templates/workflow_job_template/${workflowJobTemplate.id}`; + value = strings.get('details.WEBHOOK'); + } else if (createdBy) { tooltip = strings.get('tooltips.USER'); link = `/#/users/${createdBy.id}`; value = $filter('sanitize')(createdBy.username); diff --git a/awx/ui/client/features/output/output.strings.js b/awx/ui/client/features/output/output.strings.js index d50788a141..dd4ddaaa0b 100644 --- a/awx/ui/client/features/output/output.strings.js +++ b/awx/ui/client/features/output/output.strings.js @@ -38,6 +38,8 @@ function OutputStrings (BaseString) { MENU_UP: t.s('Get previous page'), MENU_LAST: t.s('Go to last page of available output'), MENU_FOLLOWING: t.s('Currently following output as it arrives. Click to unfollow'), + WEBHOOK_JOB_TEMPLATE: t.s('View the webhook configuration on the job template.'), + WEBHOOK_WORKFLOW_JOB_TEMPLATE: t.s('View the webhook configuration on the workflow job template.'), }; ns.details = { @@ -48,6 +50,7 @@ function OutputStrings (BaseString) { SHOW_LESS: t.s('Show Less'), SHOW_MORE: t.s('Show More'), UNKNOWN: t.s('Finished'), + WEBHOOK: t.s('Webhook'), }; ns.labels = { diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index 3fe9fa217d..48d7bdb2b9 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -288,6 +288,8 @@ line-height: @at-line-height-list-row-item-tag; word-break: keep-all; display: inline-flex; + margin-right: 10px; + margin-left: 10px; } .at-RowItem-tag--primary { diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less index a9f9d2bdde..c7baad45e4 100644 --- a/awx/ui/client/lib/theme/index.less +++ b/awx/ui/client/lib/theme/index.less @@ -112,6 +112,7 @@ @import '../../src/workflow-results/standard-out.block.less'; @import '../../src/templates/prompt/prompt.block.less'; @import '../../src/templates/job_templates/multi-credential/multi-credential.block.less'; +@import '../../src/templates/job_templates/webhook-credential/webhook-credential.block.less'; @import '../../src/templates/labels/labelsList.block.less'; @import '../../src/templates/survey-maker/survey-maker.block.less'; @import '../../src/templates/survey-maker/survey-maker.block.less'; diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index 5f0330b4b2..18c5bf2eee 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -794,9 +794,21 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat } if (field.genHash) { - html += "\n
\n"; + const defaultGenHashButtonTemplate = ` + + + `; + const genHashButtonTemplate = _.get(field, 'genHashButtonTemplate', defaultGenHashButtonTemplate); + html += `${genHashButtonTemplate}\n\n`; } // Add error messages diff --git a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js index bb3891278f..6e855eb0a5 100644 --- a/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js +++ b/awx/ui/client/src/templates/job_templates/add-job-template/job-template-add.controller.js @@ -10,14 +10,14 @@ 'ProcessErrors', 'GetBasePath', 'hashSetup', 'ParseTypeChange', 'Wait', 'Empty', 'ToJSON', 'CallbackHelpInit', 'GetChoices', '$state', 'availableLabels', 'CreateSelect2', '$q', 'i18n', 'Inventory', 'Project', 'InstanceGroupsService', - 'MultiCredentialService', 'ConfigData', 'resolvedModels', + 'MultiCredentialService', 'ConfigData', 'resolvedModels', '$compile', function( $filter, $scope, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert, ProcessErrors, GetBasePath, hashSetup, ParseTypeChange, Wait, Empty, ToJSON, CallbackHelpInit, GetChoices, $state, availableLabels, CreateSelect2, $q, i18n, Inventory, Project, InstanceGroupsService, - MultiCredentialService, ConfigData, resolvedModels + MultiCredentialService, ConfigData, resolvedModels, $compile ) { // Inject dynamic view @@ -39,10 +39,121 @@ $scope.can_edit = true; $scope.allow_callbacks = false; $scope.playbook_options = []; + $scope.webhook_service_options = []; $scope.mode = "add"; $scope.parseType = 'yaml'; $scope.credentialNotPresent = false; $scope.canGetAllRelatedResources = true; + $scope.webhook_key_help = i18n._('Webhook services can use this as a shared secret.'); + + // + // webhook credential - all handlers, dynamic state, etc. live here + // + + $scope.webhookCredential = { + id: null, + name: null, + isModalOpen: false, + isModalReady: false, + modalTitle: i18n._('Select Webhook Credential'), + modalBaseParams: { + order_by: 'name', + page_size: 5, + credential_type__namespace: null, + }, + modalSelectedId: null, + modalSelectedName: null, + }; + + $scope.handleWebhookCredentialLookupClick = () => { + $scope.webhookCredential.modalSelectedId = $scope.webhookCredential.id; + $scope.webhookCredential.isModalOpen = true; + }; + + $scope.handleWebhookCredentialTagDelete = () => { + $scope.webhookCredential.id = null; + $scope.webhookCredential.name = null; + }; + + $scope.handleWebhookCredentialModalClose = () => { + $scope.webhookCredential.isModalOpen = false; + $scope.webhookCredential.isModalReady = false; + }; + + $scope.handleWebhookCredentialModalReady = () => { + $scope.webhookCredential.isModalReady = true; + }; + + $scope.handleWebhookCredentialModalItemSelect = (item) => { + $scope.webhookCredential.modalSelectedId = item.id; + $scope.webhookCredential.modalSelectedName = item.name; + }; + + $scope.handleWebhookCredentialModalCancel = () => { + $scope.webhookCredential.isModalOpen = false; + $scope.webhookCredential.isModalReady = false; + $scope.webhookCredential.modalSelectedId = null; + $scope.webhookCredential.modalSelectedName = null; + + }; + + $scope.handleWebhookCredentialSelect = () => { + $scope.webhookCredential.isModalOpen = false; + $scope.webhookCredential.isModalReady = false; + $scope.webhookCredential.id = $scope.webhookCredential.modalSelectedId; + $scope.webhookCredential.name = $scope.webhookCredential.modalSelectedName; + $scope.webhookCredential.modalSelectedId = null; + $scope.webhookCredential.modalSelectedName = null; + }; + + $scope.handleWebhookKeyButtonClick = () => {}; + + $('#content-container').append($compile(` + + + + + ${i18n._('CANCEL')} + + + ${i18n._('SELECT')} + + + `)($scope)); + + $scope.$watch('webhook_service', (newValue, oldValue) => { + const newServiceValue = newValue && typeof newValue === 'object' ? newValue.value : newValue; + const oldServiceValue = oldValue && typeof oldValue === 'object' ? oldValue.value : oldValue; + if (newServiceValue !== oldServiceValue || newServiceValue === newValue) { + $scope.webhook_service = { value: newServiceValue }; + sync_webhook_service_select2(); + $scope.webhookCredential.modalBaseParams.credential_type__namespace = newServiceValue ? + `${newServiceValue}_token` + : null; + if (newServiceValue !== newValue || newValue === null) { + $scope.webhookCredential.id = null; + $scope.webhookCredential.name = null; + } + } + }); hashSetup({ scope: $scope, @@ -51,6 +162,9 @@ default_val: false }); CallbackHelpInit({ scope: $scope }); + // set initial vals for webhook checkbox + $scope.enable_webhook = false; + master.enable_webhook = false; $scope.surveyTooltip = i18n._('Please save before adding a survey to this job template.'); @@ -131,6 +245,14 @@ multiple: false, opts: $scope.custom_virtualenvs_options }); + CreateSelect2({ + element:'#webhook-service-select', + addNew: false, + multiple: false, + scope: $scope, + options: 'webhook_service_options', + model: 'webhook_service' + }); } }); @@ -151,7 +273,13 @@ variable: 'job_type_options', callback: 'choicesReadyVerbosity' }); - + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'webhook_service', + variable: 'webhook_service_options', + callback: 'choicesReadyVerbosity' + }); $scope.labelOptions = availableLabels .map((i) => ({label: i.name, value: i.id})); $scope.$emit("choicesReadyVerbosity"); @@ -167,6 +295,17 @@ }); } + function sync_webhook_service_select2() { + CreateSelect2({ + element:'#webhook-service-select', + addNew: false, + multiple: false, + scope: $scope, + options: 'webhook_service_options', + model: 'webhook_service' + }); + } + $scope.toggleForm = function(key) { $scope[key] = !$scope[key]; }; @@ -328,6 +467,9 @@ // be provided to the related credentials endpoint by the template save success handler. delete data.credential; delete data.vault_credential; + delete data.webhook_url; + delete data.webhook_key; + data.webhook_credential = $scope.webhookCredential.id; data.extra_vars = ToJSON($scope.parseType, $scope.extra_vars, true); diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js index 4512dfd622..d27282d162 100644 --- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js +++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js @@ -19,7 +19,7 @@ export default 'initSurvey', '$state', 'CreateSelect2', 'isNotificationAdmin', 'ToggleNotification','$q', 'InstanceGroupsService', 'InstanceGroupsData', 'MultiCredentialService', 'availableLabels', 'projectGetPermissionDenied', - 'inventoryGetPermissionDenied', 'jobTemplateData', 'ParseVariableString', 'ConfigData', + 'inventoryGetPermissionDenied', 'jobTemplateData', 'ParseVariableString', 'ConfigData', '$compile', 'webhookKey', function( $filter, $scope, $stateParams, JobTemplateForm, GenerateForm, Rest, Alert, @@ -29,7 +29,7 @@ export default SurveyControllerInit, $state, CreateSelect2, isNotificationAdmin, ToggleNotification, $q, InstanceGroupsService, InstanceGroupsData, MultiCredentialService, availableLabels, projectGetPermissionDenied, - inventoryGetPermissionDenied, jobTemplateData, ParseVariableString, ConfigData + inventoryGetPermissionDenied, jobTemplateData, ParseVariableString, ConfigData, $compile, webhookKey ) { $scope.$watch('job_template_obj.summary_fields.user_capabilities.edit', function(val) { @@ -61,7 +61,10 @@ export default $scope.sufficientRoleForNotifToggle = isNotificationAdmin; $scope.sufficientRoleForNotif = isNotificationAdmin || $scope.user_is_system_auditor; $scope.playbook_options = null; + $scope.webhook_service_options = null; $scope.playbook = null; + $scope.webhook_service = jobTemplateData.webhook_service; + $scope.webhook_url = ''; $scope.mode = 'edit'; $scope.parseType = 'yaml'; $scope.showJobType = false; @@ -72,6 +75,148 @@ export default $scope.skip_tag_options = []; const virtualEnvs = ConfigData.custom_virtualenvs || []; $scope.custom_virtualenvs_options = virtualEnvs; + $scope.webhook_url_help = i18n._('Webhook services can launch jobs with this job template by making a POST request to this URL.'); + $scope.webhook_key_help = i18n._('Webhook services can use this as a shared secret.'); + + $scope.currentlySavedWebhookKey = webhookKey; + $scope.webhook_key = webhookKey; + + // + // webhook credential - all handlers, dynamic state, etc. live here + // + + $scope.webhookCredential = { + id: _.get(jobTemplateData, ['summary_fields', 'webhook_credential', 'id']), + name: _.get(jobTemplateData, ['summary_fields', 'webhook_credential', 'name']), + isModalOpen: false, + isModalReady: false, + modalSelectedId: null, + modalSelectedName: null, + modalBaseParams: { + order_by: 'name', + page_size: 5, + credential_type__namespace: `${jobTemplateData.webhook_service}_token`, + }, + modalTitle: i18n._('Select Webhook Credential'), + }; + + $scope.handleWebhookCredentialLookupClick = () => { + $scope.webhookCredential.modalSelectedId = $scope.webhookCredential.id; + $scope.webhookCredential.isModalOpen = true; + }; + + $scope.handleWebhookCredentialTagDelete = () => { + $scope.webhookCredential.id = null; + $scope.webhookCredential.name = null; + }; + + $scope.handleWebhookCredentialModalClose = () => { + $scope.webhookCredential.isModalOpen = false; + $scope.webhookCredential.isModalReady = false; + }; + + $scope.handleWebhookCredentialModalReady = () => { + $scope.webhookCredential.isModalReady = true; + }; + + $scope.handleWebhookCredentialModalItemSelect = (item) => { + $scope.webhookCredential.modalSelectedId = item.id; + $scope.webhookCredential.modalSelectedName = item.name; + }; + + $scope.handleWebhookCredentialModalCancel = () => { + $scope.webhookCredential.isModalOpen = false; + $scope.webhookCredential.isModalReady = false; + $scope.webhookCredential.modalSelectedId = null; + $scope.webhookCredential.modalSelectedName = null; + }; + + $scope.handleWebhookCredentialSelect = () => { + $scope.webhookCredential.isModalOpen = false; + $scope.webhookCredential.isModalReady = false; + $scope.webhookCredential.id = $scope.webhookCredential.modalSelectedId; + $scope.webhookCredential.name = $scope.webhookCredential.modalSelectedName; + $scope.webhookCredential.modalSelectedId = null; + $scope.webhookCredential.modalSelectedName = null; + }; + + $scope.handleWebhookKeyButtonClick = () => { + Rest.setUrl(jobTemplateData.related.webhook_key); + Wait('start'); + Rest.post({}) + .then(({ data }) => { + $scope.currentlySavedWebhookKey = data.webhook_key; + $scope.webhook_key = data.webhook_key; + }) + .catch(({ data }) => { + const errorMsg = `Failed to generate new webhook key. POST returned status: ${status}`; + ProcessErrors($scope, data, status, form, { hdr: 'Error!', msg: errorMsg }); + }) + .finally(() => { + Wait('stop'); + }); + }; + + $('#content-container').append($compile(` + + + + + ${i18n._('CANCEL')} + + + ${i18n._('SELECT')} + + + `)($scope)); + + $scope.$watch('webhook_service', (newValue, oldValue) => { + const newServiceValue = newValue && typeof newValue === 'object' ? newValue.value : newValue; + const oldServiceValue = oldValue && typeof oldValue === 'object' ? oldValue.value : oldValue; + if (newServiceValue) { + $scope.webhook_url = `${$scope.callback_server_path}${jobTemplateData.url}${newServiceValue}/`; + } else { + $scope.webhook_url = ''; + $scope.webhook_key = ''; + } + if (newServiceValue !== oldServiceValue || newServiceValue === newValue) { + $scope.webhook_service = { value: newServiceValue }; + sync_webhook_service_select2(); + $scope.webhookCredential.modalBaseParams.credential_type__namespace = newServiceValue ? + `${newServiceValue}_token` : null; + if (newServiceValue !== newValue || newValue === null) { + $scope.webhookCredential.id = null; + $scope.webhookCredential.name = null; + } + if (newServiceValue !== newValue) { + if (newServiceValue === jobTemplateData.webhook_service) { + $scope.webhook_key = $scope.currentlySavedWebhookKey; + } else { + $scope.webhook_key = i18n._('A NEW WEBHOOK KEY WILL BE GENERATED ON SAVE'); + } + } + } + }); + + $scope.$watch('verbosity', sync_verbosity_select2); SurveyControllerInit({ scope: $scope, @@ -174,9 +319,6 @@ export default } } }); - - // watch for changes to 'verbosity', ensure we keep our select2 in sync when it changes. - $scope.$watch('verbosity', sync_verbosity_select2); } callback = function() { @@ -202,6 +344,17 @@ export default })); } + function sync_webhook_service_select2() { + select2LoadDefer.push(CreateSelect2({ + element:'#webhook-service-select', + addNew: false, + multiple: false, + scope: $scope, + options: 'webhook_service_options', + model: 'webhook_service' + })); + } + function jobTemplateLoadFinished(){ select2LoadDefer.push(CreateSelect2({ element:'#job_template_job_type', @@ -225,6 +378,14 @@ export default multiple: false, opts: $scope.custom_virtualenvs_options })); + select2LoadDefer.push(CreateSelect2({ + element:'#webhook-service-select', + addNew: false, + multiple: false, + scope: $scope, + options: 'webhook_service_options', + model: 'webhook_service' + })); if (!launchHasBeenEnabled) { $q.all(select2LoadDefer).then(() => { @@ -296,6 +457,15 @@ export default default_val: dft }); + // set initial vals for webhook checkbox + if (jobTemplateData.webhook_service) { + $scope.enable_webhook = true; + master.enable_webhook = true; + } else { + $scope.enable_webhook = false; + master.enable_webhook = false; + } + ParseTypeChange({ scope: $scope, field_id: 'extra_vars', @@ -498,6 +668,14 @@ export default callback: 'choicesReady' }); + GetChoices({ + scope: $scope, + url: defaultUrl, + field: 'webhook_service', + variable: 'webhook_service_options', + callback: 'choicesReady' + }); + $scope.labelOptions = availableLabels .map((i) => ({label: i.name, value: i.id})); @@ -553,7 +731,6 @@ export default }); }); - var orgDefer = $q.defer(); var associationDefer = $q.defer(); var associatedLabelsDefer = $q.defer(); @@ -729,6 +906,20 @@ export default data.job_tags = (Array.isArray($scope.job_tags)) ? _.uniq($scope.job_tags).join() : ""; data.skip_tags = (Array.isArray($scope.skip_tags)) ? _.uniq($scope.skip_tags).join() : ""; + delete data.webhook_url; + delete data.webhook_key; + delete data.enable_webhook; + data.webhook_credential = $scope.webhookCredential.id; + + if (!data.webhook_service) { + data.webhook_credential = null; + } + + if (!$scope.enable_webhook) { + data.webhook_service = ''; + data.webhook_credential = null; + } + Rest.setUrl(defaultUrl + $state.params.job_template_id); Rest.patch(data) .then(({data}) => { diff --git a/awx/ui/client/src/templates/job_templates/job-template.form.js b/awx/ui/client/src/templates/job_templates/job-template.form.js index 83b2d953eb..07c473a996 100644 --- a/awx/ui/client/src/templates/job_templates/job-template.form.js +++ b/awx/ui/client/src/templates/job_templates/job-template.form.js @@ -338,6 +338,16 @@ function(NotificationsList, i18n) { dataTitle: i18n._('Allow Provisioning Callbacks'), dataContainer: "body", ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' + }, { + name: 'enable_webhook', + label: i18n._('Enable Webhook'), + type: 'checkbox', + column: 2, + awPopOver: "

" + i18n._("Enable webhook for this job template.") + "

", + dataPlacement: 'right', + dataTitle: i18n._('Enable Webhook'), + dataContainer: "body", + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, { name: 'allow_simultaneous', label: i18n._('Enable Concurrent Jobs'), @@ -391,6 +401,80 @@ function(NotificationsList, i18n) { alwaysShowAsterisk: true } }, + webhook_service: { + label: i18n._('Webhook Service'), + type:'select', + defaultText: i18n._('Choose a Webhook Service'), + ngOptions: 'svc.label for svc in webhook_service_options track by svc.value', + ngShow: "enable_webhook && enable_webhook !== 'false'", + ngDisabled: "!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate) || !canGetAllRelatedResources", + id: 'webhook-service-select', + column: 1, + awPopOver: "

" + i18n._("Select a webhook service.") + "

", + dataTitle: i18n._('Webhook Service'), + dataPlacement: 'right', + dataContainer: "body", + }, + webhook_url: { + label: i18n._('Webhook URL'), + type: 'text', + ngShow: "job_template_obj && enable_webhook && enable_webhook !== 'false'", + awPopOver: "webhook_url_help", + awPopOverWatch: "webhook_url_help", + dataPlacement: 'top', + dataTitle: i18n._('Webhook URL'), + dataContainer: "body", + readonly: true + }, + webhook_key: { + label: i18n._('Webhook Key'), + type: 'text', + ngShow: "enable_webhook && enable_webhook !== 'false'", + genHash: true, + genHashButtonTemplate: ` + + + + `, + genHashButtonClickHandlerName: "handleWebhookKeyButtonClick", + awPopOver: "webhook_key_help", + awPopOverWatch: "webhook_key_help", + dataPlacement: 'right', + dataTitle: i18n._("Webhook Key"), + dataContainer: "body", + readonly: true, + required: false, + }, + webhook_credential: { + label: i18n._('Webhook Credential'), + type: 'custom', + ngShow: "enable_webhook && enable_webhook !== 'false'", + control: ` + `, + awPopOver: "

" + i18n._("Optionally, select the credential to use to send status updates back to the webhook service") + "

", + dataTitle: i18n._('Webhook Credential'), + dataPlacement: 'right', + dataContainer: "body", + ngDisabled: '!(webhook_service || webhook_service.value)', + required: false, + }, extra_vars: { label: i18n._('Extra Variables'), type: 'textarea', diff --git a/awx/ui/client/src/templates/job_templates/main.js b/awx/ui/client/src/templates/job_templates/main.js index 0c096c0134..ee9538a3dd 100644 --- a/awx/ui/client/src/templates/job_templates/main.js +++ b/awx/ui/client/src/templates/job_templates/main.js @@ -1,13 +1,13 @@ import jobTemplateAdd from './add-job-template/main'; import jobTemplateEdit from './edit-job-template/main'; import multiCredential from './multi-credential/main'; +import webhookCredential from './webhook-credential'; import hashSetup from './factories/hash-setup.factory'; import CallbackHelpInit from './factories/callback-help-init.factory'; import JobTemplateForm from './job-template.form'; export default - angular.module('jobTemplates', [jobTemplateAdd.name, jobTemplateEdit.name, - multiCredential.name]) - .factory('hashSetup', hashSetup) - .factory('CallbackHelpInit', CallbackHelpInit) - .factory('JobTemplateForm', JobTemplateForm); + angular.module('jobTemplates', [jobTemplateAdd.name, jobTemplateEdit.name, multiCredential.name, webhookCredential.name]) + .factory('hashSetup', hashSetup) + .factory('CallbackHelpInit', CallbackHelpInit) + .factory('JobTemplateForm', JobTemplateForm); diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/index.js b/awx/ui/client/src/templates/job_templates/webhook-credential/index.js new file mode 100644 index 0000000000..97b634463a --- /dev/null +++ b/awx/ui/client/src/templates/job_templates/webhook-credential/index.js @@ -0,0 +1,4 @@ +import webhookCredentialInput from './webhook-credential-input.component'; + +export default angular.module('webhookCredential', []) + .component('webhookCredentialInput', webhookCredentialInput); diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/main.js b/awx/ui/client/src/templates/job_templates/webhook-credential/main.js new file mode 100644 index 0000000000..a91d79dbc6 --- /dev/null +++ b/awx/ui/client/src/templates/job_templates/webhook-credential/main.js @@ -0,0 +1,9 @@ +import webhookCredential from './webhook-credential.directive'; +import webhookCredentialModal from './webhook-credential-modal.directive'; +import webhookCredentialService from './webhook-credential.service'; + +export default + angular.module('webhookCredential', []) + .directive('webhookCredential', webhookCredential) + .directive('webhookCredentialModal', webhookCredentialModal) + .service('WebhookCredentialService', webhookCredentialService); diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.component.js b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.component.js new file mode 100644 index 0000000000..8f5d752948 --- /dev/null +++ b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.component.js @@ -0,0 +1,11 @@ +const templateUrl = require('~src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html'); +export default { + templateUrl, + controllerAs: 'vm', + bindings: { + isFieldDisabled: '<', + tagName: '<', + onLookupClick: '<', + onTagDelete: '<', + }, +}; diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html new file mode 100644 index 0000000000..f34c70b8e7 --- /dev/null +++ b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential-input.partial.html @@ -0,0 +1,43 @@ +
+ + + + +
+
+
+
+
+ +
+
+ +
+
+ + {{ vm.tagName }} + +
+
+ +
+
+
+
+
+
+
diff --git a/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential.block.less b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential.block.less new file mode 100644 index 0000000000..24467c7d0f --- /dev/null +++ b/awx/ui/client/src/templates/job_templates/webhook-credential/webhook-credential.block.less @@ -0,0 +1,113 @@ +.WebhookCredential-tags { + padding-left: 0px; +} + +.WebhookCredential-flexContainer { + display: flex; + width: 100%; + flex-wrap: wrap; +} + +.WebhookCredential-tagContainer { + display: flex; + max-width: 100%; + background-color: @default-link; + color: @default-bg; + border-radius: 5px; + padding: 0px 0px 0px 10px; + margin: 3px 10px 3px 0px; +} + +.WebhookCredential-tagContainer--disabled { + background-color: @default-icon; +} + +.WebhookCredential-tag { + font-size: 12px; + margin-right: 10px; + max-width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding: 2px 0px 2px 15px; +} + +.WebhookCredential-tag--disabled { + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + padding-left: 10px; +} + +.WebhookCredential-tag--deletable { + margin-right: 0px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border-right: 0; + max-width: ~"calc(100% - 23px)"; + padding-left: 10px; +} + +.WebhookCredential-deleteContainer { + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + padding: 2px 5px; + align-items: center; + display: flex; + cursor: pointer; +} + +.WebhookCredential-tagDelete { + font-size: 11px; +} + +.WebhookCredential-iconContainer { + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; + padding: 0px 5px; + margin: 3px 0px; + margin-left: -3px; + align-items: center; + display: flex; +} + +.WebhookCredential-iconContainer--disabled { + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + padding: 0 5px; + padding-left: 10px; + margin: 3px 0px; + align-items: center; + display: flex; +} + + +.WebhookCredential-tagIcon { + margin: 0px 0px; + font-size: 12px; +} + +.WebhookCredential-name { + flex: initial; + font-size: 12px; + max-width: 100%; +} + +.WebhookCredential-name--label { + color: @default-list-header-bg; + font-size: 12px; + margin-left: -8px; + margin-right: 5px; +} + +.WebhookCredential-tag--deletable > .WebhookCredential-name { + max-width: ~"calc(100% - 23px)"; +} + +.WebhookCredential-deleteContainer:hover { + border-color: @default-err; + background-color: @default-err!important; +} + +.WebhookCredential-deleteContainer:hover > .WebhookCredential-tagDelete { + color: @default-bg; +} diff --git a/awx/ui/client/src/templates/main.js b/awx/ui/client/src/templates/main.js index 4f301cdb91..fb4dab309a 100644 --- a/awx/ui/client/src/templates/main.js +++ b/awx/ui/client/src/templates/main.js @@ -296,7 +296,24 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p msg: i18n._('Failed to get organizations for which this user is a notification administrator. GET returned ') + status }); }); - }] + }], + webhookKey: ['Rest', 'ProcessErrors', 'jobTemplateData', 'i18n', + function(Rest, ProcessErrors, jobTemplateData, i18n) { + Rest.setUrl(jobTemplateData.related.webhook_key); + return Rest.get() + .then(({ data = {} }) => { + return data.webhook_key || ''; + }) + .catch(({data, status}) => { + if (status === 403) { + return; + } + ProcessErrors(null, data, status, null, { + hdr: i18n._('Error!'), + msg: i18n._('Failed to get webhook key GET returned ') + status + }); + }); + }], } } }); @@ -457,7 +474,24 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p msg: i18n._('Failed to get organizations for which this user is a notification administrator. GET returned ') + status }); }); - }] + }], + webhookKey: ['Rest', 'ProcessErrors', 'workflowJobTemplateData', 'i18n', + function(Rest, ProcessErrors, workflowJobTemplateData, i18n) { + Rest.setUrl(workflowJobTemplateData.related.webhook_key); + return Rest.get() + .then(({ data = {} }) => { + return data.webhook_key || ''; + }) + .catch(({data, status}) => { + if (status === 403) { + return; + } + ProcessErrors(null, data, status, null, { + hdr: i18n._('Error!'), + msg: i18n._('Failed to get webhook key GET returned ') + status + }); + }); + }], } } }); diff --git a/awx/ui/client/src/templates/workflows.form.js b/awx/ui/client/src/templates/workflows.form.js index 9124233c71..848ba7a471 100644 --- a/awx/ui/client/src/templates/workflows.form.js +++ b/awx/ui/client/src/templates/workflows.form.js @@ -162,8 +162,92 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n) { dataTitle: i18n._('Enable Concurrent Jobs'), dataContainer: "body", ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit)' + }, { + name: 'enable_webhook', + label: i18n._('Enable Webhook'), + type: 'checkbox', + column: 2, + awPopOver: "

" + i18n._("Enable webhook for this workflow job template.") + "

", + dataPlacement: 'right', + dataTitle: i18n._('Enable Webhook'), + dataContainer: "body", + ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit)' }] - } + }, + webhook_service: { + label: i18n._('Webhook Service'), + type:'select', + defaultText: i18n._('Choose a Webhook Service'), + ngOptions: 'svc.label for svc in webhook_service_options track by svc.value', + ngShow: "enable_webhook && enable_webhook !== 'false'", + ngDisabled: "!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddOrEdit)", + id: 'webhook-service-select', + column: 1, + awPopOver: "

" + i18n._("Select a webhook service.") + "

", + dataTitle: i18n._('Webhook Service'), + dataPlacement: 'right', + dataContainer: "body", + }, + webhook_url: { + label: i18n._('Webhook URL'), + type: 'text', + ngShow: "workflow_job_template_obj && enable_webhook && enable_webhook !== 'false'", + awPopOver: "webhook_url_help", + awPopOverWatch: "webhook_url_help", + dataPlacement: 'top', + dataTitle: i18n._('Webhook URL'), + dataContainer: "body", + readonly: true + }, + webhook_key: { + label: i18n._('Webhook Key'), + type: 'text', + ngShow: "enable_webhook && enable_webhook !== 'false'", + genHash: true, + genHashButtonTemplate: ` + + + + `, + genHashButtonClickHandlerName: "handleWebhookKeyButtonClick", + awPopOver: "webhook_key_help", + awPopOverWatch: "webhook_key_help", + dataPlacement: 'right', + dataTitle: i18n._("Webhook Key"), + dataContainer: "body", + readonly: true, + required: false, + }, + webhook_credential: { + label: i18n._('Webhook Credential'), + type: 'custom', + ngShow: "enable_webhook && enable_webhook !== 'false'", + control: ` + `, + awPopOver: "

" + i18n._("Select the credential to use with the webhook service.") + "

", + dataTitle: i18n._('Webhook Credential'), + dataPlacement: 'right', + dataContainer: "body", + ngDisabled: '!webhook_service.value', + required: false, + }, }, buttons: { //for now always generates