Merge pull request #940 from ryanpetrello/multivenv

implement support for per-playbook/project/org virtualenvs
This commit is contained in:
Matthew Jones
2018-01-10 12:15:38 -05:00
committed by GitHub
20 changed files with 352 additions and 30 deletions

View File

@@ -896,7 +896,7 @@ class OrganizationSerializer(BaseSerializer):
class Meta: class Meta:
model = Organization model = Organization
fields = ('*',) fields = ('*', 'custom_virtualenv',)
def get_related(self, obj): def get_related(self, obj):
res = super(OrganizationSerializer, self).get_related(obj) res = super(OrganizationSerializer, self).get_related(obj)
@@ -984,7 +984,7 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
class Meta: class Meta:
model = Project model = Project
fields = ('*', 'organization', 'scm_delete_on_next_update', 'scm_update_on_launch', fields = ('*', 'organization', 'scm_delete_on_next_update', 'scm_update_on_launch',
'scm_update_cache_timeout', 'scm_revision',) + \ 'scm_update_cache_timeout', 'scm_revision', 'custom_virtualenv',) + \
('last_update_failed', 'last_updated') # Backwards compatibility ('last_update_failed', 'last_updated') # Backwards compatibility
read_only_fields = ('scm_delete_on_next_update',) read_only_fields = ('scm_delete_on_next_update',)
@@ -2530,7 +2530,7 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
fields = ('*', 'host_config_key', 'ask_diff_mode_on_launch', 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch', fields = ('*', 'host_config_key', 'ask_diff_mode_on_launch', 'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch',
'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch', 'ask_inventory_on_launch', 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_verbosity_on_launch', 'ask_inventory_on_launch',
'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'diff_mode', 'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'diff_mode',
'allow_simultaneous') 'allow_simultaneous', 'custom_virtualenv')
def get_related(self, obj): def get_related(self, obj):
res = super(JobTemplateSerializer, self).get_related(obj) res = super(JobTemplateSerializer, self).get_related(obj)

View File

@@ -315,6 +315,7 @@ class ApiV1ConfigView(APIView):
data.update(dict( data.update(dict(
project_base_dir = settings.PROJECTS_ROOT, project_base_dir = settings.PROJECTS_ROOT,
project_local_paths = Project.get_local_path_choices(), project_local_paths = Project.get_local_path_choices(),
custom_virtualenvs = get_custom_venv_choices(),
)) ))
return Response(data) return Response(data)

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2018-01-09 21:30
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0018_v330_add_additional_stdout_events'),
]
operations = [
migrations.AddField(
model_name='jobtemplate',
name='custom_virtualenv',
field=models.CharField(blank=True, default=None, max_length=100, null=True),
),
migrations.AddField(
model_name='organization',
name='custom_virtualenv',
field=models.CharField(blank=True, default=None, max_length=100, null=True),
),
migrations.AddField(
model_name='project',
name='custom_virtualenv',
field=models.CharField(blank=True, default=None, max_length=100, null=True),
),
]

View File

@@ -34,7 +34,7 @@ from awx.main.models.notifications import (
) )
from awx.main.utils import parse_yaml_or_json from awx.main.utils import parse_yaml_or_json
from awx.main.fields import ImplicitRoleField from awx.main.fields import ImplicitRoleField
from awx.main.models.mixins import ResourceMixin, SurveyJobTemplateMixin, SurveyJobMixin, TaskManagerJobMixin from awx.main.models.mixins import ResourceMixin, SurveyJobTemplateMixin, SurveyJobMixin, TaskManagerJobMixin, CustomVirtualEnvMixin
from awx.main.fields import JSONField, AskForField from awx.main.fields import JSONField, AskForField
@@ -215,7 +215,7 @@ class JobOptions(BaseModel):
return needed return needed
class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin): class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin):
''' '''
A job template is a reusable job definition for applying a project (with A job template is a reusable job definition for applying a project (with
playbook) to an inventory source with a given credential. playbook) to an inventory source with a given credential.
@@ -509,6 +509,18 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
def get_ui_url(self): def get_ui_url(self):
return urljoin(settings.TOWER_URL_BASE, "/#/jobs/{}".format(self.pk)) return urljoin(settings.TOWER_URL_BASE, "/#/jobs/{}".format(self.pk))
@property
def ansible_virtualenv_path(self):
# the order here enforces precedence (it matters)
for virtualenv in (
self.job_template.custom_virtualenv,
self.project.custom_virtualenv,
self.project.organization.custom_virtualenv
):
if virtualenv:
return virtualenv
return settings.ANSIBLE_VENV_PATH
@property @property
def event_class(self): def event_class(self):
return JobEvent return JobEvent

View File

@@ -1,26 +1,29 @@
# Python # Python
import os
import json import json
from copy import copy, deepcopy from copy import copy, deepcopy
# Django # Django
from django.conf import settings
from django.db import models from django.db import models
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User # noqa from django.contrib.auth.models import User # noqa
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError
# AWX # AWX
from awx.main.models.base import prevent_search from awx.main.models.base import prevent_search
from awx.main.models.rbac import ( from awx.main.models.rbac import (
Role, RoleAncestorEntry, get_roles_on_resource Role, RoleAncestorEntry, get_roles_on_resource
) )
from awx.main.utils import parse_yaml_or_json from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices
from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted
from awx.main.fields import JSONField, AskForField from awx.main.fields import JSONField, AskForField
__all__ = ['ResourceMixin', 'SurveyJobTemplateMixin', 'SurveyJobMixin', __all__ = ['ResourceMixin', 'SurveyJobTemplateMixin', 'SurveyJobMixin',
'TaskManagerUnifiedJobMixin', 'TaskManagerJobMixin', 'TaskManagerProjectUpdateMixin', 'TaskManagerUnifiedJobMixin', 'TaskManagerJobMixin', 'TaskManagerProjectUpdateMixin',
'TaskManagerInventoryUpdateMixin',] 'TaskManagerInventoryUpdateMixin', 'CustomVirtualEnvMixin']
class ResourceMixin(models.Model): class ResourceMixin(models.Model):
@@ -416,3 +419,23 @@ class TaskManagerProjectUpdateMixin(TaskManagerUpdateOnLaunchMixin):
class TaskManagerInventoryUpdateMixin(TaskManagerUpdateOnLaunchMixin): class TaskManagerInventoryUpdateMixin(TaskManagerUpdateOnLaunchMixin):
class Meta: class Meta:
abstract = True abstract = True
class CustomVirtualEnvMixin(models.Model):
class Meta:
abstract = True
custom_virtualenv = models.CharField(
blank=True,
null=True,
default=None,
max_length=100
)
def clean_custom_virtualenv(self):
value = self.custom_virtualenv
if value and os.path.join(value, '') not in get_custom_venv_choices():
raise ValidationError(
_('{} is not a valid virtualenv in {}').format(value, settings.BASE_VENV_PATH)
)
return os.path.join(value or '', '')

View File

@@ -22,12 +22,12 @@ from awx.main.models.rbac import (
ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
ROLE_SINGLETON_SYSTEM_AUDITOR, ROLE_SINGLETON_SYSTEM_AUDITOR,
) )
from awx.main.models.mixins import ResourceMixin from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin
__all__ = ['Organization', 'Team', 'Profile', 'AuthToken'] __all__ = ['Organization', 'Team', 'Profile', 'AuthToken']
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin): class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin):
''' '''
An organization is the basic unit of multi-tenancy divisions An organization is the basic unit of multi-tenancy divisions
''' '''

View File

@@ -24,7 +24,7 @@ from awx.main.models.notifications import (
JobNotificationMixin, JobNotificationMixin,
) )
from awx.main.models.unified_jobs import * # noqa from awx.main.models.unified_jobs import * # noqa
from awx.main.models.mixins import ResourceMixin, TaskManagerProjectUpdateMixin from awx.main.models.mixins import ResourceMixin, TaskManagerProjectUpdateMixin, CustomVirtualEnvMixin
from awx.main.utils import update_scm_url from awx.main.utils import update_scm_url
from awx.main.utils.ansible import skip_directory, could_be_inventory, could_be_playbook from awx.main.utils.ansible import skip_directory, could_be_inventory, could_be_playbook
from awx.main.fields import ImplicitRoleField from awx.main.fields import ImplicitRoleField
@@ -223,7 +223,7 @@ class ProjectOptions(models.Model):
return proj_path + '.lock' return proj_path + '.lock'
class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEnvMixin):
''' '''
A project represents a playbook git repo that can access a set of inventories A project represents a playbook git repo that can access a set of inventories
''' '''

View File

@@ -626,10 +626,16 @@ class BaseTask(LogErrorsTask):
'': '', '': '',
} }
def add_ansible_venv(self, env, add_awx_lib=True): def add_ansible_venv(self, venv_path, env, add_awx_lib=True):
env['VIRTUAL_ENV'] = settings.ANSIBLE_VENV_PATH env['VIRTUAL_ENV'] = venv_path
env['PATH'] = os.path.join(settings.ANSIBLE_VENV_PATH, "bin") + ":" + env['PATH'] env['PATH'] = os.path.join(venv_path, "bin") + ":" + env['PATH']
venv_libdir = os.path.join(settings.ANSIBLE_VENV_PATH, "lib") venv_libdir = os.path.join(venv_path, "lib")
if not os.path.exists(venv_libdir):
raise RuntimeError(
'a valid Python virtualenv does not exist at {}'.format(venv_path)
)
env.pop('PYTHONPATH', None) # default to none if no python_ver matches env.pop('PYTHONPATH', None) # default to none if no python_ver matches
if os.path.isdir(os.path.join(venv_libdir, "python2.7")): if os.path.isdir(os.path.join(venv_libdir, "python2.7")):
env['PYTHONPATH'] = os.path.join(venv_libdir, "python2.7", "site-packages") + ":" env['PYTHONPATH'] = os.path.join(venv_libdir, "python2.7", "site-packages") + ":"
@@ -802,6 +808,8 @@ class BaseTask(LogErrorsTask):
kwargs['private_data_files'] = self.build_private_data_files(instance, **kwargs) kwargs['private_data_files'] = self.build_private_data_files(instance, **kwargs)
kwargs['passwords'] = self.build_passwords(instance, **kwargs) kwargs['passwords'] = self.build_passwords(instance, **kwargs)
kwargs['proot_show_paths'] = self.proot_show_paths kwargs['proot_show_paths'] = self.proot_show_paths
if getattr(instance, 'ansible_virtualenv_path', settings.ANSIBLE_VENV_PATH) != settings.ANSIBLE_VENV_PATH:
kwargs['proot_custom_virtualenv'] = instance.ansible_virtualenv_path
args = self.build_args(instance, **kwargs) args = self.build_args(instance, **kwargs)
safe_args = self.build_safe_args(instance, **kwargs) safe_args = self.build_safe_args(instance, **kwargs)
output_replacements = self.build_output_replacements(instance, **kwargs) output_replacements = self.build_output_replacements(instance, **kwargs)
@@ -1021,7 +1029,7 @@ class RunJob(BaseTask):
plugin_dirs.extend(settings.AWX_ANSIBLE_CALLBACK_PLUGINS) plugin_dirs.extend(settings.AWX_ANSIBLE_CALLBACK_PLUGINS)
plugin_path = ':'.join(plugin_dirs) plugin_path = ':'.join(plugin_dirs)
env = super(RunJob, self).build_env(job, **kwargs) env = super(RunJob, self).build_env(job, **kwargs)
env = self.add_ansible_venv(env, add_awx_lib=kwargs.get('isolated', False)) env = self.add_ansible_venv(job.ansible_virtualenv_path, env, add_awx_lib=kwargs.get('isolated', False))
# Set environment variables needed for inventory and job event # Set environment variables needed for inventory and job event
# callbacks to work. # callbacks to work.
env['JOB_ID'] = str(job.pk) env['JOB_ID'] = str(job.pk)
@@ -1314,7 +1322,7 @@ class RunProjectUpdate(BaseTask):
Build environment dictionary for ansible-playbook. Build environment dictionary for ansible-playbook.
''' '''
env = super(RunProjectUpdate, self).build_env(project_update, **kwargs) env = super(RunProjectUpdate, self).build_env(project_update, **kwargs)
env = self.add_ansible_venv(env) env = self.add_ansible_venv(settings.ANSIBLE_VENV_PATH, env)
env['ANSIBLE_RETRY_FILES_ENABLED'] = str(False) env['ANSIBLE_RETRY_FILES_ENABLED'] = str(False)
env['ANSIBLE_ASK_PASS'] = str(False) env['ANSIBLE_ASK_PASS'] = str(False)
env['ANSIBLE_BECOME_ASK_PASS'] = str(False) env['ANSIBLE_BECOME_ASK_PASS'] = str(False)
@@ -2055,7 +2063,7 @@ class RunAdHocCommand(BaseTask):
''' '''
plugin_dir = self.get_path_to('..', 'plugins', 'callback') plugin_dir = self.get_path_to('..', 'plugins', 'callback')
env = super(RunAdHocCommand, self).build_env(ad_hoc_command, **kwargs) env = super(RunAdHocCommand, self).build_env(ad_hoc_command, **kwargs)
env = self.add_ansible_venv(env) env = self.add_ansible_venv(settings.ANSIBLE_VENV_PATH, env)
# Set environment variables needed for inventory and ad hoc event # Set environment variables needed for inventory and ad hoc event
# callbacks to work. # callbacks to work.
env['AD_HOC_COMMAND_ID'] = str(ad_hoc_command.pk) env['AD_HOC_COMMAND_ID'] = str(ad_hoc_command.pk)

View File

@@ -1,3 +1,6 @@
import os
from backports.tempfile import TemporaryDirectory
import pytest import pytest
# AWX # AWX
@@ -7,6 +10,7 @@ from awx.main.models.jobs import Job, JobTemplate
from awx.main.migrations import _save_password_keys as save_password_keys from awx.main.migrations import _save_password_keys as save_password_keys
# Django # Django
from django.conf import settings
from django.apps import apps from django.apps import apps
@@ -570,3 +574,31 @@ def test_save_survey_passwords_on_migration(job_template_with_survey_passwords):
save_password_keys.migrate_survey_passwords(apps, None) save_password_keys.migrate_survey_passwords(apps, None)
job = job_template_with_survey_passwords.jobs.all()[0] job = job_template_with_survey_passwords.jobs.all()[0]
assert job.survey_passwords == {'SSN': '$encrypted$', 'secret_key': '$encrypted$'} assert job.survey_passwords == {'SSN': '$encrypted$', 'secret_key': '$encrypted$'}
@pytest.mark.django_db
def test_job_template_custom_virtualenv(get, patch, organization_factory, job_template_factory):
objs = organization_factory("org", superusers=['admin'])
jt = job_template_factory("jt", organization=objs.organization,
inventory='test_inv', project='test_proj').job_template
with TemporaryDirectory(dir=settings.BASE_VENV_PATH) as temp_dir:
admin = objs.superusers.admin
os.makedirs(os.path.join(temp_dir, 'bin', 'activate'))
url = reverse('api:job_template_detail', kwargs={'pk': jt.id})
patch(url, {'custom_virtualenv': temp_dir}, user=admin, expect=200)
assert get(url, user=admin).data['custom_virtualenv'] == os.path.join(temp_dir, '')
@pytest.mark.django_db
def test_job_template_invalid_custom_virtualenv(get, patch, organization_factory,
job_template_factory):
objs = organization_factory("org", superusers=['admin'])
jt = job_template_factory("jt", organization=objs.organization,
inventory='test_inv', project='test_proj').job_template
url = reverse('api:job_template_detail', kwargs={'pk': jt.id})
resp = patch(url, {'custom_virtualenv': '/foo/bar'}, user=objs.superusers.admin, expect=400)
assert resp.data['custom_virtualenv'] == [
'/foo/bar is not a valid virtualenv in {}'.format(settings.BASE_VENV_PATH)
]

View File

@@ -2,15 +2,16 @@
# All Rights Reserved. # All Rights Reserved.
# Python # Python
import os
from backports.tempfile import TemporaryDirectory
from django.conf import settings
import pytest import pytest
import mock import mock
# Django
from awx.api.versioning import reverse
# AWX # AWX
from awx.main.models import * # noqa from awx.main.models import * # noqa
from awx.api.versioning import reverse
@pytest.mark.django_db @pytest.mark.django_db
@@ -188,3 +189,21 @@ def test_delete_organization_xfail1(delete, organization, alice):
@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True) @mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True)
def test_delete_organization_xfail2(delete, organization): def test_delete_organization_xfail2(delete, organization):
delete(reverse('api:organization_detail', kwargs={'pk': organization.id}), user=None, expect=401) delete(reverse('api:organization_detail', kwargs={'pk': organization.id}), user=None, expect=401)
@pytest.mark.django_db
def test_organization_custom_virtualenv(get, patch, organization, admin):
with TemporaryDirectory(dir=settings.BASE_VENV_PATH) as temp_dir:
os.makedirs(os.path.join(temp_dir, 'bin', 'activate'))
url = reverse('api:organization_detail', kwargs={'pk': organization.id})
patch(url, {'custom_virtualenv': temp_dir}, user=admin, expect=200)
assert get(url, user=admin).data['custom_virtualenv'] == os.path.join(temp_dir, '')
@pytest.mark.django_db
def test_organization_invalid_custom_virtualenv(get, patch, organization, admin):
url = reverse('api:organization_detail', kwargs={'pk': organization.id})
resp = patch(url, {'custom_virtualenv': '/foo/bar'}, user=admin, expect=400)
assert resp.data['custom_virtualenv'] == [
'/foo/bar is not a valid virtualenv in {}'.format(settings.BASE_VENV_PATH)
]

View File

@@ -1,5 +1,11 @@
import os
from backports.tempfile import TemporaryDirectory
from django.conf import settings
import pytest import pytest
from awx.api.versioning import reverse
@pytest.mark.django_db @pytest.mark.django_db
class TestInsightsCredential: class TestInsightsCredential:
@@ -13,3 +19,20 @@ class TestInsightsCredential:
{'credential': scm_credential.id}, admin_user, {'credential': scm_credential.id}, admin_user,
expect=400) expect=400)
@pytest.mark.django_db
def test_project_custom_virtualenv(get, patch, project, admin):
with TemporaryDirectory(dir=settings.BASE_VENV_PATH) as temp_dir:
os.makedirs(os.path.join(temp_dir, 'bin', 'activate'))
url = reverse('api:project_detail', kwargs={'pk': project.id})
patch(url, {'custom_virtualenv': temp_dir}, user=admin, expect=200)
assert get(url, user=admin).data['custom_virtualenv'] == os.path.join(temp_dir, '')
@pytest.mark.django_db
def test_project_invalid_custom_virtualenv(get, patch, project, admin):
url = reverse('api:project_detail', kwargs={'pk': project.id})
resp = patch(url, {'custom_virtualenv': '/foo/bar'}, user=admin, expect=400)
assert resp.data['custom_virtualenv'] == [
'/foo/bar is not a valid virtualenv in {}'.format(settings.BASE_VENV_PATH)
]

View File

@@ -0,0 +1,40 @@
import pytest
from awx.main.models import JobTemplate
@pytest.mark.django_db
def test_awx_virtualenv_from_settings(inventory, project, machine_credential):
jt = JobTemplate.objects.create(
name='my-jt',
inventory=inventory,
project=project,
playbook='helloworld.yml'
)
jt.credentials.add(machine_credential)
job = jt.create_unified_job()
assert job.ansible_virtualenv_path == '/venv/ansible'
@pytest.mark.django_db
def test_awx_custom_virtualenv(inventory, project, machine_credential):
jt = JobTemplate.objects.create(
name='my-jt',
inventory=inventory,
project=project,
playbook='helloworld.yml'
)
jt.credentials.add(machine_credential)
job = jt.create_unified_job()
job.project.organization.custom_virtualenv = '/venv/fancy-org'
job.project.organization.save()
assert job.ansible_virtualenv_path == '/venv/fancy-org'
job.project.custom_virtualenv = '/venv/fancy-proj'
job.project.save()
assert job.ansible_virtualenv_path == '/venv/fancy-proj'
job.job_template.custom_virtualenv = '/venv/fancy-jt'
job.job_template.save()
assert job.ansible_virtualenv_path == '/venv/fancy-jt'

View File

@@ -8,6 +8,7 @@ import re
import shutil import shutil
import tempfile import tempfile
from backports.tempfile import TemporaryDirectory
import fcntl import fcntl
import mock import mock
import pytest import pytest
@@ -204,6 +205,7 @@ class TestJobExecution:
mock.patch.object(Project, 'get_project_path', lambda *a, **kw: self.project_path), mock.patch.object(Project, 'get_project_path', lambda *a, **kw: self.project_path),
# don't emit websocket statuses; they use the DB and complicate testing # don't emit websocket statuses; they use the DB and complicate testing
mock.patch.object(UnifiedJob, 'websocket_emit_status', mock.Mock()), mock.patch.object(UnifiedJob, 'websocket_emit_status', mock.Mock()),
mock.patch.object(Job, 'ansible_virtualenv_path', settings.ANSIBLE_VENV_PATH),
mock.patch('awx.main.expect.run.run_pexpect', self.run_pexpect), mock.patch('awx.main.expect.run.run_pexpect', self.run_pexpect),
] ]
for cls in (Job, AdHocCommand): for cls in (Job, AdHocCommand):
@@ -348,6 +350,35 @@ class TestGenericRun(TestJobExecution):
args, cwd, env, stdout = call_args args, cwd, env, stdout = call_args
assert env['FOO'] == 'BAR' assert env['FOO'] == 'BAR'
def test_valid_custom_virtualenv(self):
with TemporaryDirectory(dir=settings.BASE_VENV_PATH) as tempdir:
os.makedirs(os.path.join(tempdir, 'lib'))
os.makedirs(os.path.join(tempdir, 'bin', 'activate'))
venv_patch = mock.patch.object(Job, 'ansible_virtualenv_path', tempdir)
self.patches.append(venv_patch)
venv_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['PATH'].startswith(os.path.join(tempdir, 'bin'))
assert env['VIRTUAL_ENV'] == tempdir
for path in (settings.ANSIBLE_VENV_PATH, tempdir):
assert '--ro-bind {} {}'.format(path, path) in ' '.join(args)
def test_invalid_custom_virtualenv(self):
venv_patch = mock.patch.object(Job, 'ansible_virtualenv_path', '/venv/missing')
self.patches.append(venv_patch)
venv_patch.start()
with pytest.raises(Exception):
self.task.run(self.pk)
tb = self.task.update_model.call_args[-1]['result_traceback']
assert 'a valid Python virtualenv does not exist at /venv/missing' in tb
class TestAdhocRun(TestJobExecution): class TestAdhocRun(TestJobExecution):

View File

@@ -8,6 +8,8 @@ from uuid import uuid4
import json import json
import yaml import yaml
from backports.tempfile import TemporaryDirectory
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
@@ -164,3 +166,11 @@ def test_extract_ansible_vars():
redacted, var_list = common.extract_ansible_vars(json.dumps(my_dict)) redacted, var_list = common.extract_ansible_vars(json.dumps(my_dict))
assert var_list == set(['ansible_connetion_setting']) assert var_list == set(['ansible_connetion_setting'])
assert redacted == {"foobar": "baz"} assert redacted == {"foobar": "baz"}
def test_get_custom_venv_choices():
assert common.get_custom_venv_choices() == []
with TemporaryDirectory(dir=settings.BASE_VENV_PATH) as temp_dir:
os.makedirs(os.path.join(temp_dir, 'bin', 'activate'))
assert common.get_custom_venv_choices() == [os.path.join(temp_dir, '')]

View File

@@ -47,7 +47,7 @@ __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore',
'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', 'extract_ansible_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', 'set_environ', 'IllegalArgumentError',] 'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices']
def get_object_or_400(klass, *args, **kwargs): def get_object_or_400(klass, *args, **kwargs):
@@ -756,9 +756,11 @@ def wrap_args_with_proot(args, cwd, **kwargs):
show_paths = [cwd] show_paths = [cwd]
for venv in ( for venv in (
settings.ANSIBLE_VENV_PATH, settings.ANSIBLE_VENV_PATH,
settings.AWX_VENV_PATH settings.AWX_VENV_PATH,
kwargs.get('proot_custom_virtualenv')
): ):
new_args.extend(['--ro-bind', venv, venv]) if venv:
new_args.extend(['--ro-bind', venv, venv])
show_paths.extend(getattr(settings, 'AWX_PROOT_SHOW_PATHS', None) or []) show_paths.extend(getattr(settings, 'AWX_PROOT_SHOW_PATHS', None) or [])
show_paths.extend(kwargs.get('proot_show_paths', [])) show_paths.extend(kwargs.get('proot_show_paths', []))
for path in sorted(set(show_paths)): for path in sorted(set(show_paths)):
@@ -838,6 +840,21 @@ def get_current_apps():
return current_apps return current_apps
def get_custom_venv_choices():
from django.conf import settings
custom_venv_path = settings.BASE_VENV_PATH
if os.path.exists(custom_venv_path):
return [
os.path.join(custom_venv_path, x.decode('utf-8'), '')
for x in os.listdir(custom_venv_path)
if x not in ('awx', 'ansible') and
os.path.isdir(os.path.join(custom_venv_path, x)) and
os.path.exists(os.path.join(custom_venv_path, x, 'bin', 'activate'))
]
else:
return []
class OutputEventFilter(object): class OutputEventFilter(object):
''' '''
File-like object that looks for encoded job events in stdout data. File-like object that looks for encoded job events in stdout data.

View File

@@ -4,6 +4,7 @@
# Development settings for AWX project. # Development settings for AWX project.
# Python # Python
import os
import socket import socket
import copy import copy
import sys import sys
@@ -123,8 +124,9 @@ for setting in dir(this_module):
include(optional('/etc/tower/settings.py'), scope=locals()) include(optional('/etc/tower/settings.py'), scope=locals())
include(optional('/etc/tower/conf.d/*.py'), scope=locals()) include(optional('/etc/tower/conf.d/*.py'), scope=locals())
ANSIBLE_VENV_PATH = "/venv/ansible" BASE_VENV_PATH = "/venv/"
AWX_VENV_PATH = "/venv/awx" ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible")
AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
# If any local_*.py files are present in awx/settings/, use them to override # If any local_*.py files are present in awx/settings/, use them to override
# default settings for development. If not present, we can still run using # default settings for development. If not present, we can still run using

View File

@@ -4,6 +4,7 @@
# Production settings for AWX project. # Production settings for AWX project.
# Python # Python
import os
import copy import copy
import errno import errno
import sys import sys
@@ -43,10 +44,11 @@ JOBOUTPUT_ROOT = '/var/lib/awx/job_status/'
SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle' SCHEDULE_METADATA_LOCATION = '/var/lib/awx/.tower_cycle'
# Ansible base virtualenv paths and enablement # Ansible base virtualenv paths and enablement
ANSIBLE_VENV_PATH = "/var/lib/awx/venv/ansible" BASE_VENV_PATH = "/var/lib/awx/venv"
ANSIBLE_VENV_PATH = os.path.join(BASE_VENV_PATH, "ansible")
# Tower base virtualenv paths and enablement # Tower base virtualenv paths and enablement
AWX_VENV_PATH = "/var/lib/awx/venv/awx" AWX_VENV_PATH = os.path.join(BASE_VENV_PATH, "awx")
AWX_ISOLATED_USERNAME = 'awx' AWX_ISOLATED_USERNAME = 'awx'

View File

@@ -0,0 +1,71 @@
Managing Custom Python Dependencies
===================================
awx installations pre-build a special [Python
virtualenv](https://pypi.python.org/pypi/virtualenv) which is automatically
activated for all `ansible-playbook` runs invoked by awx (for example, any time
a Job Template is launched). By default, this virtualenv is located at
`/var/lib/awx/venv/ansible` on the file system.
awx pre-installs a variety of third-party library/SDK support into this
virtualenv for its integration points with a variety of cloud providers (such
as EC2, OpenStack, Azure, etc...)
Periodically, awx users want to add additional SDK support into this
virtualenv; this documentation describes the supported way to do so.
Preparing a New Custom Virtualenv
=================================
awx allows a _different_ virtualenv to be specified and used on Job Template
runs. To choose a custom virtualenv, first create one in `/var/lib/awx/venv`:
$ sudo virtualenv /var/lib/awx/venv/my-custom-venv
Your newly created virtualenv needs a few base dependencies to properly run
playbooks (awx uses memcached as a holding space for playbook artifacts and
fact gathering):
$ sudo /var/lib/awx/venv/my-custom-venv/bin/pip install python-memcached psutil
From here, you can install _additional_ Python dependencies that you care
about, such as a per-virtualenv version of ansible itself:
$ sudo /var/lib/awx/venv/my-custom-venv/bin/pip install -U "ansible == X.Y.Z"
...or an additional third-party SDK that's not included with the base awx installation:
$ sudo /var/lib/awx/venv/my-custom-venv/bin/pip install -U python-digitalocean
If you want to copy them, the libraries included in awx's default virtualenv
can be found using `pip freeze`:
$ sudo /var/lib/awx/venv/ansible/bin/pip freeze
One important item to keep in mind is that in a clustered awx installation,
you need to ensure that the same custom virtualenv exists on _every_ local file
system at `/var/lib/awx/venv/`. For container-based deployments, this likely
means building these steps into your own custom image building workflow.
Assigning Custom Virtualenvs
============================
Once you've created a custom virtualenv, you can assign it at the Organization,
Project, or Job Template level:
PATCH https://awx-host.example.org/api/v2/organizations/N/
PATCH https://awx-host.example.org/api/v2/projects/N/
PATCH https://awx-host.example.org/api/v2/job_templates/N/
Content-Type: application/json
{
'custom_virtualenv': '/var/lib/awx/venv/my-custom-venv'
}
An HTTP `GET` request to `/api/v2/config/` will provide a list of
detected installed virtualenvs:
{
"custom_virtualenvs": [
"/var/lib/awx/venv/my-custom-venv",
"/var/lib/awx/venv/my-other-custom-venv",
],
...
}

View File

@@ -15,3 +15,4 @@ flower
uwsgitop uwsgitop
jupyter jupyter
matplotlib matplotlib
backports.tempfile # support in unit tests for py32+ tempfile.TemporaryDirectory