mirror of
https://github.com/ansible/awx.git
synced 2026-05-19 14:57:39 -02:30
Merge pull request #1430 from wwitzel3/networking-cred
Network Credential
This commit is contained in:
@@ -77,6 +77,7 @@ SUMMARIZABLE_FK_FIELDS = {
|
|||||||
'project': DEFAULT_SUMMARY_FIELDS + ('status',),
|
'project': DEFAULT_SUMMARY_FIELDS + ('status',),
|
||||||
'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'),
|
'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'),
|
||||||
'cloud_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'),
|
'cloud_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'),
|
||||||
|
'network_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'net'),
|
||||||
'permission': DEFAULT_SUMMARY_FIELDS,
|
'permission': DEFAULT_SUMMARY_FIELDS,
|
||||||
'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',),
|
'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',),
|
||||||
'job_template': DEFAULT_SUMMARY_FIELDS,
|
'job_template': DEFAULT_SUMMARY_FIELDS,
|
||||||
@@ -1551,7 +1552,7 @@ class JobOptionsSerializer(BaseSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
fields = ('*', 'job_type', 'inventory', 'project', 'playbook',
|
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',
|
'verbosity', 'extra_vars', 'job_tags', 'force_handlers',
|
||||||
'skip_tags', 'start_at_task',)
|
'skip_tags', 'start_at_task',)
|
||||||
|
|
||||||
@@ -1567,6 +1568,9 @@ class JobOptionsSerializer(BaseSerializer):
|
|||||||
if obj.cloud_credential:
|
if obj.cloud_credential:
|
||||||
res['cloud_credential'] = reverse('api:credential_detail',
|
res['cloud_credential'] = reverse('api:credential_detail',
|
||||||
args=(obj.cloud_credential.pk,))
|
args=(obj.cloud_credential.pk,))
|
||||||
|
if obj.network_credential:
|
||||||
|
res['network_credential'] = reverse('api:credential_detail',
|
||||||
|
args=(obj.network_credential.pk,))
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def _summary_field_labels(self, obj):
|
def _summary_field_labels(self, obj):
|
||||||
@@ -1591,6 +1595,8 @@ class JobOptionsSerializer(BaseSerializer):
|
|||||||
ret['credential'] = None
|
ret['credential'] = None
|
||||||
if 'cloud_credential' in ret and not obj.cloud_credential:
|
if 'cloud_credential' in ret and not obj.cloud_credential:
|
||||||
ret['cloud_credential'] = None
|
ret['cloud_credential'] = None
|
||||||
|
if 'network_credential' in ret and not obj.network_credential:
|
||||||
|
ret['network_credential'] = None
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
@@ -1718,6 +1724,8 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
|
|||||||
data.setdefault('credential', job_template.credential.pk)
|
data.setdefault('credential', job_template.credential.pk)
|
||||||
if job_template.cloud_credential:
|
if job_template.cloud_credential:
|
||||||
data.setdefault('cloud_credential', job_template.cloud_credential.pk)
|
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('forks', job_template.forks)
|
||||||
data.setdefault('limit', job_template.limit)
|
data.setdefault('limit', job_template.limit)
|
||||||
data.setdefault('verbosity', job_template.verbosity)
|
data.setdefault('verbosity', job_template.verbosity)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
|
|||||||
|
|
||||||
KIND_CHOICES = [
|
KIND_CHOICES = [
|
||||||
('ssh', _('Machine')),
|
('ssh', _('Machine')),
|
||||||
|
('net', _('Network')),
|
||||||
('scm', _('Source Control')),
|
('scm', _('Source Control')),
|
||||||
('aws', _('Amazon Web Services')),
|
('aws', _('Amazon Web Services')),
|
||||||
('rax', _('Rackspace')),
|
('rax', _('Rackspace')),
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ class JobOptions(BaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
on_delete=models.SET_NULL,
|
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(
|
forks = models.PositiveIntegerField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=0,
|
default=0,
|
||||||
@@ -141,6 +149,14 @@ class JobOptions(BaseModel):
|
|||||||
)
|
)
|
||||||
return cred
|
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):
|
def clean_cloud_credential(self):
|
||||||
cred = self.cloud_credential
|
cred = self.cloud_credential
|
||||||
if cred and cred.kind not in CLOUD_PROVIDERS + ('aws',):
|
if cred and cred.kind not in CLOUD_PROVIDERS + ('aws',):
|
||||||
@@ -212,7 +228,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _get_unified_job_field_names(cls):
|
def _get_unified_job_field_names(cls):
|
||||||
return ['name', 'description', 'job_type', 'inventory', 'project',
|
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',
|
'limit', 'verbosity', 'job_tags', 'extra_vars', 'launch_type',
|
||||||
'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled',
|
'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled',
|
||||||
'labels',]
|
'labels',]
|
||||||
|
|||||||
@@ -372,7 +372,7 @@ class BaseTask(Task):
|
|||||||
'''
|
'''
|
||||||
Create a temporary files containing the private data.
|
Create a temporary files containing the private data.
|
||||||
Returns a dictionary with keys from build_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 = self.build_private_data(instance, **kwargs)
|
||||||
private_data_files = {}
|
private_data_files = {}
|
||||||
@@ -392,10 +392,9 @@ class BaseTask(Task):
|
|||||||
data += '\n'
|
data += '\n'
|
||||||
# For credentials used with ssh-add, write to a named pipe which
|
# 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.
|
# 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)
|
path = os.path.join(kwargs.get('private_data_dir', tempfile.gettempdir()), name)
|
||||||
os.mkfifo(path, 0600)
|
self.open_fifo_write(path)
|
||||||
thread.start_new_thread(lambda p, d: open(p, 'w').write(d), (path, data))
|
|
||||||
else:
|
else:
|
||||||
handle, path = tempfile.mkstemp(dir=kwargs.get('private_data_dir', None))
|
handle, path = tempfile.mkstemp(dir=kwargs.get('private_data_dir', None))
|
||||||
f = os.fdopen(handle, 'w')
|
f = os.fdopen(handle, 'w')
|
||||||
@@ -405,6 +404,14 @@ class BaseTask(Task):
|
|||||||
private_data_files[name] = path
|
private_data_files[name] = path
|
||||||
return private_data_files
|
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):
|
def build_passwords(self, instance, **kwargs):
|
||||||
'''
|
'''
|
||||||
Build a dictionary of passwords for responding to prompts.
|
Build a dictionary of passwords for responding to prompts.
|
||||||
@@ -435,7 +442,7 @@ class BaseTask(Task):
|
|||||||
env['VIRTUAL_ENV'] = settings.ANSIBLE_VENV_PATH
|
env['VIRTUAL_ENV'] = settings.ANSIBLE_VENV_PATH
|
||||||
env['PATH'] = os.path.join(settings.ANSIBLE_VENV_PATH, "bin") + ":" + env['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/") + ":"
|
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
|
env['PROOT_TMP_DIR'] = tower_settings.AWX_PROOT_BASE_PATH
|
||||||
return env
|
return env
|
||||||
|
|
||||||
@@ -694,12 +701,12 @@ class RunJob(BaseTask):
|
|||||||
Returns a dict of the form
|
Returns a dict of the form
|
||||||
dict['credential'] = <credential_decrypted_ssh_key_data>
|
dict['credential'] = <credential_decrypted_ssh_key_data>
|
||||||
dict['cloud_credential'] = <cloud_credential_decrypted_ssh_key_data>
|
dict['cloud_credential'] = <cloud_credential_decrypted_ssh_key_data>
|
||||||
|
dict['network_credential'] = <network_credential_decrypted_ssh_key_data>
|
||||||
'''
|
'''
|
||||||
job_credentials = ['credential', 'cloud_credential']
|
job_credentials = ['credential', 'cloud_credential', 'network_credential']
|
||||||
private_data = {}
|
private_data = {}
|
||||||
# If we were sent SSH credentials, decrypt them and send them
|
# If we were sent SSH credentials, decrypt them and send them
|
||||||
# back (they will be written to a temporary file).
|
# back (they will be written to a temporary file).
|
||||||
|
|
||||||
for cred_name in job_credentials:
|
for cred_name in job_credentials:
|
||||||
credential = getattr(job, cred_name, None)
|
credential = getattr(job, cred_name, None)
|
||||||
if credential:
|
if credential:
|
||||||
@@ -801,6 +808,16 @@ class RunJob(BaseTask):
|
|||||||
elif cloud_cred and cloud_cred.kind == 'openstack':
|
elif cloud_cred and cloud_cred.kind == 'openstack':
|
||||||
env['OS_CLIENT_CONFIG_FILE'] = kwargs.get('private_data_files', {}).get('cloud_credential', '')
|
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
|
# Set environment variables related to scan jobs
|
||||||
if job.job_type == PERM_INVENTORY_SCAN:
|
if job.job_type == PERM_INVENTORY_SCAN:
|
||||||
env['ANSIBLE_LIBRARY'] = self.get_path_to('..', 'plugins', 'library')
|
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.
|
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):
|
def should_use_proot(self, instance, **kwargs):
|
||||||
'''
|
'''
|
||||||
|
|||||||
61
awx/main/tests/unit/test_network_credential.py
Normal file
61
awx/main/tests/unit/test_network_credential.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user