diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 4227163166..ebecaa26e1 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -77,6 +77,7 @@ SUMMARIZABLE_FK_FIELDS = { 'project': DEFAULT_SUMMARY_FIELDS + ('status',), 'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'), 'cloud_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'), + 'network_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'net'), 'permission': DEFAULT_SUMMARY_FIELDS, 'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',), 'job_template': DEFAULT_SUMMARY_FIELDS, @@ -1551,7 +1552,7 @@ class JobOptionsSerializer(BaseSerializer): class Meta: fields = ('*', 'job_type', 'inventory', 'project', 'playbook', - 'credential', 'cloud_credential', 'forks', 'limit', + 'credential', 'cloud_credential', 'network_credential', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', 'skip_tags', 'start_at_task',) @@ -1567,6 +1568,9 @@ class JobOptionsSerializer(BaseSerializer): if obj.cloud_credential: res['cloud_credential'] = reverse('api:credential_detail', args=(obj.cloud_credential.pk,)) + if obj.network_credential: + res['network_credential'] = reverse('api:credential_detail', + args=(obj.network_credential.pk,)) return res def _summary_field_labels(self, obj): @@ -1591,6 +1595,8 @@ class JobOptionsSerializer(BaseSerializer): ret['credential'] = None if 'cloud_credential' in ret and not obj.cloud_credential: ret['cloud_credential'] = None + if 'network_credential' in ret and not obj.network_credential: + ret['network_credential'] = None return ret def validate(self, attrs): @@ -1718,6 +1724,8 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): data.setdefault('credential', job_template.credential.pk) if job_template.cloud_credential: data.setdefault('cloud_credential', job_template.cloud_credential.pk) + if job_template.network_credential: + data.setdefault('network_credential', job_template.network_credential.pk) data.setdefault('forks', job_template.forks) data.setdefault('limit', job_template.limit) data.setdefault('verbosity', job_template.verbosity) diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 9cfcc50d54..d247154fd7 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -33,6 +33,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): KIND_CHOICES = [ ('ssh', _('Machine')), + ('net', _('Network')), ('scm', _('Source Control')), ('aws', _('Amazon Web Services')), ('rax', _('Rackspace')), diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index b58995edbc..0dda818d11 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -85,6 +85,14 @@ class JobOptions(BaseModel): default=None, on_delete=models.SET_NULL, ) + network_credential = models.ForeignKey( + 'Credential', + related_name='%(class)ss_as_network_credential+', + blank=True, + null=True, + default=None, + on_delete=models.SET_NULL, + ), forks = models.PositiveIntegerField( blank=True, default=0, @@ -141,6 +149,14 @@ class JobOptions(BaseModel): ) return cred + def clean_network_credential(self): + cred = self.network_credential + if cred and cred.kind != 'net': + raise ValidationError( + 'You must provide a network credential.', + ) + return cred + def clean_cloud_credential(self): cred = self.cloud_credential if cred and cred.kind not in CLOUD_PROVIDERS + ('aws',): @@ -212,7 +228,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin): @classmethod def _get_unified_job_field_names(cls): return ['name', 'description', 'job_type', 'inventory', 'project', - 'playbook', 'credential', 'cloud_credential', 'forks', 'schedule', + 'playbook', 'credential', 'cloud_credential', 'network_credential', 'forks', 'schedule', 'limit', 'verbosity', 'job_tags', 'extra_vars', 'launch_type', 'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled', 'labels',] diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 24ed713e63..abacb7be53 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -372,7 +372,7 @@ class BaseTask(Task): ''' Create a temporary files containing the private data. Returns a dictionary with keys from build_private_data - (i.e. 'credential', 'cloud_credential') and values the file path. + (i.e. 'credential', 'cloud_credential', 'network_credential') and values the file path. ''' private_data = self.build_private_data(instance, **kwargs) private_data_files = {} @@ -392,10 +392,9 @@ class BaseTask(Task): data += '\n' # For credentials used with ssh-add, write to a named pipe which # will be read then closed, instead of leaving the SSH key on disk. - if name in ('credential', 'scm_credential', 'ad_hoc_credential') and not ssh_too_old: + if name in ('credential', 'network_credential', 'scm_credential', 'ad_hoc_credential') and not ssh_too_old: path = os.path.join(kwargs.get('private_data_dir', tempfile.gettempdir()), name) - os.mkfifo(path, 0600) - thread.start_new_thread(lambda p, d: open(p, 'w').write(d), (path, data)) + self.open_fifo_write(path) else: handle, path = tempfile.mkstemp(dir=kwargs.get('private_data_dir', None)) f = os.fdopen(handle, 'w') @@ -405,6 +404,14 @@ class BaseTask(Task): private_data_files[name] = path return private_data_files + def open_fifo_write(self, path): + '''open_fifo_write opens the fifo named pipe in a new thread. + This blocks until the the calls to ssh-agent/ssh-add have read the + credential information from the pipe. + ''' + os.mkfifo(path, 0600) + thread.start_new_thread(lambda p, d: open(p, 'w').write(d), (path, data)) + def build_passwords(self, instance, **kwargs): ''' Build a dictionary of passwords for responding to prompts. @@ -435,7 +442,7 @@ class BaseTask(Task): env['VIRTUAL_ENV'] = settings.ANSIBLE_VENV_PATH env['PATH'] = os.path.join(settings.ANSIBLE_VENV_PATH, "bin") + ":" + env['PATH'] env['PYTHONPATH'] = os.path.join(settings.ANSIBLE_VENV_PATH, "lib/python2.7/site-packages/") + ":" - if self.should_use_proot: + if self.should_use_proot(instance, **kwargs): env['PROOT_TMP_DIR'] = tower_settings.AWX_PROOT_BASE_PATH return env @@ -694,12 +701,12 @@ class RunJob(BaseTask): Returns a dict of the form dict['credential'] = dict['cloud_credential'] = + dict['network_credential'] = ''' - job_credentials = ['credential', 'cloud_credential'] + job_credentials = ['credential', 'cloud_credential', 'network_credential'] private_data = {} # If we were sent SSH credentials, decrypt them and send them # back (they will be written to a temporary file). - for cred_name in job_credentials: credential = getattr(job, cred_name, None) if credential: @@ -801,6 +808,16 @@ class RunJob(BaseTask): elif cloud_cred and cloud_cred.kind == 'openstack': env['OS_CLIENT_CONFIG_FILE'] = kwargs.get('private_data_files', {}).get('cloud_credential', '') + network_cred = job.network_credential + if network_cred: + env['ANSIBLE_NET_USERNAME'] = network_cred.username + env['ANSIBLE_NET_PASSWORD'] = decrypt_field(network_cred, 'password') + + authorize = network_cred.become_method == 'sudo' + env['ANSIBLE_NET_AUTHORIZE'] = unicode(int(authorize)) + if authorize: + env['ANSIBLE_NET_AUTHORIZE_PASSWORD'] = decrypt_field(network_cred, 'become_password') + # Set environment variables related to scan jobs if job.job_type == PERM_INVENTORY_SCAN: env['ANSIBLE_LIBRARY'] = self.get_path_to('..', 'plugins', 'library') @@ -938,7 +955,12 @@ class RunJob(BaseTask): ''' If using an SSH key, return the path for use by ssh-agent. ''' - return kwargs.get('private_data_files', {}).get('credential', '') + private_data_files = kwargs.get('private_data_files', {}) + if 'credential' in private_data_files: + return private_data_files.get('credential') + elif 'network_credential' in private_data_files: + return private_data_files.get('network_credential') + return '' def should_use_proot(self, instance, **kwargs): ''' diff --git a/awx/main/tests/unit/test_network_credential.py b/awx/main/tests/unit/test_network_credential.py new file mode 100644 index 0000000000..00621fb0a3 --- /dev/null +++ b/awx/main/tests/unit/test_network_credential.py @@ -0,0 +1,61 @@ +import pytest + +from awx.main.models.credential import Credential +from awx.main.models.jobs import Job +from awx.main.models.inventory import Inventory +from awx.main.tasks import RunJob + + +@pytest.fixture +def options(): + return { + 'username':'test', + 'password':'test', + 'ssh_key_data': """-----BEGIN PRIVATE KEY-----\nstuff==\n-----END PRIVATE KEY-----""", + 'become_method': 'sudo', + 'become_password': 'passwd', + } + + +def test_net_cred_parse(mocker, options): + with mocker.patch('django.db.ConnectionRouter.db_for_write'): + job = Job(id=1) + job.inventory = mocker.MagicMock(spec=Inventory, id=2) + job.network_credential = Credential(**options) + + run_job = RunJob() + mocker.patch.object(run_job, 'should_use_proot', return_value=False) + + env = run_job.build_env(job, private_data_dir='/tmp') + assert env['ANSIBLE_NET_USERNAME'] == options['username'] + assert env['ANSIBLE_NET_PASSWORD'] == options['password'] + assert env['ANSIBLE_NET_AUTHORIZE'] == '1' + assert env['ANSIBLE_NET_AUTHORIZE_PASSWORD'] == options['become_password'] + + +def test_net_cred_ssh_agent(mocker, options): + with mocker.patch('django.db.ConnectionRouter.db_for_write'): + run_job = RunJob() + + mock_job_attrs = {'forks': False, 'id': 1, 'cancel_flag': False, 'status': 'running', 'job_type': 'normal', + 'credential': None, 'cloud_credential': None, 'network_credential': Credential(**options), + 'become_enabled': False, 'become_method': None, 'become_username': None, + 'inventory': mocker.MagicMock(spec=Inventory, id=2), 'force_handlers': False, + 'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': False, + 'start_at_task': False, 'pk': 1, 'launch_type': 'normal', 'job_template':None, + 'created_by': None, 'extra_vars_dict': None, 'project':None, 'playbook': 'test.yml'} + mock_job = mocker.MagicMock(spec=Job, **mock_job_attrs) + + mocker.patch.object(run_job, 'update_model', return_value=mock_job) + mocker.patch.object(run_job, 'build_cwd', return_value='/tmp') + mocker.patch.object(run_job, 'should_use_proot', return_value=False) + mocker.patch.object(run_job, 'run_pexpect', return_value=('successful', 0)) + mocker.patch.object(run_job, 'open_fifo_write', return_value=None) + + run_job.run(mock_job.id) + assert run_job.update_model.call_count == 3 + + job_args = run_job.update_model.call_args_list[1][1].get('job_args') + assert 'ssh-add' in job_args + assert 'ssh-agent' in job_args + assert 'network_credential' in job_args