diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 8f49a793b..ddf9a53db 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1392,6 +1392,7 @@ def get_related(self, obj): notification_templates_success=self.reverse('api:organization_notification_templates_success_list', kwargs={'pk': obj.pk}), notification_templates_error=self.reverse('api:organization_notification_templates_error_list', kwargs={'pk': obj.pk}), notification_templates_approvals=self.reverse('api:organization_notification_templates_approvals_list', kwargs={'pk': obj.pk}), + notification_templates_changed=self.reverse('api:organization_notification_templates_changed_list', kwargs={'pk': obj.pk}), object_roles=self.reverse('api:organization_object_roles_list', kwargs={'pk': obj.pk}), access_list=self.reverse('api:organization_access_list', kwargs={'pk': obj.pk}), instance_groups=self.reverse('api:organization_instance_groups_list', kwargs={'pk': obj.pk}), @@ -3482,6 +3483,7 @@ def get_related(self, obj): 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}), + notification_templates_changed=self.reverse('api:job_template_notification_templates_changed_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}), @@ -5393,8 +5395,8 @@ def check_messages(messages): error_list.append(_("Expected dict for 'messages' field, found {}".format(type(messages)))) else: for event in messages: - 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)) + if event not in ('started', 'success', 'error', 'changed', 'workflow_approval'): + error_list.append(_("Event '{}' invalid, must be one of 'started', 'success', 'error', 'changed', or 'workflow_approval'").format(event)) continue event_messages = messages[event] if event_messages is None: diff --git a/awx/api/urls/job_template.py b/awx/api/urls/job_template.py index e898d9a86..4f3d07049 100644 --- a/awx/api/urls/job_template.py +++ b/awx/api/urls/job_template.py @@ -17,6 +17,7 @@ JobTemplateNotificationTemplatesErrorList, JobTemplateNotificationTemplatesStartedList, JobTemplateNotificationTemplatesSuccessList, + JobTemplateNotificationTemplatesChangedList, JobTemplateInstanceGroupsList, JobTemplateAccessList, JobTemplateObjectRolesList, @@ -49,6 +50,11 @@ JobTemplateNotificationTemplatesSuccessList.as_view(), name='job_template_notification_templates_success_list', ), + path( + '/notification_templates_changed/', + JobTemplateNotificationTemplatesChangedList.as_view(), + name='job_template_notification_templates_changed_list', + ), path('/instance_groups/', JobTemplateInstanceGroupsList.as_view(), name='job_template_instance_groups_list'), path('/access_list/', JobTemplateAccessList.as_view(), name='job_template_access_list'), path('/object_roles/', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'), diff --git a/awx/api/urls/organization.py b/awx/api/urls/organization.py index 131e7b5db..4f007b810 100644 --- a/awx/api/urls/organization.py +++ b/awx/api/urls/organization.py @@ -20,6 +20,7 @@ OrganizationNotificationTemplatesStartedList, OrganizationNotificationTemplatesSuccessList, OrganizationNotificationTemplatesApprovalList, + OrganizationNotificationTemplatesChangedList, OrganizationInstanceGroupsList, OrganizationGalaxyCredentialsList, OrganizationObjectRolesList, @@ -61,6 +62,11 @@ OrganizationNotificationTemplatesApprovalList.as_view(), name='organization_notification_templates_approvals_list', ), + path( + '/notification_templates_changed/', + OrganizationNotificationTemplatesChangedList.as_view(), + name='organization_notification_templates_changed_list', + ), path('/instance_groups/', OrganizationInstanceGroupsList.as_view(), name='organization_instance_groups_list'), path('/galaxy_credentials/', OrganizationGalaxyCredentialsList.as_view(), name='organization_galaxy_credentials_list'), path('/object_roles/', OrganizationObjectRolesList.as_view(), name='organization_object_roles_list'), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 0e98bf285..3f46259a7 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -2708,6 +2708,10 @@ class JobTemplateNotificationTemplatesSuccessList(JobTemplateNotificationTemplat relationship = 'notification_templates_success' +class JobTemplateNotificationTemplatesChangedList(JobTemplateNotificationTemplatesAnyList): + relationship = 'notification_templates_changed' + + class JobTemplateCredentialsList(SubListCreateAttachDetachAPIView): model = models.Credential serializer_class = serializers.CredentialSerializer diff --git a/awx/api/views/organization.py b/awx/api/views/organization.py index 83594f240..fc1e83811 100644 --- a/awx/api/views/organization.py +++ b/awx/api/views/organization.py @@ -196,6 +196,10 @@ class OrganizationNotificationTemplatesApprovalList(OrganizationNotificationTemp relationship = 'notification_templates_approvals' +class OrganizationNotificationTemplatesChangedList(OrganizationNotificationTemplatesAnyList): + relationship = 'notification_templates_changed' + + class OrganizationInstanceGroupsList(OrganizationInstanceGroupMembershipMixin, SubListAttachDetachAPIView): model = InstanceGroup serializer_class = InstanceGroupSerializer diff --git a/awx/main/migrations/0210_notification_templates_changed.py b/awx/main/migrations/0210_notification_templates_changed.py new file mode 100644 index 000000000..d9140ebce --- /dev/null +++ b/awx/main/migrations/0210_notification_templates_changed.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('main', '0209_cleanup_dab_rbac_leftovers'), + ] + + operations = [ + migrations.AddField( + model_name='jobtemplate', + name='notification_templates_changed', + field=models.ManyToManyField(blank=True, related_name='%(class)s_notification_templates_for_changed', to='main.notificationtemplate'), + ), + migrations.AddField( + model_name='organization', + name='notification_templates_changed', + field=models.ManyToManyField(blank=True, related_name='%(class)s_notification_templates_for_changed', to='main.notificationtemplate'), + ), + ] diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index 517fb97cb..c4878fd6b 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -166,7 +166,7 @@ def get_ui_url(self): @property def notification_templates(self): all_orgs = {h.inventory.organization for h in self.hosts.all()} - active_templates = dict(error=set(), success=set(), started=set()) + active_templates = dict(error=set(), success=set(), started=set(), changed=set()) base_notification_templates = NotificationTemplate.objects for org in all_orgs: for templ in base_notification_templates.filter(organization_notification_templates_for_errors=org): @@ -175,9 +175,12 @@ def notification_templates(self): active_templates['success'].add(templ) for templ in base_notification_templates.filter(organization_notification_templates_for_started=org): active_templates['started'].add(templ) + for templ in base_notification_templates.filter(organization_notification_templates_for_changed=org): + active_templates['changed'].add(templ) active_templates['error'] = list(active_templates['error']) active_templates['success'] = list(active_templates['success']) active_templates['started'] = list(active_templates['started']) + active_templates['changed'] = list(active_templates['changed']) return active_templates def get_passwords_needed_to_start(self): @@ -251,3 +254,10 @@ def get_notification_templates(self): def get_notification_friendly_name(self): return "AdHoc Command" + + def has_changes(self): + """ + Whether the command reported a change on any host. An ad hoc command has no + JobHostSummary rows, so this reads the per host events instead. + """ + return self.ad_hoc_command_events.filter(changed=True).exists() diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 3762f3a04..d17ace16e 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -328,6 +328,11 @@ class Meta: "groups will be applied." ), ) + notification_templates_changed = models.ManyToManyField( + "NotificationTemplate", + blank=True, + related_name='%(class)s_notification_templates_for_changed', + ) @classmethod def _get_unified_job_class(cls): @@ -618,6 +623,9 @@ def notification_templates(self): success_notification_templates = list( base_notification_templates.filter(unifiedjobtemplate_notification_templates_for_success__in=[self, self.project]) ) + # Changes are reported by the job itself, so this trigger only exists on the job + # template and on its organization, not on the project. + changed_notification_templates = list(base_notification_templates.filter(jobtemplate_notification_templates_for_changed__in=[self])) # Get Organization NotificationTemplates if self.organization is not None: error_notification_templates = set( @@ -629,7 +637,15 @@ def notification_templates(self): success_notification_templates = set( success_notification_templates + list(base_notification_templates.filter(organization_notification_templates_for_success=self.organization)) ) - return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates)) + changed_notification_templates = set( + changed_notification_templates + list(base_notification_templates.filter(organization_notification_templates_for_changed=self.organization)) + ) + return dict( + error=list(error_notification_templates), + started=list(started_notification_templates), + success=list(success_notification_templates), + changed=list(changed_notification_templates), + ) ''' RelatedJobsMixin @@ -929,6 +945,13 @@ def get_notification_templates(self): def get_notification_friendly_name(self): return "Job" + def has_changes(self): + """ + Whether the run reported a change on any host. Check mode counts, which is what + makes this useful for a compliance playbook that is only meant to report drift. + """ + return self.job_host_summaries.filter(changed__gt=0).exists() + def get_hosts_for_fact_cache(self): """ Builds the queryset to use for writing or finalizing the fact cache diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index cef529c23..dfaaa0c21 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -73,7 +73,7 @@ class Meta: notification_configuration = prevent_search(models.JSONField(default=dict)) def default_messages(): - return {'started': None, 'success': None, 'error': None, 'workflow_approval': None} + return {'started': None, 'success': None, 'error': None, 'changed': None, 'workflow_approval': None} messages = models.JSONField(null=True, blank=True, default=default_messages, help_text=_('Optional custom messages for notification template.')) @@ -244,7 +244,7 @@ def get_absolute_url(self, request=None): class JobNotificationMixin(object): - STATUS_TO_TEMPLATE_TYPE = {'succeeded': 'success', 'running': 'started', 'failed': 'error'} + STATUS_TO_TEMPLATE_TYPE = {'succeeded': 'success', 'running': 'started', 'failed': 'error', 'changed': 'changed'} # Maximum number of host names exposed in the notification context to keep # payloads bounded for jobs run against very large inventories. HOST_LIST_MAX = 1000 @@ -462,6 +462,13 @@ def get_notification_templates(self): def get_notification_friendly_name(self): raise RuntimeError("Define me") + def has_changes(self): + """ + Whether the run reported a change. Only job types that record per host results can + answer this, so everything else never triggers the changed notifications. + """ + return False + def notification_data(self): raise RuntimeError("Define me") @@ -520,8 +527,8 @@ def build_notification_message(self, nt, status): def send_notification_templates(self, status): from awx.main.tasks.system import send_notifications # avoid circular import - if status not in ['running', 'succeeded', 'failed']: - raise ValueError(_("status must be either running, succeeded or failed")) + if status not in ['running', 'succeeded', 'failed', 'changed']: + raise ValueError(_("status must be either running, succeeded, failed or changed")) try: notification_templates = self.get_notification_templates() except Exception: @@ -531,6 +538,12 @@ def send_notification_templates(self, status): if not notification_templates: return + # A run that changed something notifies the templates set up for changes on top of + # the ones for how it ended, so that a playbook run in check mode that reports drift + # is notified even though it succeeded. + if status in ('succeeded', 'failed') and notification_templates.get('changed') and self.has_changes(): + self.send_notification_templates('changed') + for nt in set(notification_templates.get(self.STATUS_TO_TEMPLATE_TYPE[status], [])): msg, body = self.build_notification_message(nt, status) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index ad2cb077d..547a4ee29 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -43,6 +43,7 @@ class Meta: help_text=_('Maximum number of hosts allowed to be managed by this organization.'), ) notification_templates_approvals = models.ManyToManyField("NotificationTemplate", blank=True, related_name='%(class)s_notification_templates_for_approvals') + notification_templates_changed = models.ManyToManyField("NotificationTemplate", blank=True, related_name='%(class)s_notification_templates_for_changed') default_environment = models.ForeignKey( 'ExecutionEnvironment', null=True, diff --git a/awx/main/notifications/custom_notification_base.py b/awx/main/notifications/custom_notification_base.py index bbf5b3742..62dd4ef00 100644 --- a/awx/main/notifications/custom_notification_base.py +++ b/awx/main/notifications/custom_notification_base.py @@ -7,6 +7,11 @@ 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_CHANGED_MSG = "{{ job_friendly_name }} #{{ job.id }} '{{ job.name }}' reported changes: {{ url }}" + DEFAULT_CHANGED_BODY = ( + "{{ job_friendly_name }} #{{ job.id }} reported changes and had status {{ job.status }}, view details at {{ url }}\n\n{{ job_metadata }}" + ) + DEFAULT_APPROVAL_RUNNING_MSG = 'The approval node "{{ approval_node_name }}" needs review. This node can be viewed at: {{ workflow_url }}' DEFAULT_APPROVAL_RUNNING_BODY = ( 'The approval node "{{ approval_node_name }}" needs review. This approval node can be viewed at: {{ workflow_url }}' @@ -27,6 +32,7 @@ class CustomNotificationBase(object): "started": {"message": DEFAULT_MSG, "body": None}, "success": {"message": DEFAULT_MSG, "body": None}, "error": {"message": DEFAULT_MSG, "body": None}, + "changed": {"message": DEFAULT_CHANGED_MSG, "body": None}, "workflow_approval": { "running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": None}, "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": None}, diff --git a/awx/main/notifications/email_backend.py b/awx/main/notifications/email_backend.py index 902a8b812..4fe440373 100644 --- a/awx/main/notifications/email_backend.py +++ b/awx/main/notifications/email_backend.py @@ -8,6 +8,9 @@ DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG DEFAULT_BODY = CustomNotificationBase.DEFAULT_BODY +DEFAULT_CHANGED_MSG = CustomNotificationBase.DEFAULT_CHANGED_MSG +DEFAULT_CHANGED_BODY = CustomNotificationBase.DEFAULT_CHANGED_BODY + DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY @@ -40,6 +43,7 @@ class CustomEmailBackend(EmailBackend, CustomNotificationBase): "started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "changed": {"message": DEFAULT_CHANGED_MSG, "body": DEFAULT_CHANGED_BODY}, "workflow_approval": { "running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY}, "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY}, diff --git a/awx/main/notifications/grafana_backend.py b/awx/main/notifications/grafana_backend.py index 2fcee0be8..714408bc2 100644 --- a/awx/main/notifications/grafana_backend.py +++ b/awx/main/notifications/grafana_backend.py @@ -15,6 +15,7 @@ from awx.main.notifications.custom_notification_base import CustomNotificationBase DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG +DEFAULT_CHANGED_MSG = CustomNotificationBase.DEFAULT_CHANGED_MSG DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY @@ -41,6 +42,7 @@ class GrafanaBackend(AWXBaseEmailBackend, CustomNotificationBase): "started": {"body": DEFAULT_BODY, "message": DEFAULT_MSG}, "success": {"body": DEFAULT_BODY, "message": DEFAULT_MSG}, "error": {"body": DEFAULT_BODY, "message": DEFAULT_MSG}, + "changed": {"body": DEFAULT_BODY, "message": DEFAULT_CHANGED_MSG}, "workflow_approval": { "running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY}, "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY}, diff --git a/awx/main/notifications/pagerduty_backend.py b/awx/main/notifications/pagerduty_backend.py index d8f37c07f..09bc3893f 100644 --- a/awx/main/notifications/pagerduty_backend.py +++ b/awx/main/notifications/pagerduty_backend.py @@ -12,6 +12,7 @@ from awx.main.notifications.custom_notification_base import CustomNotificationBase DEFAULT_MSG = CustomNotificationBase.DEFAULT_MSG +DEFAULT_CHANGED_MSG = CustomNotificationBase.DEFAULT_CHANGED_MSG DEFAULT_APPROVAL_RUNNING_MSG = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_MSG DEFAULT_APPROVAL_RUNNING_BODY = CustomNotificationBase.DEFAULT_APPROVAL_RUNNING_BODY @@ -43,6 +44,7 @@ class PagerDutyBackend(AWXBaseEmailBackend, CustomNotificationBase): "started": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "success": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, "error": {"message": DEFAULT_MSG, "body": DEFAULT_BODY}, + "changed": {"message": DEFAULT_CHANGED_MSG, "body": DEFAULT_BODY}, "workflow_approval": { "running": {"message": DEFAULT_APPROVAL_RUNNING_MSG, "body": DEFAULT_APPROVAL_RUNNING_BODY}, "approved": {"message": DEFAULT_APPROVAL_APPROVED_MSG, "body": DEFAULT_APPROVAL_APPROVED_BODY}, diff --git a/awx/main/notifications/webhook_backend.py b/awx/main/notifications/webhook_backend.py index bb6211339..91377e595 100644 --- a/awx/main/notifications/webhook_backend.py +++ b/awx/main/notifications/webhook_backend.py @@ -34,6 +34,7 @@ class WebhookBackend(AWXBaseEmailBackend, CustomNotificationBase): "started": {"body": DEFAULT_BODY}, "success": {"body": DEFAULT_BODY}, "error": {"body": DEFAULT_BODY}, + "changed": {"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 }}"}'}, diff --git a/awx/main/tests/functional/api/test_notifications.py b/awx/main/tests/functional/api/test_notifications.py index 431065396..4db630a71 100644 --- a/awx/main/tests/functional/api/test_notifications.py +++ b/awx/main/tests/functional/api/test_notifications.py @@ -163,3 +163,57 @@ def test_post_wfj_notification(get, post, admin, workflow_job, notification): response = get(url, admin) assert response.status_code == 200 assert len(response.data['results']) == 1 + + +@pytest.mark.django_db +def test_get_jt_changed_notification(get, admin, job_template): + url = reverse('api:job_template_notification_templates_changed_list', kwargs={'pk': job_template.pk}) + response = get(url, admin) + assert response.status_code == 200 + assert len(response.data['results']) == 0 + + +@pytest.mark.django_db +def test_post_jt_changed_notification(get, post, admin, notification_template, job_template): + url = reverse('api:job_template_notification_templates_changed_list', kwargs={'pk': job_template.pk}) + response = post(url, dict(id=notification_template.id, associate=True), admin) + assert response.status_code == 204 + response = get(url, admin) + assert response.status_code == 200 + assert len(response.data['results']) == 1 + + +@pytest.mark.django_db +def test_get_org_changed_notification(get, admin, organization): + url = reverse('api:organization_notification_templates_changed_list', kwargs={'pk': organization.pk}) + response = get(url, admin) + assert response.status_code == 200 + assert len(response.data['results']) == 0 + + +@pytest.mark.django_db +def test_post_org_changed_notification(get, post, admin, notification_template, organization): + url = reverse('api:organization_notification_templates_changed_list', kwargs={'pk': organization.pk}) + response = post(url, dict(id=notification_template.id, associate=True), admin) + assert response.status_code == 204 + response = get(url, admin) + assert response.status_code == 200 + assert len(response.data['results']) == 1 + + +@pytest.mark.django_db +def test_jt_notification_templates_include_the_changed_trigger(job_template, organization, notification_template): + job_template.organization = organization + job_template.save() + job_template.notification_templates_changed.add(notification_template) + + assert job_template.notification_templates['changed'] == [notification_template] + + +@pytest.mark.django_db +def test_org_changed_notification_templates_reach_its_job_templates(job_template, organization, notification_template): + job_template.organization = organization + job_template.save() + organization.notification_templates_changed.add(notification_template) + + assert job_template.notification_templates['changed'] == [notification_template] diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py index 6efc0e1c6..b1ead8dc9 100644 --- a/awx/main/tests/functional/models/test_notifications.py +++ b/awx/main/tests/functional/models/test_notifications.py @@ -5,7 +5,17 @@ import pytest # from awx.main.models import NotificationTemplates, Notifications, JobNotificationMixin -from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobNotificationMixin, NotificationTemplate, ProjectUpdate, Schedule, SystemJob, WorkflowJob +from awx.main.models import ( + AdHocCommand, + InventoryUpdate, + Job, + JobNotificationMixin, + NotificationTemplate, + ProjectUpdate, + Schedule, + SystemJob, + WorkflowJob, +) from awx.api.serializers import UnifiedJobSerializer @@ -181,3 +191,114 @@ def check_structure_and_completeness(expected_structure, obj): context_stub = JobNotificationMixin.context_stub() check_structure_and_completeness(TestJobNotificationMixin.CONTEXT_STRUCTURE, context_stub) + + +class TestChangedNotifications(object): + """The changed trigger fires for a run that reported a change on any host, next to the + trigger for how the run ended, so a compliance playbook run in check mode reports drift + without having to fail to be noticed.""" + + def notification_template(self, name): + return NotificationTemplate.objects.create( + name=name, + notification_type='webhook', + notification_configuration=dict(url='http://localhost', username='', password='', headers={}), + ) + + def notified_templates(self, build_notification_message): + return set(call[0][0] for call in build_notification_message.call_args_list) + + @pytest.mark.django_db + def test_job_without_changes(self): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=0, failures=0) + + assert job.has_changes() is False + + @pytest.mark.django_db + def test_job_with_changes(self): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=0, failures=0) + job.job_host_summaries.create(host_name='host-b', failed=False, ok=1, changed=2, failures=0) + + assert job.has_changes() is True + + @pytest.mark.parametrize('JobClass', [InventoryUpdate, ProjectUpdate, SystemJob, WorkflowJob]) + def test_job_types_without_host_results_never_report_changes(self, JobClass): + # these inherit JobNotificationMixin.has_changes, which answers without a query, + # so an unsaved instance is enough and no required relations have to be invented + assert JobClass().has_changes() is False + + @pytest.mark.django_db + def test_ad_hoc_command_without_changes(self): + command = AdHocCommand.objects.create(name='fake-command') + command.ad_hoc_command_events.create(host_name='host-a', event='runner_on_ok', changed=False) + + assert command.has_changes() is False + + @pytest.mark.django_db + def test_ad_hoc_command_with_changes(self): + command = AdHocCommand.objects.create(name='fake-command') + command.ad_hoc_command_events.create(host_name='host-a', event='runner_on_ok', changed=False) + command.ad_hoc_command_events.create(host_name='host-b', event='runner_on_ok', changed=True) + + assert command.has_changes() is True + + @pytest.mark.django_db + def test_changed_templates_are_notified_next_to_the_outcome(self, mocker): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=1, failures=0) + success = self.notification_template('on-success') + changed = self.notification_template('on-changed') + mocker.patch.object(Job, 'get_notification_templates', return_value={'success': [success], 'changed': [changed]}) + build = mocker.patch.object(Job, 'build_notification_message', return_value=('msg', 'body')) + + job.send_notification_templates('succeeded') + + assert self.notified_templates(build) == {success, changed} + + @pytest.mark.django_db + def test_a_job_that_changed_nothing_only_notifies_the_outcome(self, mocker): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=0, failures=0) + success = self.notification_template('on-success') + changed = self.notification_template('on-changed') + mocker.patch.object(Job, 'get_notification_templates', return_value={'success': [success], 'changed': [changed]}) + build = mocker.patch.object(Job, 'build_notification_message', return_value=('msg', 'body')) + + job.send_notification_templates('succeeded') + + assert self.notified_templates(build) == {success} + + @pytest.mark.django_db + def test_a_failed_job_that_changed_something_notifies_both(self, mocker): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=True, ok=1, changed=1, failures=1) + error = self.notification_template('on-error') + changed = self.notification_template('on-changed') + mocker.patch.object(Job, 'get_notification_templates', return_value={'error': [error], 'changed': [changed]}) + build = mocker.patch.object(Job, 'build_notification_message', return_value=('msg', 'body')) + + job.send_notification_templates('failed') + + assert self.notified_templates(build) == {error, changed} + + @pytest.mark.django_db + def test_the_start_of_a_job_does_not_notify_the_changed_templates(self, mocker): + job = Job.objects.create(name='fake-job') + job.job_host_summaries.create(host_name='host-a', failed=False, ok=1, changed=1, failures=0) + started = self.notification_template('on-started') + changed = self.notification_template('on-changed') + mocker.patch.object(Job, 'get_notification_templates', return_value={'started': [started], 'changed': [changed]}) + build = mocker.patch.object(Job, 'build_notification_message', return_value=('msg', 'body')) + + job.send_notification_templates('running') + + assert self.notified_templates(build) == {started} + + @pytest.mark.django_db + def test_an_unknown_status_is_refused(self): + job = Job.objects.create(name='fake-job') + + with pytest.raises(ValueError): + job.send_notification_templates('bogus') diff --git a/awx/main/tests/functional/test_notifications.py b/awx/main/tests/functional/test_notifications.py index cf93030ac..c635bc577 100644 --- a/awx/main/tests/functional/test_notifications.py +++ b/awx/main/tests/functional/test_notifications.py @@ -47,7 +47,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, 'workflow_approval': None} + assert response.data['messages'] == {'started': None, 'success': None, 'error': None, 'changed': None, 'workflow_approval': None} @pytest.mark.django_db diff --git a/awx/ui/src/api/mixins/Notifications.mixin.js b/awx/ui/src/api/mixins/Notifications.mixin.js index c3782e2d8..49d0ffd44 100644 --- a/awx/ui/src/api/mixins/Notifications.mixin.js +++ b/awx/ui/src/api/mixins/Notifications.mixin.js @@ -32,6 +32,13 @@ const NotificationsMixin = (parent) => ); } + readNotificationTemplatesChanged(id, params) { + return this.http.get( + `${this.baseUrl}${id}/notification_templates_changed/`, + { params } + ); + } + associateNotificationTemplatesStarted(resourceId, notificationId) { return this.http.post( `${this.baseUrl}${resourceId}/notification_templates_started/`, @@ -74,13 +81,28 @@ const NotificationsMixin = (parent) => ); } + associateNotificationTemplatesChanged(resourceId, notificationId) { + return this.http.post( + `${this.baseUrl}${resourceId}/notification_templates_changed/`, + { id: notificationId } + ); + } + + disassociateNotificationTemplatesChanged(resourceId, notificationId) { + return this.http.post( + `${this.baseUrl}${resourceId}/notification_templates_changed/`, + { id: notificationId, disassociate: true } + ); + } + /** * This is a helper method meant to simplify setting the "on" status of * a related notification. * * @param[resourceId] - id of the base resource * @param[notificationId] - id of the notification - * @param[notificationType] - the type of notification, options are "success" and "error" + * @param[notificationType] - the type of notification, options are "approvals", + * "started", "success", "error" and "changed" */ associateNotificationTemplate( resourceId, @@ -115,6 +137,13 @@ const NotificationsMixin = (parent) => ); } + if (notificationType === 'changed') { + return this.associateNotificationTemplatesChanged( + resourceId, + notificationId + ); + } + throw new Error( `Unsupported notificationType for association: ${notificationType}` ); @@ -126,7 +155,8 @@ const NotificationsMixin = (parent) => * * @param[resourceId] - id of the base resource * @param[notificationId] - id of the notification - * @param[notificationType] - the type of notification, options are "success" and "error" + * @param[notificationType] - the type of notification, options are "approvals", + * "started", "success", "error" and "changed" */ disassociateNotificationTemplate( resourceId, @@ -161,6 +191,13 @@ const NotificationsMixin = (parent) => ); } + if (notificationType === 'changed') { + return this.disassociateNotificationTemplatesChanged( + resourceId, + notificationId + ); + } + throw new Error( `Unsupported notificationType for disassociation: ${notificationType}` ); diff --git a/awx/ui/src/components/NotificationList/NotificationList.js b/awx/ui/src/components/NotificationList/NotificationList.js index 77a317c4a..cc0ffb7b0 100644 --- a/awx/ui/src/components/NotificationList/NotificationList.js +++ b/awx/ui/src/components/NotificationList/NotificationList.js @@ -26,6 +26,7 @@ function NotificationList({ id, showApprovalsToggle = false, + showChangedToggle = false, }) { const { t } = useLingui(); const location = useLocation(); @@ -38,6 +39,7 @@ function NotificationList({ notifications, itemCount, approvalsTemplateIds, + changedTemplateIds, startedTemplateIds, successTemplateIds, errorTemplateIds, @@ -106,12 +108,21 @@ function NotificationList({ rtnObj.approvalsTemplateIds = []; } + if (showChangedToggle) { + const { data: changedTemplates } = + await apiModel.readNotificationTemplatesChanged(id, idMatchParams); + rtnObj.changedTemplateIds = changedTemplates.results.map((ch) => ch.id); + } else { + rtnObj.changedTemplateIds = []; + } + return rtnObj; - }, [apiModel, id, location, showApprovalsToggle]), + }, [apiModel, id, location, showApprovalsToggle, showChangedToggle]), { notifications: [], itemCount: 0, approvalsTemplateIds: [], + changedTemplateIds: [], startedTemplateIds: [], successTemplateIds: [], errorTemplateIds: [], @@ -231,11 +242,13 @@ function NotificationList({ } toggleNotification={handleNotificationToggle} approvalsTurnedOn={approvalsTemplateIds.includes(notification.id)} + changedTurnedOn={changedTemplateIds.includes(notification.id)} errorTurnedOn={errorTemplateIds.includes(notification.id)} startedTurnedOn={startedTemplateIds.includes(notification.id)} successTurnedOn={successTemplateIds.includes(notification.id)} typeLabels={typeLabels} showApprovalsToggle={showApprovalsToggle} + showChangedToggle={showChangedToggle} rowIndex={index} /> )} diff --git a/awx/ui/src/components/NotificationList/NotificationList.test.js b/awx/ui/src/components/NotificationList/NotificationList.test.js index bde2efdbd..cf1e92330 100644 --- a/awx/ui/src/components/NotificationList/NotificationList.test.js +++ b/awx/ui/src/components/NotificationList/NotificationList.test.js @@ -197,3 +197,81 @@ describe('', () => { expect(within(errorDialog).getByText('Details')).toBeInTheDocument(); }); }); + +describe('', () => { + let container; + let user; + const data = { + count: 1, + results: [ + { + id: 1, + name: 'Notification one', + url: '/api/v2/notification_templates/1/', + notification_type: 'email', + }, + ], + }; + + const toggle = (id) => container.querySelector(`#${id}`); + + beforeEach(async () => { + NotificationTemplatesAPI.readOptions.mockReturnValue({ + data: { + actions: { + GET: { + notification_type: { + choices: [['email', 'Email']], + }, + }, + }, + }, + }); + + NotificationTemplatesAPI.read.mockReturnValue({ data }); + + JobTemplatesAPI.readNotificationTemplatesSuccess.mockReturnValue({ + data: { results: [] }, + }); + + JobTemplatesAPI.readNotificationTemplatesError.mockReturnValue({ + data: { results: [] }, + }); + + JobTemplatesAPI.readNotificationTemplatesStarted.mockReturnValue({ + data: { results: [] }, + }); + + JobTemplatesAPI.readNotificationTemplatesChanged.mockReturnValue({ + data: { results: [{ id: 1 }] }, + }); + + ({ container, user } = renderWithContexts( + + )); + + await waitFor(() => + expect(toggle('notification-1-changed-toggle')).toBeInTheDocument() + ); + }); + + test('should show the changed toggle as configured', () => { + expect(JobTemplatesAPI.readNotificationTemplatesChanged).toHaveBeenCalled(); + expect(toggle('notification-1-changed-toggle')).toBeChecked(); + }); + + test('should disable changed notification', async () => { + await user.click(toggle('notification-1-changed-toggle')); + expect( + JobTemplatesAPI.disassociateNotificationTemplate + ).toHaveBeenCalledWith(1, 1, 'changed'); + await waitFor(() => + expect(toggle('notification-1-changed-toggle')).not.toBeChecked() + ); + }); +}); diff --git a/awx/ui/src/components/NotificationList/NotificationListItem.js b/awx/ui/src/components/NotificationList/NotificationListItem.js index 41b4725bd..182e2a030 100644 --- a/awx/ui/src/components/NotificationList/NotificationListItem.js +++ b/awx/ui/src/components/NotificationList/NotificationListItem.js @@ -15,10 +15,12 @@ function NotificationListItem({ startedTurnedOn = false, successTurnedOn = false, errorTurnedOn = false, + changedTurnedOn = false, toggleNotification, typeLabels, showApprovalsToggle = false, + showChangedToggle = false, }) { const { t } = useLingui(); return ( @@ -32,7 +34,10 @@ function NotificationListItem({ {typeLabels[notification.notification_type]} - + + + + toggleNotification(notification.id, changedTurnedOn, 'changed') + } + aria-label={t`Toggle notification changed`} + /> + ); diff --git a/awx/ui/src/components/NotificationList/NotificationListItem.test.js b/awx/ui/src/components/NotificationList/NotificationListItem.test.js index 9ab571392..f7371e3dd 100644 --- a/awx/ui/src/components/NotificationList/NotificationListItem.test.js +++ b/awx/ui/src/components/NotificationList/NotificationListItem.test.js @@ -51,6 +51,33 @@ describe('', () => { expect(screen.getAllByRole('switch')).toHaveLength(4); }); + test('shows changed toggle when configured', () => { + setup({ showChangedToggle: true }); + expect(screen.getAllByRole('switch')).toHaveLength(4); + }); + + test('handles changed click when toggle is on', async () => { + const { user } = setup({ + showChangedToggle: true, + changedTurnedOn: true, + }); + await user.click( + screen.getByRole('switch', { name: 'Toggle notification changed' }) + ); + expect(toggleNotification).toHaveBeenCalledWith(9000, true, 'changed'); + }); + + test('handles changed click when toggle is off', async () => { + const { user } = setup({ + showChangedToggle: true, + changedTurnedOn: false, + }); + await user.click( + screen.getByRole('switch', { name: 'Toggle notification changed' }) + ); + expect(toggleNotification).toHaveBeenCalledWith(9000, false, 'changed'); + }); + test('displays correct type', () => { setup(); expect(screen.getByText('Slack')).toBeInTheDocument(); diff --git a/awx/ui/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.js b/awx/ui/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.js index f0e1629c7..78e8ce949 100644 --- a/awx/ui/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.js +++ b/awx/ui/src/screens/NotificationTemplate/shared/CustomMessagesSubForm.js @@ -40,6 +40,7 @@ function CustomMessagesSubForm({ defaultMessages, type }) { resetFields('messages.started', defs.started); resetFields('messages.success', defs.success); resetFields('messages.error', defs.error); + resetFields('messages.changed', defs.changed); resetFields( 'messages.workflow_approval.approved', defs.workflow_approval.approved @@ -162,6 +163,24 @@ function CustomMessagesSubForm({ defaultMessages, type }) { rows={6} /> )} + {showMessages && ( + + )} + {showBodies && ( + + )} {showMessages && ( } /> diff --git a/awx/ui/src/screens/Template/Template.js b/awx/ui/src/screens/Template/Template.js index 24783fe5d..c93fae254 100644 --- a/awx/ui/src/screens/Template/Template.js +++ b/awx/ui/src/screens/Template/Template.js @@ -244,6 +244,7 @@ function Template({ setBreadcrumb }) { id={Number(templateId)} canToggleNotifications={isNotifAdmin} apiModel={JobTemplatesAPI} + showChangedToggle /> } /> diff --git a/docs/docsite/rst/userguide/notifications.rst b/docs/docsite/rst/userguide/notifications.rst index a599c2830..febf84028 100644 --- a/docs/docsite/rst/userguide/notifications.rst +++ b/docs/docsite/rst/userguide/notifications.rst @@ -23,7 +23,7 @@ A Notification is a manifestation of the notification template; for example, whe At a high level, the typical flow for the notification system works as follows: - A user creates a notification template to the REST API at the ``/api/v2/notification_templates`` endpoint (either through the API or through the UI). -- A user assigns the notification template to any of the various objects that support it (all variants of job templates as well as organizations and projects) and at the appropriate trigger level for which they want the notification (started, success, or error). For example a user may wish to assign a particular notification template to trigger when Job Template 1 fails. In which case, they will associate the notification template with the job template at ``/api/v2/job_templates/n/notification_templates_error`` API endpoint. +- A user assigns the notification template to any of the various objects that support it (all variants of job templates as well as organizations and projects) and at the appropriate trigger level for which they want the notification (started, success, error, or, for job templates and organizations, changed). For example a user may wish to assign a particular notification template to trigger when Job Template 1 fails. In which case, they will associate the notification template with the job template at ``/api/v2/job_templates/n/notification_templates_error`` API endpoint. - You can set notifications on job start, not just job end. Users and teams are also able to define their own notifications that can be attached to arbitrary jobs. @@ -536,6 +536,8 @@ For workflow templates that have approval nodes, in addition to *Start*, *Succes Refer to :ref:`ug_wf_approval_nodes` for additional detail on working with these types of nodes. +Job templates and organizations also offer a *Changed* trigger, which notifies when a run reported a change on any host. On an organization it covers the jobs and ad hoc commands run under it. It fires next to the trigger for how the run ended, so a job that changed something and succeeded notifies both *Success* and *Changed*. A run in check mode reports the changes it would have made, which makes this the trigger to use for a hardening or compliance playbook that runs nightly and should only be reported on when it finds drift. The API endpoint for the association is ``/api/v2/job_templates/n/notification_templates_changed`` and ``/api/v2/organizations/n/notification_templates_changed``. + Configure the ``host`` hostname for notifications ========================================================