diff --git a/awx/main/conf.py b/awx/main/conf.py index 438c100bef..ca3cdbab4f 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -210,6 +210,17 @@ register( category_slug='jobs', ) +register( + 'AWX_TASK_ENV', + field_class=fields.DictField, + default={}, + label=_('Extra Environment Variables'), + help_text=_('Additional environment variables set for playbook runs, inventory updates, project updates, and notification sending.'), + category=_('Jobs'), + category_slug='jobs', + placeholder={'HTTP_PROXY': 'myproxy.local:8080'}, +) + register( 'STDOUT_MAX_BYTES_DISPLAY', field_class=fields.IntegerField, diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 4e42a96060..f98bcd99b1 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -4,6 +4,7 @@ import logging from django.db import models +from django.conf import settings from django.core.mail.message import EmailMessage from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_str, force_text @@ -11,7 +12,7 @@ from django.utils.encoding import smart_str, force_text # AWX from awx.api.versioning import reverse from awx.main.models.base import * # noqa -from awx.main.utils import encrypt_field, decrypt_field +from awx.main.utils import encrypt_field, decrypt_field, set_environ from awx.main.notifications.email_backend import CustomEmailBackend from awx.main.notifications.slack_backend import SlackBackend from awx.main.notifications.twilio_backend import TwilioBackend @@ -117,7 +118,8 @@ class NotificationTemplate(CommonModelNameNotUnique): sender = self.notification_configuration.pop(self.notification_class.sender_parameter, None) backend_obj = self.notification_class(**self.notification_configuration) notification_obj = EmailMessage(subject, backend_obj.format_body(body), sender, recipients) - return backend_obj.send_messages([notification_obj]) + with set_environ(**settings.AWX_TASK_ENV): + return backend_obj.send_messages([notification_obj]) def display_notification_configuration(self): field_val = self.notification_configuration.copy() diff --git a/awx/main/tests/functional/test_notifications.py b/awx/main/tests/functional/test_notifications.py index 2f9787d9f0..fe350fd988 100644 --- a/awx/main/tests/functional/test_notifications.py +++ b/awx/main/tests/functional/test_notifications.py @@ -1,6 +1,10 @@ import mock import pytest +from requests.adapters import HTTPAdapter +from requests.utils import select_proxy +from requests.exceptions import ConnectionError + from awx.api.versioning import reverse from awx.main.models.notifications import NotificationTemplate, Notification from awx.main.models.inventory import Inventory, InventorySource @@ -129,3 +133,26 @@ def test_disallow_delete_when_notifications_pending(delete, user, notification_t response = delete(url, user=u) assert response.status_code == 405 + +@pytest.mark.django_db +def test_custom_environment_injection(post, user, organization): + u = user('admin-poster', True) + url = reverse('api:notification_template_list') + response = post(url, + dict(name="test-webhook", + description="test webhook", + organization=organization.id, + notification_type="webhook", + notification_configuration=dict(url="https://example.org", + headers={"Test": "Header"})), + u) + assert response.status_code == 201 + template = NotificationTemplate.objects.get(pk=response.data['id']) + with pytest.raises(ConnectionError), \ + mock.patch('django.conf.settings.AWX_TASK_ENV', {'HTTPS_PROXY': '192.168.50.100:1234'}), \ + mock.patch.object(HTTPAdapter, 'send') as fake_send: + def _send_side_effect(request, **kw): + assert select_proxy(request.url, kw['proxies']) == '192.168.50.100:1234' + raise ConnectionError() + fake_send.side_effect = _send_side_effect + template.send('subject', 'message') diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 08b5e80d72..ef913e9914 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -290,6 +290,17 @@ class TestGenericRun(TestJobExecution): args, cwd, env, stdout = call_args assert args[0] == 'bwrap' + def test_awx_task_env(self): + patch = mock.patch('awx.main.tasks.settings.AWX_TASK_ENV', {'FOO': 'BAR'}) + patch.start() + + self.task.run(self.pk) + + assert self.run_pexpect.call_count == 1 + call_args, _ = self.run_pexpect.call_args_list[0] + args, cwd, env, stdout = call_args + assert env['FOO'] == 'BAR' + class TestIsolatedExecution(TestJobExecution): @@ -1035,6 +1046,17 @@ class TestJobCredentials(TestJobExecution): self.run_pexpect.side_effect = run_pexpect_side_effect self.task.run(self.pk) + def test_awx_task_env(self): + patch = mock.patch('awx.main.tasks.settings.AWX_TASK_ENV', {'FOO': 'BAR'}) + patch.start() + + self.task.run(self.pk) + + assert self.run_pexpect.call_count == 1 + call_args, _ = self.run_pexpect.call_args_list[0] + args, cwd, env, stdout = call_args + assert env['FOO'] == 'BAR' + class TestProjectUpdateCredentials(TestJobExecution): @@ -1056,6 +1078,11 @@ class TestProjectUpdateCredentials(TestJobExecution): dict(scm_type='git'), dict(scm_type='hg'), dict(scm_type='svn'), + ], + 'test_awx_task_env': [ + dict(scm_type='git'), + dict(scm_type='hg'), + dict(scm_type='svn'), ] } @@ -1113,6 +1140,18 @@ class TestProjectUpdateCredentials(TestJobExecution): self.run_pexpect.side_effect = partial(run_pexpect_side_effect, private_data) self.task.run(self.pk) + def test_awx_task_env(self, scm_type): + self.instance.scm_type = scm_type + patch = mock.patch('awx.main.tasks.settings.AWX_TASK_ENV', {'FOO': 'BAR'}) + patch.start() + + self.task.run(self.pk) + + assert self.run_pexpect.call_count == 1 + call_args, _ = self.run_pexpect.call_args_list[0] + args, cwd, env, stdout = call_args + assert env['FOO'] == 'BAR' + class TestInventoryUpdateCredentials(TestJobExecution): @@ -1323,6 +1362,28 @@ class TestInventoryUpdateCredentials(TestJobExecution): self.run_pexpect.side_effect = run_pexpect_side_effect self.task.run(self.pk) + def test_awx_task_env(self): + gce = CredentialType.defaults['gce']() + self.instance.source = 'gce' + self.instance.credential = Credential( + pk=1, + credential_type=gce, + inputs = { + 'username': 'bob', + 'project': 'some-project', + } + ) + patch = mock.patch('awx.main.tasks.settings.AWX_TASK_ENV', {'FOO': 'BAR'}) + patch.start() + + self.task.run(self.pk) + + assert self.run_pexpect.call_count == 1 + call_args, _ = self.run_pexpect.call_args_list[0] + args, cwd, env, stdout = call_args + assert env['FOO'] == 'BAR' + + def test_os_open_oserror(): with pytest.raises(OSError): diff --git a/awx/main/tests/unit/utils/test_common.py b/awx/main/tests/unit/utils/test_common.py index 030962da52..f8592d844b 100644 --- a/awx/main/tests/unit/utils/test_common.py +++ b/awx/main/tests/unit/utils/test_common.py @@ -2,7 +2,9 @@ # Copyright (c) 2017 Ansible, Inc. # All Rights Reserved. +import os import pytest +from uuid import uuid4 from awx.main.utils import common @@ -15,3 +17,13 @@ from awx.main.utils import common ]) def test_parse_yaml_or_json(input_, output): assert common.parse_yaml_or_json(input_) == output + + +def test_set_environ(): + key = str(uuid4()) + old_environ = os.environ.copy() + with common.set_environ(**{key: 'bar'}): + assert os.environ[key] == 'bar' + assert set(os.environ.keys()) - set(old_environ.keys()) == set([key]) + assert os.environ == old_environ + assert key not in os.environ diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 5f0d49ec0a..473683a668 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -42,7 +42,7 @@ __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', 'callback_filter_out_ansible_extra_vars', 'get_search_fields', 'get_system_task_capacity', 'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict', 'model_instance_diff', 'timestamp_apiformat', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', - 'has_model_field_prefetched'] + 'has_model_field_prefetched', 'set_environ'] def get_object_or_400(klass, *args, **kwargs): @@ -583,6 +583,23 @@ def ignore_inventory_group_removal(): _inventory_updates.is_removing = previous_value +@contextlib.contextmanager +def set_environ(**environ): + ''' + Temporarily set the process environment variables. + + >>> with set_environ(FOO='BAR'): + ... assert os.environ['FOO'] == 'BAR' + ''' + old_environ = os.environ.copy() + try: + os.environ.update(environ) + yield + finally: + os.environ.clear() + os.environ.update(old_environ) + + @memoize() def check_proot_installed(): ''' diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bd8655d545..7423e90956 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -37,3 +37,6 @@ * Tower now uses a modified version of [Fernet](https://github.com/fernet/spec/blob/master/Spec.md). Our `Fernet256` class uses `AES-256-CBC` instead of `AES-128-CBC` for all encrypted fields. [[#826](https://github.com/ansible/ansible-tower/issues/826)] +* Added the ability to set custom environment variables set for playbook runs, + inventory updates, project updates, and notification sending. + [[#3508](https://github.com/ansible/ansible-tower/issues/3508)]