diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 89531632b6..46b2562098 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -227,6 +227,156 @@ class Notification(CreatedModifiedModel): class JobNotificationMixin(object): + # Tree of fields that can be safely referenced in a notification message + JOB_FIELDS_WHITELIST = ['id', 'type', 'url', 'created', 'modified', 'name', 'description', 'job_type', 'playbook', + 'forks', 'limit', 'verbosity', 'job_tags', 'force_handlers', 'skip_tags', 'start_at_task', + 'timeout', 'use_fact_cache', 'launch_type', 'status', 'failed', 'started', 'finished', + 'elapsed', 'job_explanation', 'execution_node', 'controller_node', 'allow_simultaneous', + 'scm_revision', 'diff_mode', 'job_slice_number', 'job_slice_count', 'custom_virtualenv', + {'host_status_counts': ['skipped', 'ok', 'changed', 'failures', 'dark']}, + {'playbook_counts': ['play_count', 'task_count']}, + {'summary_fields': [{'inventory': ['id', 'name', 'description', 'has_active_failures', + 'total_hosts', 'hosts_with_active_failures', 'total_groups', + 'groups_with_active_failures', 'has_inventory_sources', + 'total_inventory_sources', 'inventory_sources_with_failures', + 'organization_id', 'kind']}, + {'project': ['id', 'name', 'description', 'status', 'scm_type']}, + {'project_update': ['id', 'name', 'description', 'status', 'failed']}, + {'job_template': ['id', 'name', 'description']}, + {'unified_job_template': ['id', 'name', 'description', 'unified_job_type']}, + {'instance_group': ['name', 'id']}, + {'created_by': ['id', 'username', 'first_name', 'last_name']}, + {'labels': ['count', 'results']}, + {'source_workflow_job': ['description', 'elapsed', 'failed', 'id', 'name', 'status']}]}] + + @classmethod + def context_stub(cls): + """Returns a stub context that can be used for validating notification messages. + Context has the same structure as the context that will actually be used to render + a notification message.""" + context = {'job': {'allow_simultaneous': False, + 'controller_node': 'foo_controller', + 'created': datetime.datetime(2018, 11, 13, 6, 4, 0, 0, tzinfo=datetime.timezone.utc), + 'custom_virtualenv': 'my_venv', + 'description': 'Sample job description', + 'diff_mode': False, + 'elapsed': 0.403018, + 'execution_node': 'awx', + 'failed': False, + 'finished': False, + 'force_handlers': False, + 'forks': 0, + 'host_status_counts': {'skipped': 1, 'ok': 5, 'changed': 3, 'failures': 0, 'dark': 0}, + 'id': 42, + 'job_explanation': 'Sample job explanation', + 'job_slice_count': 1, + 'job_slice_number': 0, + 'job_tags': '', + 'job_type': 'run', + 'launch_type': 'workflow', + 'limit': 'bar_limit', + 'modified': datetime.datetime(2018, 12, 13, 6, 4, 0, 0, tzinfo=datetime.timezone.utc), + 'name': 'Stub JobTemplate', + 'playbook_counts': {'play_count': 5, 'task_count': 10}, + 'playbook': 'ping.yml', + 'scm_revision': '', + 'skip_tags': '', + 'start_at_task': '', + 'started': '2019-07-29T17:38:14.137461Z', + 'status': 'running', + 'summary_fields': {'created_by': {'first_name': '', + 'id': 1, + 'last_name': '', + 'username': 'admin'}, + 'instance_group': {'id': 1, 'name': 'tower'}, + 'inventory': {'description': 'Sample inventory description', + 'groups_with_active_failures': 0, + 'has_active_failures': False, + 'has_inventory_sources': False, + 'hosts_with_active_failures': 0, + 'id': 17, + 'inventory_sources_with_failures': 0, + 'kind': '', + 'name': 'Stub Inventory', + 'organization_id': 121, + 'total_groups': 0, + 'total_hosts': 1, + 'total_inventory_sources': 0}, + 'job_template': {'description': 'Sample job template description', + 'id': 39, + 'name': 'Stub JobTemplate'}, + 'labels': {'count': 0, 'results': []}, + 'project': {'description': 'Sample project description', + 'id': 38, + 'name': 'Stub project', + 'scm_type': 'git', + 'status': 'successful'}, + 'project_update': {'id': 5, 'name': 'Stub Project Update', 'description': 'Project Update', + 'status': 'running', 'failed': False}, + 'unified_job_template': {'description': 'Sample unified job template description', + 'id': 39, + 'name': 'Stub Job Template', + 'unified_job_type': 'job'}, + 'source_workflow_job': {'description': 'Sample workflow job description', + 'elapsed': 0.000, + 'failed': False, + 'id': 88, + 'name': 'Stub WorkflowJobTemplate', + 'status': 'running'}}, + 'timeout': 0, + 'type': 'job', + 'url': '/api/v2/jobs/13/', + 'use_fact_cache': False, + 'verbosity': 0}, + 'job_friendly_name': 'Job', + 'url': 'https://towerhost/#/jobs/playbook/1010', + 'job_summary_dict': """{'url': 'https://towerhost/$/jobs/playbook/13', + 'traceback': '', + 'status': 'running', + 'started': '2019-08-07T21:46:38.362630+00:00', + 'project': 'Stub project', + 'playbook': 'ping.yml', + 'name': 'Stub Job Template', + 'limit': '', + 'inventory': 'Stub Inventory', + 'id': 42, + 'hosts': {}, + 'friendly_name': 'Job', + 'finished': False, + 'credential': 'Stub credential', + 'created_by': 'admin'}"""} + + 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 + (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)} + + def build_context(node, fields, whitelisted_fields): + for safe_field in whitelisted_fields: + if type(safe_field) is dict: + field, whitelist_subnode = safe_field.copy().popitem() + # ensure content present in job serialization + if field not in fields: + continue + subnode = fields[field] + node[field] = {} + build_context(node[field], subnode, whitelist_subnode) + else: + # ensure content present in job serialization + if safe_field not in fields: + continue + node[safe_field] = fields[safe_field] + build_context(context['job'], serialized_job, self.JOB_FIELDS_WHITELIST) + + return context + def get_notification_templates(self): raise RuntimeError("Define me") diff --git a/awx/main/tests/functional/models/test_notifications.py b/awx/main/tests/functional/models/test_notifications.py new file mode 100644 index 0000000000..e835f2d2dd --- /dev/null +++ b/awx/main/tests/functional/models/test_notifications.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +from copy import deepcopy +import datetime + +import pytest + +#from awx.main.models import NotificationTemplates, Notifications, JobNotificationMixin +from awx.main.models import (AdHocCommand, InventoryUpdate, Job, JobNotificationMixin, ProjectUpdate, + SystemJob, WorkflowJob) +from awx.api.serializers import UnifiedJobSerializer + + +class TestJobNotificationMixin(object): + CONTEXT_STRUCTURE = {'job': {'allow_simultaneous': bool, + 'custom_virtualenv': str, + 'controller_node': str, + 'created': datetime.datetime, + 'description': str, + 'diff_mode': bool, + 'elapsed': float, + 'execution_node': str, + 'failed': bool, + 'finished': bool, + 'force_handlers': bool, + 'forks': int, + 'host_status_counts': {'skipped': int, 'ok': int, 'changed': int, + 'failures': int, 'dark': int}, + 'id': int, + 'job_explanation': str, + 'job_slice_count': int, + 'job_slice_number': int, + 'job_tags': str, + 'job_type': str, + 'launch_type': str, + 'limit': str, + 'modified': datetime.datetime, + 'name': str, + 'playbook': str, + 'playbook_counts': {'play_count': int, 'task_count': int}, + 'scm_revision': str, + 'skip_tags': str, + 'start_at_task': str, + 'started': str, + 'status': str, + 'summary_fields': {'created_by': {'first_name': str, + 'id': int, + 'last_name': str, + 'username': str}, + 'instance_group': {'id': int, 'name': str}, + 'inventory': {'description': str, + 'groups_with_active_failures': int, + 'has_active_failures': bool, + 'has_inventory_sources': bool, + 'hosts_with_active_failures': int, + 'id': int, + 'inventory_sources_with_failures': int, + 'kind': str, + 'name': str, + 'organization_id': int, + 'total_groups': int, + 'total_hosts': int, + 'total_inventory_sources': int}, + 'job_template': {'description': str, + 'id': int, + 'name': str}, + 'labels': {'count': int, 'results': list}, + 'project': {'description': str, + 'id': int, + 'name': str, + 'scm_type': str, + 'status': str}, + 'project_update': {'id': int, 'name': str, 'description': str, 'status': str, 'failed': bool}, + 'unified_job_template': {'description': str, + 'id': int, + 'name': str, + 'unified_job_type': str}, + 'source_workflow_job': {'description': str, + 'elapsed': float, + 'failed': bool, + 'id': int, + 'name': str, + 'status': str}}, + + 'timeout': int, + 'type': str, + 'url': str, + 'use_fact_cache': bool, + 'verbosity': int}, + 'job_friendly_name': str, + 'job_summary_dict': str, + 'url': str} + + + @pytest.mark.django_db + @pytest.mark.parametrize('JobClass', [AdHocCommand, InventoryUpdate, Job, ProjectUpdate, SystemJob, WorkflowJob]) + def test_context(self, JobClass, sqlite_copy_expert, project, inventory_source): + """The Jinja context defines all of the fields that can be used by a template. Ensure that the context generated + for each job type has the expected structure.""" + def check_structure(expected_structure, obj): + if isinstance(expected_structure, dict): + assert isinstance(obj, dict) + for key in obj: + assert key in expected_structure + if obj[key] is None: + continue + if isinstance(expected_structure[key], dict): + assert isinstance(obj[key], dict) + check_structure(expected_structure[key], obj[key]) + else: + assert isinstance(obj[key], expected_structure[key]) + kwargs = {} + if JobClass is InventoryUpdate: + kwargs['inventory_source'] = inventory_source + elif JobClass is ProjectUpdate: + kwargs['project'] = project + + job = JobClass.objects.create(name='foo', **kwargs) + job_serialization = UnifiedJobSerializer(job).to_representation(job) + + context = job.context(job_serialization) + check_structure(TestJobNotificationMixin.CONTEXT_STRUCTURE, context) + + def test_context_stub(self): + """The context stub is a fake context used to validate custom notification messages. Ensure that + this also has the expected structure. Furthermore, ensure that the stub context contains + *all* fields that could possibly be included in a context.""" + def check_structure_and_completeness(expected_structure, obj): + expected_structure = deepcopy(expected_structure) + if isinstance(expected_structure, dict): + assert isinstance(obj, dict) + for key in obj: + assert key in expected_structure + # Context stub should not have any undefined fields + assert obj[key] is not None + if isinstance(expected_structure[key], dict): + assert isinstance(obj[key], dict) + check_structure_and_completeness(expected_structure[key], obj[key]) + expected_structure.pop(key) + else: + assert isinstance(obj[key], expected_structure[key]) + expected_structure.pop(key) + # Ensure all items in expected structure were present + assert not len(expected_structure) + + context_stub = JobNotificationMixin.context_stub() + check_structure_and_completeness(TestJobNotificationMixin.CONTEXT_STRUCTURE, context_stub) + +