diff --git a/.gitignore b/.gitignore index be22f947ae..ac443e2acd 100644 --- a/.gitignore +++ b/.gitignore @@ -135,9 +135,10 @@ use_dev_supervisor.txt # Ansible module tests -awx_collection_test_venv/ -awx_collection/*.tar.gz -awx_collection/galaxy.yml +/awx_collection_test_venv/ +/awx_collection/*.tar.gz +/awx_collection/galaxy.yml +/sanity/ .idea/* *.unison.tmp diff --git a/Makefile b/Makefile index 0a93a8696b..7ce3323339 100644 --- a/Makefile +++ b/Makefile @@ -399,6 +399,13 @@ flake8_collection: test_collection_all: prepare_collection_venv test_collection flake8_collection +test_collection_sanity: + rm -rf sanity + mkdir -p sanity/ansible_collections/awx + cp -Ra awx_collection sanity/ansible_collections/awx/awx # symlinks do not work + cd sanity/ansible_collections/awx/awx && git init && git add . # requires both this file structure and a git repo, so there you go + cd sanity/ansible_collections/awx/awx && ansible-test sanity --test validate-modules + build_collection: ansible-playbook -i localhost, awx_collection/template_galaxy.yml -e collection_package=$(COLLECTION_PACKAGE) -e collection_namespace=$(COLLECTION_NAMESPACE) -e collection_version=$(VERSION) ansible-galaxy collection build awx_collection --output-path=awx_collection diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 818160ac3b..a3d8d43306 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4338,13 +4338,30 @@ class NotificationTemplateSerializer(BaseSerializer): error_list = [] collected_messages = [] + def check_messages(messages): + for message_type in messages: + if message_type not in ('message', 'body'): + error_list.append(_("Message type '{}' invalid, must be either 'message' or 'body'").format(message_type)) + continue + message = messages[message_type] + if message is None: + continue + if not isinstance(message, str): + error_list.append(_("Expected string for '{}', found {}, ").format(message_type, type(message))) + continue + if message_type == 'message': + if '\n' in message: + error_list.append(_("Messages cannot contain newlines (found newline in {} event)".format(event))) + continue + collected_messages.append(message) + # Validate structure / content types if not isinstance(messages, dict): error_list.append(_("Expected dict for 'messages' field, found {}".format(type(messages)))) else: for event in messages: - if event not in ['started', 'success', 'error']: - error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', or 'error'").format(event)) + if event not in ('started', 'success', 'error', 'workflow_approval'): + error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', 'error', or 'workflow_approval'").format(event)) continue event_messages = messages[event] if event_messages is None: @@ -4352,21 +4369,21 @@ class NotificationTemplateSerializer(BaseSerializer): if not isinstance(event_messages, dict): error_list.append(_("Expected dict for event '{}', found {}").format(event, type(event_messages))) continue - for message_type in event_messages: - if message_type not in ['message', 'body']: - error_list.append(_("Message type '{}' invalid, must be either 'message' or 'body'").format(message_type)) - continue - message = event_messages[message_type] - if message is None: - continue - if not isinstance(message, str): - error_list.append(_("Expected string for '{}', found {}, ").format(message_type, type(message))) - continue - if message_type == 'message': - if '\n' in message: - error_list.append(_("Messages cannot contain newlines (found newline in {} event)".format(event))) + if event == 'workflow_approval': + for subevent in event_messages: + if subevent not in ('running', 'approved', 'timed_out', 'denied'): + error_list.append(_("Workflow Approval event '{}' invalid, must be one of " + "'running', 'approved', 'timed_out', or 'denied'").format(subevent)) continue - collected_messages.append(message) + subevent_messages = event_messages[subevent] + if subevent_messages is None: + continue + if not isinstance(subevent_messages, dict): + error_list.append(_("Expected dict for workflow approval event '{}', found {}").format(subevent, type(subevent_messages))) + continue + check_messages(subevent_messages) + else: + check_messages(event_messages) # Subclass to return name of undefined field class DescriptiveUndefined(StrictUndefined): @@ -4497,8 +4514,18 @@ class NotificationSerializer(BaseSerializer): 'notification_type', 'recipients', 'subject', 'body') def get_body(self, obj): - if obj.notification_type == 'webhook' and 'body' in obj.body: - return obj.body['body'] + if obj.notification_type in ('webhook', 'pagerduty'): + if isinstance(obj.body, dict): + if 'body' in obj.body: + return obj.body['body'] + elif isinstance(obj.body, str): + # attempt to load json string + try: + potential_body = json.loads(obj.body) + if isinstance(potential_body, dict): + return potential_body + except json.JSONDecodeError: + pass return obj.body def get_related(self, obj): @@ -4774,6 +4801,18 @@ class InstanceGroupSerializer(BaseSerializer): raise serializers.ValidationError(_('Isolated instances may not be added or removed from instances groups via the API.')) if self.instance and self.instance.controller_id is not None: raise serializers.ValidationError(_('Isolated instance group membership may not be managed via the API.')) + if value and self.instance and self.instance.is_containerized: + raise serializers.ValidationError(_('Containerized instances may not be managed via the API')) + return value + + def validate_policy_instance_percentage(self, value): + if value and self.instance and self.instance.is_containerized: + raise serializers.ValidationError(_('Containerized instances may not be managed via the API')) + return value + + def validate_policy_instance_minimum(self, value): + if value and self.instance and self.instance.is_containerized: + raise serializers.ValidationError(_('Containerized instances may not be managed via the API')) return value def validate_name(self, value): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index f337345df9..9d0e19b95e 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -102,7 +102,7 @@ from awx.main.scheduler.dag_workflow import WorkflowDAG from awx.api.views.mixin import ( ControlledByScmMixin, InstanceGroupMembershipMixin, OrganizationCountsMixin, RelatedJobsPreventDeleteMixin, - UnifiedJobDeletionMixin, + UnifiedJobDeletionMixin, NoTruncateMixin, ) from awx.api.views.organization import ( # noqa OrganizationList, @@ -383,6 +383,13 @@ class InstanceGroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAP serializer_class = serializers.InstanceGroupSerializer permission_classes = (InstanceGroupTowerPermission,) + def update_raw_data(self, data): + if self.get_object().is_containerized: + data.pop('policy_instance_percentage', None) + data.pop('policy_instance_minimum', None) + data.pop('policy_instance_list', None) + return super(InstanceGroupDetail, self).update_raw_data(data) + def destroy(self, request, *args, **kwargs): instance = self.get_object() if instance.controller is not None: @@ -2136,12 +2143,21 @@ class InventorySourceHostsList(HostRelatedSearchMixin, SubListDestroyAPIView): def perform_list_destroy(self, instance_list): inv_source = self.get_parent_object() with ignore_inventory_computed_fields(): - # Activity stream doesn't record disassociation here anyway - # no signals-related reason to not bulk-delete - models.Host.groups.through.objects.filter( - host__inventory_sources=inv_source - ).delete() - r = super(InventorySourceHostsList, self).perform_list_destroy(instance_list) + if not settings.ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: + from awx.main.signals import disable_activity_stream + with disable_activity_stream(): + # job host summary deletion necessary to avoid deadlock + models.JobHostSummary.objects.filter(host__inventory_sources=inv_source).update(host=None) + models.Host.objects.filter(inventory_sources=inv_source).delete() + r = super(InventorySourceHostsList, self).perform_list_destroy([]) + else: + # Advance delete of group-host memberships to prevent deadlock + # Activity stream doesn't record disassociation here anyway + # no signals-related reason to not bulk-delete + models.Host.groups.through.objects.filter( + host__inventory_sources=inv_source + ).delete() + r = super(InventorySourceHostsList, self).perform_list_destroy(instance_list) update_inventory_computed_fields.delay(inv_source.inventory_id, True) return r @@ -2157,11 +2173,18 @@ class InventorySourceGroupsList(SubListDestroyAPIView): def perform_list_destroy(self, instance_list): inv_source = self.get_parent_object() with ignore_inventory_computed_fields(): - # Same arguments for bulk delete as with host list - models.Group.hosts.through.objects.filter( - group__inventory_sources=inv_source - ).delete() - r = super(InventorySourceGroupsList, self).perform_list_destroy(instance_list) + if not settings.ACTIVITY_STREAM_ENABLED_FOR_INVENTORY_SYNC: + from awx.main.signals import disable_activity_stream + with disable_activity_stream(): + models.Group.objects.filter(inventory_sources=inv_source).delete() + r = super(InventorySourceGroupsList, self).perform_list_destroy([]) + else: + # Advance delete of group-host memberships to prevent deadlock + # Same arguments for bulk delete as with host list + models.Group.hosts.through.objects.filter( + group__inventory_sources=inv_source + ).delete() + r = super(InventorySourceGroupsList, self).perform_list_destroy(instance_list) update_inventory_computed_fields.delay(inv_source.inventory_id, True) return r @@ -3762,18 +3785,12 @@ class JobHostSummaryDetail(RetrieveAPIView): serializer_class = serializers.JobHostSummarySerializer -class JobEventList(ListAPIView): +class JobEventList(NoTruncateMixin, ListAPIView): model = models.JobEvent serializer_class = serializers.JobEventSerializer search_fields = ('stdout',) - def get_serializer_context(self): - context = super().get_serializer_context() - if self.request.query_params.get('no_truncate'): - context.update(no_truncate=True) - return context - class JobEventDetail(RetrieveAPIView): @@ -3786,7 +3803,7 @@ class JobEventDetail(RetrieveAPIView): return context -class JobEventChildrenList(SubListAPIView): +class JobEventChildrenList(NoTruncateMixin, SubListAPIView): model = models.JobEvent serializer_class = serializers.JobEventSerializer @@ -3811,7 +3828,7 @@ class JobEventHostsList(HostRelatedSearchMixin, SubListAPIView): name = _('Job Event Hosts List') -class BaseJobEventsList(SubListAPIView): +class BaseJobEventsList(NoTruncateMixin, SubListAPIView): model = models.JobEvent serializer_class = serializers.JobEventSerializer @@ -4007,18 +4024,12 @@ class AdHocCommandRelaunch(GenericAPIView): return Response(data, status=status.HTTP_201_CREATED, headers=headers) -class AdHocCommandEventList(ListAPIView): +class AdHocCommandEventList(NoTruncateMixin, ListAPIView): model = models.AdHocCommandEvent serializer_class = serializers.AdHocCommandEventSerializer search_fields = ('stdout',) - def get_serializer_context(self): - context = super().get_serializer_context() - if self.request.query_params.get('no_truncate'): - context.update(no_truncate=True) - return context - class AdHocCommandEventDetail(RetrieveAPIView): @@ -4031,7 +4042,7 @@ class AdHocCommandEventDetail(RetrieveAPIView): return context -class BaseAdHocCommandEventsList(SubListAPIView): +class BaseAdHocCommandEventsList(NoTruncateMixin, SubListAPIView): model = models.AdHocCommandEvent serializer_class = serializers.AdHocCommandEventSerializer @@ -4297,8 +4308,15 @@ class NotificationTemplateTest(GenericAPIView): def post(self, request, *args, **kwargs): obj = self.get_object() - notification = obj.generate_notification("Tower Notification Test {} {}".format(obj.id, settings.TOWER_URL_BASE), - {"body": "Ansible Tower Test Notification {} {}".format(obj.id, settings.TOWER_URL_BASE)}) + msg = "Tower Notification Test {} {}".format(obj.id, settings.TOWER_URL_BASE) + if obj.notification_type in ('email', 'pagerduty'): + body = "Ansible Tower Test Notification {} {}".format(obj.id, settings.TOWER_URL_BASE) + elif obj.notification_type == 'webhook': + body = '{{"body": "Ansible Tower Test Notification {} {}"}}'.format(obj.id, settings.TOWER_URL_BASE) + else: + body = {"body": "Ansible Tower Test Notification {} {}".format(obj.id, settings.TOWER_URL_BASE)} + notification = obj.generate_notification(msg, body) + if not notification: return Response({}, status=status.HTTP_400_BAD_REQUEST) else: diff --git a/awx/api/views/mixin.py b/awx/api/views/mixin.py index 546b7090f2..e7d4959dfc 100644 --- a/awx/api/views/mixin.py +++ b/awx/api/views/mixin.py @@ -270,3 +270,11 @@ class ControlledByScmMixin(object): obj = super(ControlledByScmMixin, self).get_parent_object() self._reset_inv_src_rev(obj) return obj + + +class NoTruncateMixin(object): + def get_serializer_context(self): + context = super().get_serializer_context() + if self.request.query_params.get('no_truncate'): + context.update(no_truncate=True) + return context diff --git a/awx/api/views/webhooks.py b/awx/api/views/webhooks.py index e3ed6e64c9..6be88a316b 100644 --- a/awx/api/views/webhooks.py +++ b/awx/api/views/webhooks.py @@ -1,6 +1,5 @@ from hashlib import sha1 import hmac -import json import logging import urllib.parse @@ -151,13 +150,13 @@ class WebhookReceiverBase(APIView): 'webhook_credential': obj.webhook_credential, 'webhook_guid': event_guid, }, - 'extra_vars': json.dumps({ + 'extra_vars': { '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) diff --git a/awx/main/access.py b/awx/main/access.py index 3eefb08723..7f9be65333 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -465,7 +465,7 @@ class BaseAccess(object): else: relationship = 'members' return access_method(obj, parent_obj, relationship, skip_sub_obj_read_check=True, data={}) - except (ParseError, ObjectDoesNotExist): + except (ParseError, ObjectDoesNotExist, PermissionDenied): return False return False @@ -1660,26 +1660,19 @@ class JobAccess(BaseAccess): except JobLaunchConfig.DoesNotExist: config = None + if obj.job_template and (self.user not in obj.job_template.execute_role): + return False + # Check if JT execute access (and related prompts) is sufficient - if obj.job_template is not None: - if config is None: - prompts_access = False - elif not config.has_user_prompts(obj.job_template): - prompts_access = True - elif obj.created_by_id != self.user.pk and vars_are_encrypted(config.extra_data): - prompts_access = False - if self.save_messages: - self.messages['detail'] = _('Job was launched with secret prompts provided by another user.') - else: - prompts_access = ( - JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}) and - not config.has_unprompted(obj.job_template) - ) - jt_access = self.user in obj.job_template.execute_role - if prompts_access and jt_access: + if config and obj.job_template: + if not config.has_user_prompts(obj.job_template): return True - elif not jt_access: - return False + elif obj.created_by_id != self.user.pk and vars_are_encrypted(config.extra_data): + # never allowed, not even for org admins + raise PermissionDenied(_('Job was launched with secret prompts provided by another user.')) + elif not config.has_unprompted(obj.job_template): + if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}): + return True org_access = bool(obj.inventory) and self.user in obj.inventory.organization.inventory_admin_role project_access = obj.project is None or self.user in obj.project.admin_role @@ -2098,23 +2091,20 @@ class WorkflowJobAccess(BaseAccess): self.messages['detail'] = _('Workflow Job was launched with unknown prompts.') return False + # execute permission to WFJT is mandatory for any relaunch + if self.user not in template.execute_role: + return False + # Check if access to prompts to prevent relaunch if config.prompts_dict(): if obj.created_by_id != self.user.pk and vars_are_encrypted(config.extra_data): - if self.save_messages: - self.messages['detail'] = _('Job was launched with secret prompts provided by another user.') - return False + raise PermissionDenied(_("Job was launched with secret prompts provided by another user.")) if not JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}): - if self.save_messages: - self.messages['detail'] = _('Job was launched with prompts you lack access to.') - return False + raise PermissionDenied(_('Job was launched with prompts you lack access to.')) if config.has_unprompted(template): - if self.save_messages: - self.messages['detail'] = _('Job was launched with prompts no longer accepted.') - return False + raise PermissionDenied(_('Job was launched with prompts no longer accepted.')) - # execute permission to WFJT is mandatory for any relaunch - return (self.user in template.execute_role) + return True # passed config checks def can_recreate(self, obj): node_qs = obj.workflow_job_nodes.all().prefetch_related('inventory', 'credentials', 'unified_job_template') diff --git a/awx/main/conf.py b/awx/main/conf.py index cce5e0a5de..f63fee564f 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -513,6 +513,16 @@ register( category_slug='jobs' ) +register( + 'PUBLIC_GALAXY_ENABLED', + field_class=fields.BooleanField, + default=True, + label=_('Allow Access to Public Galaxy'), + help_text=_('Allow or deny access to the public Ansible Galaxy during project updates.'), + category=_('Jobs'), + category_slug='jobs' +) + register( 'STDOUT_MAX_BYTES_DISPLAY', field_class=fields.IntegerField, diff --git a/awx/main/dispatch/worker/task.py b/awx/main/dispatch/worker/task.py index d3817fa196..7e7437d445 100644 --- a/awx/main/dispatch/worker/task.py +++ b/awx/main/dispatch/worker/task.py @@ -4,6 +4,7 @@ import importlib import sys import traceback +from kubernetes.config import kube_config from awx.main.tasks import dispatch_startup, inform_cluster_of_shutdown @@ -107,6 +108,14 @@ class TaskWorker(BaseWorker): for callback in body.get('errbacks', []) or []: callback['uuid'] = body['uuid'] self.perform_work(callback) + finally: + # It's frustrating that we have to do this, but the python k8s + # client leaves behind cacert files in /tmp, so we must clean up + # the tmpdir per-dispatcher process every time a new task comes in + try: + kube_config._cleanup_temp_files() + except Exception: + logger.exception('failed to cleanup k8s client tmp files') for callback in body.get('callbacks', []) or []: callback['uuid'] = body['uuid'] diff --git a/awx/main/isolated/manager.py b/awx/main/isolated/manager.py index 5a0555ed72..642ba373a4 100644 --- a/awx/main/isolated/manager.py +++ b/awx/main/isolated/manager.py @@ -6,6 +6,7 @@ import stat import tempfile import time import logging +import yaml from django.conf import settings import ansible_runner @@ -48,10 +49,17 @@ class IsolatedManager(object): def build_inventory(self, hosts): if self.instance and self.instance.is_containerized: inventory = {'all': {'hosts': {}}} + fd, path = tempfile.mkstemp( + prefix='.kubeconfig', dir=self.private_data_dir + ) + with open(path, 'wb') as temp: + temp.write(yaml.dump(self.pod_manager.kube_config).encode()) + temp.flush() + os.chmod(temp.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) for host in hosts: inventory['all']['hosts'][host] = { "ansible_connection": "kubectl", - "ansible_kubectl_config": self.pod_manager.kube_config + "ansible_kubectl_config": path, } else: inventory = '\n'.join([ @@ -143,6 +151,8 @@ class IsolatedManager(object): '- /artifacts/job_events/*-partial.json.tmp', # don't rsync the ssh_key FIFO '- /env/ssh_key', + # don't rsync kube config files + '- .kubeconfig*' ] for filename, data in ( diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 70fa92cf0a..915b18977f 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -295,7 +295,10 @@ class PrimordialModel(HasEditsMixin, CreatedModifiedModel): def __init__(self, *args, **kwargs): r = super(PrimordialModel, self).__init__(*args, **kwargs) - self._prior_values_store = self._get_fields_snapshot() + if self.pk: + self._prior_values_store = self._get_fields_snapshot() + else: + self._prior_values_store = {} return r def save(self, *args, **kwargs): diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index ef428bcdfa..9163e57e92 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -73,7 +73,7 @@ class NotificationTemplate(CommonModelNameNotUnique): notification_configuration = prevent_search(JSONField(blank=False)) def default_messages(): - return {'started': None, 'success': None, 'error': None} + return {'started': None, 'success': None, 'error': None, 'workflow_approval': None} messages = JSONField( null=True, @@ -92,25 +92,6 @@ class NotificationTemplate(CommonModelNameNotUnique): def get_message(self, condition): return self.messages.get(condition, {}) - def build_notification_message(self, event_type, context): - env = sandbox.ImmutableSandboxedEnvironment() - templates = self.get_message(event_type) - msg_template = templates.get('message', {}) - - try: - notification_subject = env.from_string(msg_template).render(**context) - except (TemplateSyntaxError, UndefinedError, SecurityError): - notification_subject = '' - - - msg_body = templates.get('body', {}) - try: - notification_body = env.from_string(msg_body).render(**context) - except (TemplateSyntaxError, UndefinedError, SecurityError): - notification_body = '' - - return (notification_subject, notification_body) - def get_absolute_url(self, request=None): return reverse('api:notification_template_detail', kwargs={'pk': self.pk}, request=request) @@ -128,19 +109,34 @@ class NotificationTemplate(CommonModelNameNotUnique): old_messages = old_nt.messages new_messages = self.messages + def merge_messages(local_old_messages, local_new_messages, local_event): + if local_new_messages.get(local_event, {}) and local_old_messages.get(local_event, {}): + local_old_event_msgs = local_old_messages[local_event] + local_new_event_msgs = local_new_messages[local_event] + for msg_type in ['message', 'body']: + if msg_type not in local_new_event_msgs and local_old_event_msgs.get(msg_type, None): + local_new_event_msgs[msg_type] = local_old_event_msgs[msg_type] if old_messages is not None and new_messages is not None: - for event in ['started', 'success', 'error']: + for event in ('started', 'success', 'error', 'workflow_approval'): if not new_messages.get(event, {}) and old_messages.get(event, {}): new_messages[event] = old_messages[event] continue - if new_messages.get(event, {}) and old_messages.get(event, {}): - old_event_msgs = old_messages[event] - new_event_msgs = new_messages[event] - for msg_type in ['message', 'body']: - if msg_type not in new_event_msgs and old_event_msgs.get(msg_type, None): - new_event_msgs[msg_type] = old_event_msgs[msg_type] + + if event == 'workflow_approval' and old_messages.get('workflow_approval', None): + new_messages.setdefault('workflow_approval', {}) + for subevent in ('running', 'approved', 'timed_out', 'denied'): + old_wfa_messages = old_messages['workflow_approval'] + new_wfa_messages = new_messages['workflow_approval'] + if not new_wfa_messages.get(subevent, {}) and old_wfa_messages.get(subevent, {}): + new_wfa_messages[subevent] = old_wfa_messages[subevent] + continue + if old_wfa_messages: + merge_messages(old_wfa_messages, new_wfa_messages, subevent) + else: + merge_messages(old_messages, new_messages, event) new_messages.setdefault(event, None) + for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password", self.notification_class.init_parameters): if self.notification_configuration[field].startswith("$encrypted$"): @@ -169,12 +165,12 @@ class NotificationTemplate(CommonModelNameNotUnique): def recipients(self): return self.notification_configuration[self.notification_class.recipient_parameter] - def generate_notification(self, subject, message): + def generate_notification(self, msg, body): notification = Notification(notification_template=self, notification_type=self.notification_type, recipients=smart_str(self.recipients), - subject=subject, - body=message) + subject=msg, + body=body) notification.save() return notification @@ -370,7 +366,7 @@ class JobNotificationMixin(object): 'verbosity': 0}, 'job_friendly_name': 'Job', 'url': 'https://towerhost/#/jobs/playbook/1010', - 'job_summary_dict': """{'url': 'https://towerhost/$/jobs/playbook/13', + 'job_metadata': """{'url': 'https://towerhost/$/jobs/playbook/13', 'traceback': '', 'status': 'running', 'started': '2019-08-07T21:46:38.362630+00:00', @@ -389,14 +385,14 @@ class JobNotificationMixin(object): return context def context(self, serialized_job): - """Returns a context that can be used for rendering notification messages. - Context contains whitelisted content retrieved from a serialized job object + """Returns a dictionary that can be used for rendering notification messages. + The context will contain whitelisted content retrieved from a serialized job object (see JobNotificationMixin.JOB_FIELDS_WHITELIST), the job's friendly name, and a url to the job run.""" context = {'job': {}, 'job_friendly_name': self.get_notification_friendly_name(), 'url': self.get_ui_url(), - 'job_summary_dict': json.dumps(self.notification_data(), indent=4)} + 'job_metadata': json.dumps(self.notification_data(), indent=4)} def build_context(node, fields, whitelisted_fields): for safe_field in whitelisted_fields: @@ -434,32 +430,33 @@ class JobNotificationMixin(object): context = self.context(job_serialization) msg_template = body_template = None + msg = body = '' + # Use custom template if available if nt.messages: - templates = nt.messages.get(self.STATUS_TO_TEMPLATE_TYPE[status], {}) or {} - msg_template = templates.get('message', {}) - body_template = templates.get('body', {}) + template = nt.messages.get(self.STATUS_TO_TEMPLATE_TYPE[status], {}) or {} + msg_template = template.get('message', None) + body_template = template.get('body', None) + # If custom template not provided, look up default template + default_template = nt.notification_class.default_messages[self.STATUS_TO_TEMPLATE_TYPE[status]] + if not msg_template: + msg_template = default_template.get('message', None) + if not body_template: + body_template = default_template.get('body', None) if msg_template: try: - notification_subject = env.from_string(msg_template).render(**context) + msg = env.from_string(msg_template).render(**context) except (TemplateSyntaxError, UndefinedError, SecurityError): - notification_subject = '' - else: - notification_subject = u"{} #{} '{}' {}: {}".format(self.get_notification_friendly_name(), - self.id, - self.name, - status, - self.get_ui_url()) - notification_body = self.notification_data() - notification_body['friendly_name'] = self.get_notification_friendly_name() + msg = '' + if body_template: try: - notification_body['body'] = env.from_string(body_template).render(**context) + body = env.from_string(body_template).render(**context) except (TemplateSyntaxError, UndefinedError, SecurityError): - notification_body['body'] = '' + body = '' - return (notification_subject, notification_body) + return (msg, body) def send_notification_templates(self, status): from awx.main.tasks import send_notifications # avoid circular import @@ -475,16 +472,13 @@ class JobNotificationMixin(object): return for nt in set(notification_templates.get(self.STATUS_TO_TEMPLATE_TYPE[status], [])): - try: - (notification_subject, notification_body) = self.build_notification_message(nt, status) - except AttributeError: - raise NotImplementedError("build_notification_message() does not exist" % status) + (msg, body) = self.build_notification_message(nt, status) # Use kwargs to force late-binding # https://stackoverflow.com/a/3431699/10669572 - def send_it(local_nt=nt, local_subject=notification_subject, local_body=notification_body): + def send_it(local_nt=nt, local_msg=msg, local_body=body): def _func(): - send_notifications.delay([local_nt.generate_notification(local_subject, local_body).id], + send_notifications.delay([local_nt.generate_notification(local_msg, local_body).id], job_id=self.id) return _func connection.on_commit(send_it()) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index bcfee585b8..100ba1c323 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -2,6 +2,7 @@ # All Rights Reserved. # Python +import json import logging from copy import copy from urllib.parse import urljoin @@ -16,6 +17,9 @@ from django.core.exceptions import ObjectDoesNotExist # Django-CRUM from crum import get_current_user +from jinja2 import sandbox +from jinja2.exceptions import TemplateSyntaxError, UndefinedError, SecurityError + # AWX from awx.api.versioning import reverse from awx.main.models import (prevent_search, accepts_json, UnifiedJobTemplate, @@ -763,22 +767,45 @@ class WorkflowApproval(UnifiedJob, JobNotificationMixin): connection.on_commit(send_it()) def build_approval_notification_message(self, nt, approval_status): - subject = [] - workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) - subject.append(('The approval node "{}"').format(self.workflow_approval_template.name)) - if approval_status == 'running': - subject.append(('needs review. This node can be viewed at: {}').format(workflow_url)) - if approval_status == 'approved': - subject.append(('was approved. {}').format(workflow_url)) - if approval_status == 'timed_out': - subject.append(('has timed out. {}').format(workflow_url)) - elif approval_status == 'denied': - subject.append(('was denied. {}').format(workflow_url)) - subject = " ".join(subject) - body = self.notification_data() - body['body'] = subject + env = sandbox.ImmutableSandboxedEnvironment() - return subject, body + context = self.context(approval_status) + + msg_template = body_template = None + msg = body = '' + + # Use custom template if available + if nt.messages and nt.messages.get('workflow_approval', None): + template = nt.messages['workflow_approval'].get(approval_status, {}) + msg_template = template.get('message', None) + body_template = template.get('body', None) + # If custom template not provided, look up default template + default_template = nt.notification_class.default_messages['workflow_approval'][approval_status] + if not msg_template: + msg_template = default_template.get('message', None) + if not body_template: + body_template = default_template.get('body', None) + + if msg_template: + try: + msg = env.from_string(msg_template).render(**context) + except (TemplateSyntaxError, UndefinedError, SecurityError): + msg = '' + + if body_template: + try: + body = env.from_string(body_template).render(**context) + except (TemplateSyntaxError, UndefinedError, SecurityError): + body = '' + + return (msg, body) + + def context(self, approval_status): + workflow_url = urljoin(settings.TOWER_URL_BASE, '/#/workflows/{}'.format(self.workflow_job.id)) + return {'approval_status': approval_status, + 'approval_node_name': self.workflow_approval_template.name, + 'workflow_url': workflow_url, + 'job_metadata': json.dumps(self.notification_data(), indent=4)} @property def workflow_job_template(self): diff --git a/awx/main/notifications/base.py b/awx/main/notifications/base.py index bb5ac7a9ca..66ac369cbc 100644 --- a/awx/main/notifications/base.py +++ b/awx/main/notifications/base.py @@ -1,21 +1,10 @@ # Copyright (c) 2016 Ansible, Inc. # All Rights Reserved. -import json - -from django.utils.encoding import smart_text from django.core.mail.backends.base import BaseEmailBackend -from django.utils.translation import ugettext_lazy as _ class AWXBaseEmailBackend(BaseEmailBackend): def format_body(self, body): - if "body" in body: - body_actual = body['body'] - else: - body_actual = smart_text(_("{} #{} had status {}, view details at {}\n\n").format( - body['friendly_name'], body['id'], body['status'], body['url']) - ) - body_actual += json.dumps(body, indent=4) - return body_actual + return body diff --git a/awx/main/notifications/custom_notification_base.py b/awx/main/notifications/custom_notification_base.py new file mode 100644 index 0000000000..b7038ec867 --- /dev/null +++ b/awx/main/notifications/custom_notification_base.py @@ -0,0 +1,20 @@ +# Copyright (c) 2019 Ansible, Inc. +# All Rights Reserved. + + +class CustomNotificationBase(object): + DEFAULT_MSG = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" + DEFAULT_BODY = "{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_metadata }}" + + default_messages = {"started": {"message": DEFAULT_MSG, "body": None}, + "success": {"message": DEFAULT_MSG, "body": None}, + "error": {"message": DEFAULT_MSG, "body": None}, + "workflow_approval": {"running": {"message": 'The approval node "{{ approval_node_name }}" needs review. ' + 'This node can be viewed at: {{ workflow_url }}', + "body": None}, + "approved": {"message": 'The approval node "{{ approval_node_name }}" was approved. {{ workflow_url }}', + "body": None}, + "timed_out": {"message": 'The approval node "{{ approval_node_name }}" has timed out. {{ workflow_url }}', + "body": None}, + "denied": {"message": 'The approval node "{{ approval_node_name }}" was denied. {{ workflow_url }}', + "body": None}}} diff --git a/awx/main/notifications/email_backend.py b/awx/main/notifications/email_backend.py index 8abce4fff1..2b9c7d8d58 100644 --- a/awx/main/notifications/email_backend.py +++ b/awx/main/notifications/email_backend.py @@ -1,14 +1,15 @@ # Copyright (c) 2016 Ansible, Inc. # All Rights Reserved. -import json - -from django.utils.encoding import smart_text from django.core.mail.backends.smtp import EmailBackend -from django.utils.translation import ugettext_lazy as _ + +from awx.main.notifications.custom_notification_base import CustomNotificationBase + +DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG +DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY -class CustomEmailBackend(EmailBackend): +class CustomEmailBackend(EmailBackend, CustomNotificationBase): init_parameters = {"host": {"label": "Host", "type": "string"}, "port": {"label": "Port", "type": "int"}, @@ -19,22 +20,17 @@ class CustomEmailBackend(EmailBackend): "sender": {"label": "Sender Email", "type": "string"}, "recipients": {"label": "Recipient List", "type": "list"}, "timeout": {"label": "Timeout", "type": "int", "default": 30}} - - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - DEFAULT_BODY = smart_text(_("{{ job_friendly_name }} #{{ job.id }} had status {{ job.status }}, view details at {{ url }}\n\n{{ job_summary_dict }}")) - default_messages = {"started": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}, - "success": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}, - "error": {"message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}} recipient_parameter = "recipients" sender_parameter = "sender" + default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "approved": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}} def format_body(self, body): - if "body" in body: - body_actual = body['body'] - else: - body_actual = smart_text(_("{} #{} had status {}, view details at {}\n\n").format( - body['friendly_name'], body['id'], body['status'], body['url']) - ) - body_actual += json.dumps(body, indent=4) - return body_actual + # leave body unchanged (expect a string) + return body diff --git a/awx/main/notifications/grafana_backend.py b/awx/main/notifications/grafana_backend.py index ccd176e4f4..58137f27aa 100644 --- a/awx/main/notifications/grafana_backend.py +++ b/awx/main/notifications/grafana_backend.py @@ -8,24 +8,21 @@ import dateutil.parser as dp from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend +from awx.main.notifications.custom_notification_base import CustomNotificationBase logger = logging.getLogger('awx.main.notifications.grafana_backend') -class GrafanaBackend(AWXBaseEmailBackend): +class GrafanaBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"grafana_url": {"label": "Grafana URL", "type": "string"}, "grafana_key": {"label": "Grafana API Key", "type": "password"}} recipient_parameter = "grafana_url" sender_parameter = None - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - default_messages = {"started": {"message": DEFAULT_SUBJECT}, - "success": {"message": DEFAULT_SUBJECT}, - "error": {"message": DEFAULT_SUBJECT}} - def __init__(self, grafana_key,dashboardId=None, panelId=None, annotation_tags=None, grafana_no_verify_ssl=False, isRegion=True, fail_silently=False, **kwargs): super(GrafanaBackend, self).__init__(fail_silently=fail_silently) diff --git a/awx/main/notifications/hipchat_backend.py b/awx/main/notifications/hipchat_backend.py index 0fa53b4471..16790644a3 100644 --- a/awx/main/notifications/hipchat_backend.py +++ b/awx/main/notifications/hipchat_backend.py @@ -7,12 +7,14 @@ import requests from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend +from awx.main.notifications.custom_notification_base import CustomNotificationBase logger = logging.getLogger('awx.main.notifications.hipchat_backend') -class HipChatBackend(AWXBaseEmailBackend): +class HipChatBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"token": {"label": "Token", "type": "password"}, "rooms": {"label": "Destination Rooms", "type": "list"}, @@ -23,11 +25,6 @@ class HipChatBackend(AWXBaseEmailBackend): recipient_parameter = "rooms" sender_parameter = "message_from" - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - default_messages = {"started": {"message": DEFAULT_SUBJECT}, - "success": {"message": DEFAULT_SUBJECT}, - "error": {"message": DEFAULT_SUBJECT}} - def __init__(self, token, color, api_url, notify, fail_silently=False, **kwargs): super(HipChatBackend, self).__init__(fail_silently=fail_silently) self.token = token diff --git a/awx/main/notifications/irc_backend.py b/awx/main/notifications/irc_backend.py index 037293a0d3..b9a056f479 100644 --- a/awx/main/notifications/irc_backend.py +++ b/awx/main/notifications/irc_backend.py @@ -9,12 +9,14 @@ import irc.client from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend +from awx.main.notifications.custom_notification_base import CustomNotificationBase logger = logging.getLogger('awx.main.notifications.irc_backend') -class IrcBackend(AWXBaseEmailBackend): +class IrcBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"server": {"label": "IRC Server Address", "type": "string"}, "port": {"label": "IRC Server Port", "type": "int"}, @@ -25,11 +27,6 @@ class IrcBackend(AWXBaseEmailBackend): recipient_parameter = "targets" sender_parameter = None - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - default_messages = {"started": {"message": DEFAULT_SUBJECT}, - "success": {"message": DEFAULT_SUBJECT}, - "error": {"message": DEFAULT_SUBJECT}} - def __init__(self, server, port, nickname, password, use_ssl, fail_silently=False, **kwargs): super(IrcBackend, self).__init__(fail_silently=fail_silently) self.server = server diff --git a/awx/main/notifications/mattermost_backend.py b/awx/main/notifications/mattermost_backend.py index 41b3c4caa4..7a759d41a3 100644 --- a/awx/main/notifications/mattermost_backend.py +++ b/awx/main/notifications/mattermost_backend.py @@ -7,23 +7,20 @@ import json from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend +from awx.main.notifications.custom_notification_base import CustomNotificationBase logger = logging.getLogger('awx.main.notifications.mattermost_backend') -class MattermostBackend(AWXBaseEmailBackend): +class MattermostBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"mattermost_url": {"label": "Target URL", "type": "string"}, "mattermost_no_verify_ssl": {"label": "Verify SSL", "type": "bool"}} recipient_parameter = "mattermost_url" sender_parameter = None - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - default_messages = {"started": {"message": DEFAULT_SUBJECT}, - "success": {"message": DEFAULT_SUBJECT}, - "error": {"message": DEFAULT_SUBJECT}} - def __init__(self, mattermost_no_verify_ssl=False, mattermost_channel=None, mattermost_username=None, mattermost_icon_url=None, fail_silently=False, **kwargs): super(MattermostBackend, self).__init__(fail_silently=fail_silently) diff --git a/awx/main/notifications/pagerduty_backend.py b/awx/main/notifications/pagerduty_backend.py index 55827a67de..45869a34db 100644 --- a/awx/main/notifications/pagerduty_backend.py +++ b/awx/main/notifications/pagerduty_backend.py @@ -1,17 +1,23 @@ # Copyright (c) 2016 Ansible, Inc. # All Rights Reserved. +import json import logging import pygerduty from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend +from awx.main.notifications.custom_notification_base import CustomNotificationBase + +DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY +DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG logger = logging.getLogger('awx.main.notifications.pagerduty_backend') -class PagerDutyBackend(AWXBaseEmailBackend): +class PagerDutyBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"subdomain": {"label": "Pagerduty subdomain", "type": "string"}, "token": {"label": "API Token", "type": "password"}, @@ -20,11 +26,14 @@ class PagerDutyBackend(AWXBaseEmailBackend): recipient_parameter = "service_key" sender_parameter = "client_name" - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - DEFAULT_BODY = "{{ job_summary_dict }}" - default_messages = {"started": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}, - "success": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}, - "error": { "message": DEFAULT_SUBJECT, "body": DEFAULT_BODY}} + DEFAULT_BODY = "{{ job_metadata }}" + default_messages = {"started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "workflow_approval": {"running": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "approved": {"message": DEFAULT_MSG,"body": DEFAULT_BODY}, + "timed_out": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "denied": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}}} def __init__(self, subdomain, token, fail_silently=False, **kwargs): super(PagerDutyBackend, self).__init__(fail_silently=fail_silently) @@ -32,6 +41,16 @@ class PagerDutyBackend(AWXBaseEmailBackend): self.token = token def format_body(self, body): + # cast to dict if possible # TODO: is it true that this can be a dict or str? + try: + potential_body = json.loads(body) + if isinstance(potential_body, dict): + body = potential_body + except json.JSONDecodeError: + pass + + # but it's okay if this is also just a string + return body def send_messages(self, messages): diff --git a/awx/main/notifications/rocketchat_backend.py b/awx/main/notifications/rocketchat_backend.py index e211708a8c..1ad367fb57 100644 --- a/awx/main/notifications/rocketchat_backend.py +++ b/awx/main/notifications/rocketchat_backend.py @@ -7,22 +7,20 @@ import json from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend +from awx.main.notifications.custom_notification_base import CustomNotificationBase logger = logging.getLogger('awx.main.notifications.rocketchat_backend') -class RocketChatBackend(AWXBaseEmailBackend): +class RocketChatBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"rocketchat_url": {"label": "Target URL", "type": "string"}, "rocketchat_no_verify_ssl": {"label": "Verify SSL", "type": "bool"}} recipient_parameter = "rocketchat_url" sender_parameter = None - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - default_messages = {"started": {"message": DEFAULT_SUBJECT}, - "success": {"message": DEFAULT_SUBJECT}, - "error": {"message": DEFAULT_SUBJECT}} def __init__(self, rocketchat_no_verify_ssl=False, rocketchat_username=None, rocketchat_icon_url=None, fail_silently=False, **kwargs): super(RocketChatBackend, self).__init__(fail_silently=fail_silently) diff --git a/awx/main/notifications/slack_backend.py b/awx/main/notifications/slack_backend.py index 79ed838140..d70debf67c 100644 --- a/awx/main/notifications/slack_backend.py +++ b/awx/main/notifications/slack_backend.py @@ -6,24 +6,21 @@ from slackclient import SlackClient from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend +from awx.main.notifications.custom_notification_base import CustomNotificationBase logger = logging.getLogger('awx.main.notifications.slack_backend') WEBSOCKET_TIMEOUT = 30 -class SlackBackend(AWXBaseEmailBackend): +class SlackBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"token": {"label": "Token", "type": "password"}, "channels": {"label": "Destination Channels", "type": "list"}} recipient_parameter = "channels" sender_parameter = None - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - default_messages = {"started": {"message": DEFAULT_SUBJECT}, - "success": {"message": DEFAULT_SUBJECT}, - "error": {"message": DEFAULT_SUBJECT}} - def __init__(self, token, hex_color="", fail_silently=False, **kwargs): super(SlackBackend, self).__init__(fail_silently=fail_silently) self.token = token diff --git a/awx/main/notifications/twilio_backend.py b/awx/main/notifications/twilio_backend.py index 21e98e6882..38a364e00b 100644 --- a/awx/main/notifications/twilio_backend.py +++ b/awx/main/notifications/twilio_backend.py @@ -7,12 +7,14 @@ from twilio.rest import Client from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend +from awx.main.notifications.custom_notification_base import CustomNotificationBase logger = logging.getLogger('awx.main.notifications.twilio_backend') -class TwilioBackend(AWXBaseEmailBackend): +class TwilioBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"account_sid": {"label": "Account SID", "type": "string"}, "account_token": {"label": "Account Token", "type": "password"}, @@ -21,11 +23,6 @@ class TwilioBackend(AWXBaseEmailBackend): recipient_parameter = "to_numbers" sender_parameter = "from_number" - DEFAULT_SUBJECT = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' {{ job.status }}: {{ url }}" - default_messages = {"started": {"message": DEFAULT_SUBJECT}, - "success": {"message": DEFAULT_SUBJECT}, - "error": {"message": DEFAULT_SUBJECT}} - def __init__(self, account_sid, account_token, fail_silently=False, **kwargs): super(TwilioBackend, self).__init__(fail_silently=fail_silently) self.account_sid = account_sid diff --git a/awx/main/notifications/webhook_backend.py b/awx/main/notifications/webhook_backend.py index 92cddf4b3b..b9c2c35d22 100644 --- a/awx/main/notifications/webhook_backend.py +++ b/awx/main/notifications/webhook_backend.py @@ -7,13 +7,15 @@ import requests from django.utils.encoding import smart_text from django.utils.translation import ugettext_lazy as _ + from awx.main.notifications.base import AWXBaseEmailBackend from awx.main.utils import get_awx_version +from awx.main.notifications.custom_notification_base import CustomNotificationBase logger = logging.getLogger('awx.main.notifications.webhook_backend') -class WebhookBackend(AWXBaseEmailBackend): +class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase): init_parameters = {"url": {"label": "Target URL", "type": "string"}, "http_method": {"label": "HTTP Method", "type": "string", "default": "POST"}, @@ -24,10 +26,16 @@ class WebhookBackend(AWXBaseEmailBackend): recipient_parameter = "url" sender_parameter = None - DEFAULT_BODY = "{{ job_summary_dict }}" + DEFAULT_BODY = "{{ job_metadata }}" default_messages = {"started": {"body": DEFAULT_BODY}, "success": {"body": DEFAULT_BODY}, - "error": {"body": DEFAULT_BODY}} + "error": {"body": DEFAULT_BODY}, + "workflow_approval": { + "running": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" needs review. ' + 'This node can be viewed at: {{ workflow_url }}"}'}, + "approved": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was approved. {{ workflow_url }}"}'}, + "timed_out": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" has timed out. {{ workflow_url }}"}'}, + "denied": {"body": '{"body": "The approval node \\"{{ approval_node_name }}\\" was denied. {{ workflow_url }}"}'}}} def __init__(self, http_method, headers, disable_ssl_verification=False, fail_silently=False, username=None, password=None, **kwargs): self.http_method = http_method @@ -38,15 +46,13 @@ class WebhookBackend(AWXBaseEmailBackend): super(WebhookBackend, self).__init__(fail_silently=fail_silently) def format_body(self, body): - # If `body` has body field, attempt to use this as the main body, - # otherwise, leave it as a sub-field - if isinstance(body, dict) and 'body' in body and isinstance(body['body'], str): - try: - potential_body = json.loads(body['body']) - if isinstance(potential_body, dict): - body = potential_body - except json.JSONDecodeError: - pass + # expect body to be a string representing a dict + try: + potential_body = json.loads(body) + if isinstance(potential_body, dict): + body = potential_body + except json.JSONDecodeError: + body = {} return body def send_messages(self, messages): diff --git a/awx/main/redact.py b/awx/main/redact.py index ae60684377..77fc062135 100644 --- a/awx/main/redact.py +++ b/awx/main/redact.py @@ -12,10 +12,12 @@ class UriCleaner(object): @staticmethod def remove_sensitive(cleartext): + # exclude_list contains the items that will _not_ be redacted + exclude_list = [settings.PUBLIC_GALAXY_SERVER['url']] if settings.PRIMARY_GALAXY_URL: - exclude_list = [settings.PRIMARY_GALAXY_URL] + [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS] - else: - exclude_list = [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS] + exclude_list += [settings.PRIMARY_GALAXY_URL] + if settings.FALLBACK_GALAXY_SERVERS: + exclude_list += [server['url'] for server in settings.FALLBACK_GALAXY_SERVERS] redactedtext = cleartext text_index = 0 while True: diff --git a/awx/main/scheduler/kubernetes.py b/awx/main/scheduler/kubernetes.py index 00f82a3859..68a95e2fc2 100644 --- a/awx/main/scheduler/kubernetes.py +++ b/awx/main/scheduler/kubernetes.py @@ -1,9 +1,5 @@ import collections -import os -import stat import time -import yaml -import tempfile import logging from base64 import b64encode @@ -88,8 +84,17 @@ class PodManager(object): @cached_property def kube_api(self): - my_client = config.new_client_from_config(config_file=self.kube_config) - return client.CoreV1Api(api_client=my_client) + # this feels a little janky, but it's what k8s' own code does + # internally when it reads kube config files from disk: + # https://github.com/kubernetes-client/python-base/blob/0b208334ef0247aad9afcaae8003954423b61a0d/config/kube_config.py#L643 + loader = config.kube_config.KubeConfigLoader( + config_dict=self.kube_config + ) + cfg = type.__call__(client.Configuration) + loader.load_and_set(cfg) + return client.CoreV1Api(api_client=client.ApiClient( + configuration=cfg + )) @property def pod_name(self): @@ -174,10 +179,4 @@ def generate_tmp_kube_config(credential, namespace): ).decode() # decode the base64 data into a str else: config["clusters"][0]["cluster"]["insecure-skip-tls-verify"] = True - - fd, path = tempfile.mkstemp(prefix='kubeconfig') - with open(path, 'wb') as temp: - temp.write(yaml.dump(config).encode()) - temp.flush() - os.chmod(temp.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR) - return path + return config diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 4c8ca36960..e7ffb21487 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -252,19 +252,25 @@ class TaskManager(): logger.debug('Submitting isolated {} to queue {} controlled by {}.'.format( task.log_format, task.execution_node, controller_node)) elif rampart_group.is_containerized: + # find one real, non-containerized instance with capacity to + # act as the controller for k8s API interaction + match = None + for group in InstanceGroup.objects.all(): + if group.is_containerized or group.controller_id: + continue + match = group.find_largest_idle_instance() + if match: + break task.instance_group = rampart_group - if not task.supports_isolation(): + if task.supports_isolation(): + task.controller_node = match.hostname + else: # project updates and inventory updates don't *actually* run in pods, # so just pick *any* non-isolated, non-containerized host and use it - for group in InstanceGroup.objects.all(): - if group.is_containerized or group.controller_id: - continue - match = group.find_largest_idle_instance() - if match: - task.execution_node = match.hostname - logger.debug('Submitting containerized {} to queue {}.'.format( - task.log_format, task.execution_node)) - break + # as the execution node + task.execution_node = match.hostname + logger.debug('Submitting containerized {} to queue {}.'.format( + task.log_format, task.execution_node)) else: task.instance_group = rampart_group if instance is not None: diff --git a/awx/main/tasks.py b/awx/main/tasks.py index ff53cd00ac..8e0149648a 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1423,7 +1423,6 @@ class BaseTask(object): def deploy_container_group_pod(self, task): from awx.main.scheduler.kubernetes import PodManager # Avoid circular import pod_manager = PodManager(self.instance) - self.cleanup_paths.append(pod_manager.kube_config) try: log_name = task.log_format logger.debug(f"Launching pod for {log_name}.") @@ -1452,7 +1451,7 @@ class BaseTask(object): self.update_model(task.pk, execution_node=pod_manager.pod_name) return pod_manager - + @@ -1959,9 +1958,15 @@ class RunProjectUpdate(BaseTask): env['PROJECT_UPDATE_ID'] = str(project_update.pk) env['ANSIBLE_CALLBACK_PLUGINS'] = self.get_path_to('..', 'plugins', 'callback') env['ANSIBLE_GALAXY_IGNORE'] = True - # Set up the fallback server, which is the normal Ansible Galaxy by default - galaxy_servers = list(settings.FALLBACK_GALAXY_SERVERS) - # If private galaxy URL is non-blank, that means this feature is enabled + # Set up the public Galaxy server, if enabled + if settings.PUBLIC_GALAXY_ENABLED: + galaxy_servers = [settings.PUBLIC_GALAXY_SERVER] + else: + galaxy_servers = [] + # Set up fallback Galaxy servers, if configured + if settings.FALLBACK_GALAXY_SERVERS: + galaxy_servers = settings.FALLBACK_GALAXY_SERVERS + galaxy_servers + # Set up the primary Galaxy server, if configured if settings.PRIMARY_GALAXY_URL: galaxy_servers = [{'id': 'primary_galaxy'}] + galaxy_servers for key in GALAXY_SERVER_FIELDS: @@ -2354,6 +2359,27 @@ class RunInventoryUpdate(BaseTask): env[str(env_k)] = str(inventory_update.source_vars_dict[env_k]) elif inventory_update.source == 'file': raise NotImplementedError('Cannot update file sources through the task system.') + + if inventory_update.source == 'scm' and inventory_update.source_project_update: + env_key = 'ANSIBLE_COLLECTIONS_PATHS' + config_setting = 'collections_paths' + folder = 'requirements_collections' + default = '~/.ansible/collections:/usr/share/ansible/collections' + + config_values = read_ansible_config(os.path.join(private_data_dir, 'project'), [config_setting]) + + paths = default.split(':') + if env_key in env: + for path in env[env_key].split(':'): + if path not in paths: + paths = [env[env_key]] + paths + elif config_setting in config_values: + for path in config_values[config_setting].split(':'): + if path not in paths: + paths = [config_values[config_setting]] + paths + paths = [os.path.join(private_data_dir, folder)] + paths + env[env_key] = os.pathsep.join(paths) + return env def write_args_file(self, private_data_dir, args): @@ -2452,7 +2478,7 @@ class RunInventoryUpdate(BaseTask): # Use the vendored script path inventory_path = self.get_path_to('..', 'plugins', 'inventory', injector.script_name) elif src == 'scm': - inventory_path = inventory_update.get_actual_source_path() + inventory_path = os.path.join(private_data_dir, 'project', inventory_update.source_path) elif src == 'custom': handle, inventory_path = tempfile.mkstemp(dir=private_data_dir) f = os.fdopen(handle, 'w') @@ -2473,7 +2499,7 @@ class RunInventoryUpdate(BaseTask): ''' src = inventory_update.source if src == 'scm' and inventory_update.source_project_update: - return inventory_update.source_project_update.get_project_path(check_if_exists=False) + return os.path.join(private_data_dir, 'project') if src in CLOUD_PROVIDERS: injector = None if src in InventorySource.injectors: @@ -2509,8 +2535,10 @@ class RunInventoryUpdate(BaseTask): project_update_task = local_project_sync._get_task_class() try: - project_update_task().run(local_project_sync.id) - inventory_update.inventory_source.scm_last_revision = local_project_sync.project.scm_revision + sync_task = project_update_task(job_private_data_dir=private_data_dir) + sync_task.run(local_project_sync.id) + local_project_sync.refresh_from_db() + inventory_update.inventory_source.scm_last_revision = local_project_sync.scm_revision inventory_update.inventory_source.save(update_fields=['scm_last_revision']) except Exception: inventory_update = self.update_model( @@ -2518,6 +2546,13 @@ class RunInventoryUpdate(BaseTask): job_explanation=('Previous Task Failed: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % ('project_update', local_project_sync.name, local_project_sync.id))) raise + elif inventory_update.source == 'scm' and inventory_update.launch_type == 'scm' and source_project: + # This follows update, not sync, so make copy here + project_path = source_project.get_project_path(check_if_exists=False) + RunProjectUpdate.make_local_copy( + project_path, os.path.join(private_data_dir, 'project'), + source_project.scm_type, source_project.scm_revision + ) @task() diff --git a/awx/main/tests/functional/api/test_events.py b/awx/main/tests/functional/api/test_events.py new file mode 100644 index 0000000000..00b5459eaf --- /dev/null +++ b/awx/main/tests/functional/api/test_events.py @@ -0,0 +1,45 @@ +import pytest + +from awx.api.versioning import reverse +from awx.main.models import AdHocCommand, AdHocCommandEvent, JobEvent + + +@pytest.mark.django_db +@pytest.mark.parametrize('truncate, expected', [ + (True, False), + (False, True), +]) +def test_job_events_sublist_truncation(get, organization_factory, job_template_factory, truncate, expected): + objs = organization_factory("org", superusers=['admin']) + jt = job_template_factory("jt", organization=objs.organization, + inventory='test_inv', project='test_proj').job_template + job = jt.create_unified_job() + JobEvent.create_from_data(job_id=job.pk, uuid='abc123', event='runner_on_start', + stdout='a' * 1025) + + url = reverse('api:job_job_events_list', kwargs={'pk': job.pk}) + if not truncate: + url += '?no_truncate=1' + + response = get(url, user=objs.superusers.admin, expect=200) + assert (len(response.data['results'][0]['stdout']) == 1025) == expected + + +@pytest.mark.django_db +@pytest.mark.parametrize('truncate, expected', [ + (True, False), + (False, True), +]) +def test_ad_hoc_events_sublist_truncation(get, organization_factory, job_template_factory, truncate, expected): + objs = organization_factory("org", superusers=['admin']) + adhoc = AdHocCommand() + adhoc.save() + AdHocCommandEvent.create_from_data(ad_hoc_command_id=adhoc.pk, uuid='abc123', event='runner_on_start', + stdout='a' * 1025) + + url = reverse('api:ad_hoc_command_ad_hoc_command_events_list', kwargs={'pk': adhoc.pk}) + if not truncate: + url += '?no_truncate=1' + + response = get(url, user=objs.superusers.admin, expect=200) + assert (len(response.data['results'][0]['stdout']) == 1025) == expected diff --git a/awx/main/tests/functional/api/test_generic.py b/awx/main/tests/functional/api/test_generic.py index e956475e2d..c6fe7ca188 100644 --- a/awx/main/tests/functional/api/test_generic.py +++ b/awx/main/tests/functional/api/test_generic.py @@ -117,3 +117,10 @@ def test_handle_content_type(post, admin): admin, content_type='text/html', expect=415) + + +@pytest.mark.django_db +def test_basic_not_found(get, admin_user): + root_url = reverse('api:api_v2_root_view') + r = get(root_url + 'fooooooo', user=admin_user, expect=404) + assert r.data.get('detail') == 'The requested resource could not be found.' diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index d8ae10f902..ee44aad84a 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -8,6 +8,8 @@ from unittest.mock import PropertyMock # Django from django.urls import resolve +from django.http import Http404 +from django.core.handlers.exception import response_for_exception from django.contrib.auth.models import User from django.core.serializers.json import DjangoJSONEncoder from django.db.backends.sqlite3.base import SQLiteCursorWrapper @@ -581,8 +583,12 @@ def _request(verb): if 'format' not in kwargs and 'content_type' not in kwargs: kwargs['format'] = 'json' - view, view_args, view_kwargs = resolve(urllib.parse.urlparse(url)[2]) request = getattr(APIRequestFactory(), verb)(url, **kwargs) + request_error = None + try: + view, view_args, view_kwargs = resolve(urllib.parse.urlparse(url)[2]) + except Http404 as e: + request_error = e if isinstance(kwargs.get('cookies', None), dict): for key, value in kwargs['cookies'].items(): request.COOKIES[key] = value @@ -591,7 +597,10 @@ def _request(verb): if user: force_authenticate(request, user=user) - response = view(request, *view_args, **view_kwargs) + if not request_error: + response = view(request, *view_args, **view_kwargs) + else: + response = response_for_exception(request, request_error) if middleware: middleware.process_response(request, response) if expect: diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py index e835f2d2dd..1b671efdcb 100644 --- a/awx/main/tests/functional/models/test_notifications.py +++ b/awx/main/tests/functional/models/test_notifications.py @@ -87,7 +87,7 @@ class TestJobNotificationMixin(object): 'use_fact_cache': bool, 'verbosity': int}, 'job_friendly_name': str, - 'job_summary_dict': str, + 'job_metadata': str, 'url': str} @@ -144,5 +144,3 @@ class TestJobNotificationMixin(object): context_stub = JobNotificationMixin.context_stub() check_structure_and_completeness(TestJobNotificationMixin.CONTEXT_STRUCTURE, context_stub) - - diff --git a/awx/main/tests/functional/task_management/test_container_groups.py b/awx/main/tests/functional/task_management/test_container_groups.py index c1a1695bc9..47d982a725 100644 --- a/awx/main/tests/functional/task_management/test_container_groups.py +++ b/awx/main/tests/functional/task_management/test_container_groups.py @@ -1,5 +1,4 @@ import subprocess -import yaml import base64 from unittest import mock # noqa @@ -51,6 +50,5 @@ def test_kubectl_ssl_verification(containerized_job): cred.inputs['ssl_ca_cert'] = cert.stdout cred.save() pm = PodManager(containerized_job) - config = yaml.load(open(pm.kube_config), Loader=yaml.FullLoader) - ca_data = config['clusters'][0]['cluster']['certificate-authority-data'] + ca_data = pm.kube_config['clusters'][0]['cluster']['certificate-authority-data'] assert cert.stdout == base64.b64decode(ca_data.encode()) diff --git a/awx/main/tests/functional/test_inventory_source_injectors.py b/awx/main/tests/functional/test_inventory_source_injectors.py index 7f7e43c993..729658a58d 100644 --- a/awx/main/tests/functional/test_inventory_source_injectors.py +++ b/awx/main/tests/functional/test_inventory_source_injectors.py @@ -264,6 +264,7 @@ def test_inventory_update_injected_content(this_kind, script_or_plugin, inventor assert envvars.pop('ANSIBLE_INVENTORY_ENABLED') == ('auto' if use_plugin else 'script') set_files = bool(os.getenv("MAKE_INVENTORY_REFERENCE_FILES", 'false').lower()[0] not in ['f', '0']) env, content = read_content(private_data_dir, envvars, inventory_update) + env.pop('ANSIBLE_COLLECTIONS_PATHS', None) # collection paths not relevant to this test base_dir = os.path.join(DATA, script_or_plugin) if not os.path.exists(base_dir): os.mkdir(base_dir) diff --git a/awx/main/tests/functional/test_notifications.py b/awx/main/tests/functional/test_notifications.py index 4059d0b9a9..e147445f18 100644 --- a/awx/main/tests/functional/test_notifications.py +++ b/awx/main/tests/functional/test_notifications.py @@ -43,7 +43,7 @@ def test_basic_parameterization(get, post, user, organization): assert 'url' in response.data['notification_configuration'] assert 'headers' in response.data['notification_configuration'] assert 'messages' in response.data - assert response.data['messages'] == {'started': None, 'success': None, 'error': None} + assert response.data['messages'] == {'started': None, 'success': None, 'error': None, 'workflow_approval': None} @pytest.mark.django_db diff --git a/awx/main/tests/functional/test_rbac_job.py b/awx/main/tests/functional/test_rbac_job.py index 13866565fa..5dbe797f6c 100644 --- a/awx/main/tests/functional/test_rbac_job.py +++ b/awx/main/tests/functional/test_rbac_job.py @@ -19,6 +19,8 @@ from awx.main.models import ( Credential ) +from rest_framework.exceptions import PermissionDenied + from crum import impersonate @@ -252,7 +254,8 @@ class TestJobRelaunchAccess: assert 'job_var' in job.launch_config.extra_data assert bob.can_access(Job, 'start', job, validate_license=False) - assert not alice.can_access(Job, 'start', job, validate_license=False) + with pytest.raises(PermissionDenied): + alice.can_access(Job, 'start', job, validate_license=False) @pytest.mark.django_db diff --git a/awx/main/tests/functional/test_rbac_workflow.py b/awx/main/tests/functional/test_rbac_workflow.py index 04926385c0..a53d769324 100644 --- a/awx/main/tests/functional/test_rbac_workflow.py +++ b/awx/main/tests/functional/test_rbac_workflow.py @@ -7,6 +7,8 @@ from awx.main.access import ( # WorkflowJobNodeAccess ) +from rest_framework.exceptions import PermissionDenied + from awx.main.models import InventorySource, JobLaunchConfig @@ -169,7 +171,8 @@ class TestWorkflowJobAccess: wfjt.ask_inventory_on_launch = True wfjt.save() JobLaunchConfig.objects.create(job=workflow_job, inventory=inventory) - assert not WorkflowJobAccess(rando).can_start(workflow_job) + with pytest.raises(PermissionDenied): + WorkflowJobAccess(rando).can_start(workflow_job) inventory.use_role.members.add(rando) assert WorkflowJobAccess(rando).can_start(workflow_job) diff --git a/awx/main/tests/unit/api/serializers/test_notification_template_serializers.py b/awx/main/tests/unit/api/serializers/test_notification_template_serializers.py index afd29820d2..f0bd6784d4 100644 --- a/awx/main/tests/unit/api/serializers/test_notification_template_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_notification_template_serializers.py @@ -26,7 +26,7 @@ class TestNotificationTemplateSerializer(): {'started': {'message': '{{ job.id }}', 'body': '{{ job.status }}'}, 'success': {'message': None, 'body': '{{ job_friendly_name }}'}, 'error': {'message': '{{ url }}', 'body': None}}, - {'started': {'body': '{{ job_summary_dict }}'}}, + {'started': {'body': '{{ job_metadata }}'}}, {'started': {'body': '{{ job.summary_fields.inventory.total_hosts }}'}}, {'started': {'body': u'Iñtërnâtiônàlizætiøn'}} ]) diff --git a/awx/main/utils/handlers.py b/awx/main/utils/handlers.py index 82d3a285bb..289f64d064 100644 --- a/awx/main/utils/handlers.py +++ b/awx/main/utils/handlers.py @@ -288,13 +288,17 @@ class AWXProxyHandler(logging.Handler): ''' thread_local = threading.local() + _auditor = None def __init__(self, **kwargs): # TODO: process 'level' kwarg super(AWXProxyHandler, self).__init__(**kwargs) self._handler = None self._old_kwargs = {} - if settings.LOG_AGGREGATOR_AUDIT: + + @property + def auditor(self): + if not self._auditor: self._auditor = logging.handlers.RotatingFileHandler( filename='/var/log/tower/external.log', maxBytes=1024 * 1024 * 50, # 50 MB @@ -307,6 +311,7 @@ class AWXProxyHandler(logging.Handler): return json.dumps(message) self._auditor.setFormatter(WritableLogstashFormatter()) + return self._auditor def get_handler_class(self, protocol): return HANDLER_MAPPING.get(protocol, AWXNullHandler) @@ -341,8 +346,8 @@ class AWXProxyHandler(logging.Handler): if AWXProxyHandler.thread_local.enabled: actual_handler = self.get_handler() if settings.LOG_AGGREGATOR_AUDIT: - self._auditor.setLevel(settings.LOG_AGGREGATOR_LEVEL) - self._auditor.emit(record) + self.auditor.setLevel(settings.LOG_AGGREGATOR_LEVEL) + self.auditor.emit(record) return actual_handler.emit(record) def perform_test(self, custom_settings): diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 658d41d6b3..ab8a5492e8 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -635,16 +635,18 @@ PRIMARY_GALAXY_USERNAME = '' PRIMARY_GALAXY_TOKEN = '' PRIMARY_GALAXY_PASSWORD = '' PRIMARY_GALAXY_AUTH_URL = '' -# Settings for the fallback galaxy server(s), normally this is the -# actual Ansible Galaxy site. -# server options: 'id', 'url', 'username', 'password', 'token', 'auth_url' -# To not use any fallback servers set this to [] -FALLBACK_GALAXY_SERVERS = [ - { - 'id': 'galaxy', - 'url': 'https://galaxy.ansible.com' - } -] + +# Settings for the public galaxy server(s). +PUBLIC_GALAXY_ENABLED = True +PUBLIC_GALAXY_SERVER = { + 'id': 'galaxy', + 'url': 'https://galaxy.ansible.com' +} + +# List of dicts of fallback (additional) Galaxy servers. If configured, these +# will be higher precedence than public Galaxy, but lower than primary Galaxy. +# Available options: 'id', 'url', 'username', 'password', 'token', 'auth_url' +FALLBACK_GALAXY_SERVERS = [] # Enable bubblewrap support for running jobs (playbook runs only). # Note: This setting may be overridden by database settings. diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 532b04055a..de76ee669e 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -282,10 +282,12 @@ function getLaunchedByDetails () { tooltip = strings.get('tooltips.SCHEDULE'); link = `/#/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`; value = $filter('sanitize')(schedule.name); - } else { + } else if (schedule) { tooltip = null; link = null; value = $filter('sanitize')(schedule.name); + } else { + return null; } return { label, link, tooltip, value }; diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 14bf856269..7264059b49 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -5,7 +5,7 @@
- +