Support AWX_TASK_ENV injection in task and notification invocations.

This change _only_ injects `AWS_TASK_ENV` into `os.environ`; it's up to
underlying libraries to be good citizens and actually respect things
like `HTTPS_PROXY`.

see: #3508
This commit is contained in:
Ryan Petrello
2017-07-06 10:22:59 -04:00
parent 92bc5fd3f0
commit 12d41e2deb
7 changed files with 136 additions and 3 deletions

View File

@@ -210,6 +210,17 @@ register(
category_slug='jobs', 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( register(
'STDOUT_MAX_BYTES_DISPLAY', 'STDOUT_MAX_BYTES_DISPLAY',
field_class=fields.IntegerField, field_class=fields.IntegerField,

View File

@@ -4,6 +4,7 @@
import logging import logging
from django.db import models from django.db import models
from django.conf import settings
from django.core.mail.message import EmailMessage from django.core.mail.message import EmailMessage
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.encoding import smart_str, force_text from django.utils.encoding import smart_str, force_text
@@ -11,7 +12,7 @@ from django.utils.encoding import smart_str, force_text
# AWX # AWX
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main.models.base import * # noqa 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.email_backend import CustomEmailBackend
from awx.main.notifications.slack_backend import SlackBackend from awx.main.notifications.slack_backend import SlackBackend
from awx.main.notifications.twilio_backend import TwilioBackend 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) sender = self.notification_configuration.pop(self.notification_class.sender_parameter, None)
backend_obj = self.notification_class(**self.notification_configuration) backend_obj = self.notification_class(**self.notification_configuration)
notification_obj = EmailMessage(subject, backend_obj.format_body(body), sender, recipients) 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): def display_notification_configuration(self):
field_val = self.notification_configuration.copy() field_val = self.notification_configuration.copy()

View File

@@ -1,6 +1,10 @@
import mock import mock
import pytest 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.api.versioning import reverse
from awx.main.models.notifications import NotificationTemplate, Notification from awx.main.models.notifications import NotificationTemplate, Notification
from awx.main.models.inventory import Inventory, InventorySource 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) response = delete(url, user=u)
assert response.status_code == 405 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')

View File

@@ -290,6 +290,17 @@ class TestGenericRun(TestJobExecution):
args, cwd, env, stdout = call_args args, cwd, env, stdout = call_args
assert args[0] == 'bwrap' 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): class TestIsolatedExecution(TestJobExecution):
@@ -1035,6 +1046,17 @@ class TestJobCredentials(TestJobExecution):
self.run_pexpect.side_effect = run_pexpect_side_effect self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk) 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): class TestProjectUpdateCredentials(TestJobExecution):
@@ -1056,6 +1078,11 @@ class TestProjectUpdateCredentials(TestJobExecution):
dict(scm_type='git'), dict(scm_type='git'),
dict(scm_type='hg'), dict(scm_type='hg'),
dict(scm_type='svn'), 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.run_pexpect.side_effect = partial(run_pexpect_side_effect, private_data)
self.task.run(self.pk) 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): class TestInventoryUpdateCredentials(TestJobExecution):
@@ -1323,6 +1362,28 @@ class TestInventoryUpdateCredentials(TestJobExecution):
self.run_pexpect.side_effect = run_pexpect_side_effect self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk) 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(): def test_os_open_oserror():
with pytest.raises(OSError): with pytest.raises(OSError):

View File

@@ -2,7 +2,9 @@
# Copyright (c) 2017 Ansible, Inc. # Copyright (c) 2017 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
import os
import pytest import pytest
from uuid import uuid4
from awx.main.utils import common from awx.main.utils import common
@@ -15,3 +17,13 @@ from awx.main.utils import common
]) ])
def test_parse_yaml_or_json(input_, output): def test_parse_yaml_or_json(input_, output):
assert common.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

View File

@@ -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', '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', 'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict',
'model_instance_diff', 'timestamp_apiformat', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', '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): def get_object_or_400(klass, *args, **kwargs):
@@ -583,6 +583,23 @@ def ignore_inventory_group_removal():
_inventory_updates.is_removing = previous_value _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() @memoize()
def check_proot_installed(): def check_proot_installed():
''' '''

View File

@@ -37,3 +37,6 @@
* Tower now uses a modified version of [Fernet](https://github.com/fernet/spec/blob/master/Spec.md). * 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. 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)] [[#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)]