From accf7cdea2c95c92fb2d70fc1a4f9fdb17d19f31 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 27 Apr 2017 11:26:35 -0400 Subject: [PATCH] Replace Job/JT cloud/network credentials with a single M2M relation. The following fields: * (Job | JobTemplate).cloud_credential * (Job | JobTemplate).network_credential ...are replaced by M2M relationships: * Job.extra_credentials * JobTemplate.extra_credentials Includes support for task execution with multiple cloud credentials. see: #5807 --- awx/api/serializers.py | 19 +- awx/api/views.py | 3 +- awx/main/access.py | 26 +- ...0043_v320_job_template_multi_credential.py | 43 +++ awx/main/migrations/_credentialtypes.py | 18 ++ awx/main/models/jobs.py | 70 ++--- awx/main/tasks.py | 270 ++++++++++-------- .../functional/api/test_rbac_displays.py | 2 - .../functional/models/test_job_options.py | 60 ++++ awx/main/tests/unit/test_access.py | 1 + .../tests/unit/test_network_credential.py | 133 --------- awx/main/tests/unit/test_tasks.py | 213 +++++++++----- 12 files changed, 463 insertions(+), 395 deletions(-) create mode 100644 awx/main/migrations/0043_v320_job_template_multi_credential.py create mode 100644 awx/main/tests/functional/models/test_job_options.py delete mode 100644 awx/main/tests/unit/test_network_credential.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e277fe314e..612efb075b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -86,8 +86,6 @@ SUMMARIZABLE_FK_FIELDS = { 'scm_project': DEFAULT_SUMMARY_FIELDS + ('status', 'scm_type'), 'project_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed',), 'credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'), - 'cloud_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'cloud'), - 'network_credential': DEFAULT_SUMMARY_FIELDS + ('kind', 'net'), 'job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'elapsed'), 'job_template': DEFAULT_SUMMARY_FIELDS, 'workflow_job_template': DEFAULT_SUMMARY_FIELDS, @@ -2096,7 +2094,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): class Meta: fields = ('*', 'job_type', 'inventory', 'project', 'playbook', - 'credential', 'cloud_credential', 'network_credential', 'forks', 'limit', + 'credential', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', 'skip_tags', 'start_at_task', 'timeout', 'store_facts',) @@ -2109,12 +2107,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk}) if obj.credential: res['credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.credential.pk}) - if obj.cloud_credential: - res['cloud_credential'] = self.reverse('api:credential_detail', - kwargs={'pk': obj.cloud_credential.pk}) - if obj.network_credential: - res['network_credential'] = self.reverse('api:credential_detail', - kwargs={'pk': obj.network_credential.pk}) + # TODO: add related links for `extra_credentials` return res def to_representation(self, obj): @@ -2129,10 +2122,6 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): ret['playbook'] = '' if 'credential' in ret and not obj.credential: 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): @@ -2296,10 +2285,6 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): data.setdefault('playbook', job_template.playbook) if job_template.credential: 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/api/views.py b/awx/api/views.py index 48f2e43632..89b983426e 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2432,7 +2432,7 @@ class JobTemplateList(ListCreateAPIView): always_allow_superuser = False capabilities_prefetch = [ 'admin', 'execute', - {'copy': ['project.use', 'inventory.use', 'credential.use', 'cloud_credential.use', 'network_credential.use']} + {'copy': ['project.use', 'inventory.use', 'credential.use']} ] def post(self, request, *args, **kwargs): @@ -3906,7 +3906,6 @@ class UnifiedJobTemplateList(ListAPIView): capabilities_prefetch = [ 'admin', 'execute', {'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use', 'jobtemplate.credential.use', - 'jobtemplate.cloud_credential.use', 'jobtemplate.network_credential.use', 'workflowjobtemplate.organization.admin']} ] diff --git a/awx/main/access.py b/awx/main/access.py index 015c7d3dd8..0fe1518c86 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1072,7 +1072,7 @@ class JobTemplateAccess(BaseAccess): else: qs = self.model.accessible_objects(self.user, 'read_role') return qs.select_related('created_by', 'modified_by', 'inventory', 'project', - 'credential', 'cloud_credential', 'next_schedule').all() + 'credential', 'next_schedule').all() def can_add(self, data): ''' @@ -1113,13 +1113,8 @@ class JobTemplateAccess(BaseAccess): if not self.check_related('credential', Credential, data, role_field='use_role'): return False - # If a cloud credential is provided, the user should have use access. - if not self.check_related('cloud_credential', Credential, data, role_field='use_role'): - return False - - # If a network credential is provided, the user should have use access. - if not self.check_related('network_credential', Credential, data, role_field='use_role'): - return False + # TODO: If a vault credential is provided, the user should have use access to it. + # TODO: If any credential in extra_credentials, the user must have access # If an inventory is provided, the user should have use access. inventory = get_value(Inventory, 'inventory') @@ -1185,7 +1180,8 @@ class JobTemplateAccess(BaseAccess): self.check_license(feature='surveys') return True - for required_field in ('credential', 'cloud_credential', 'network_credential', 'inventory', 'project'): + # TODO: handle vault_credential and extra_credentials + for required_field in ('credential', 'inventory', 'project'): required_obj = getattr(obj, required_field, None) if required_field not in data_for_change and required_obj is not None: data_for_change[required_field] = required_obj.pk @@ -1219,8 +1215,6 @@ class JobTemplateAccess(BaseAccess): project_id = data.get('project', obj.project.id if obj.project else None) inventory_id = data.get('inventory', obj.inventory.id if obj.inventory else None) credential_id = data.get('credential', obj.credential.id if obj.credential else None) - cloud_credential_id = data.get('cloud_credential', obj.cloud_credential.id if obj.cloud_credential else None) - network_credential_id = data.get('network_credential', obj.network_credential.id if obj.network_credential else None) if project_id and self.user not in Project.objects.get(pk=project_id).use_role: return False @@ -1228,10 +1222,7 @@ class JobTemplateAccess(BaseAccess): return False if credential_id and self.user not in Credential.objects.get(pk=credential_id).use_role: return False - if cloud_credential_id and self.user not in Credential.objects.get(pk=cloud_credential_id).use_role: - return False - if network_credential_id and self.user not in Credential.objects.get(pk=network_credential_id).use_role: - return False + # TODO: handle vault_credential and extra_credentials return True @@ -1271,7 +1262,7 @@ class JobAccess(BaseAccess): def get_queryset(self): qs = self.model.objects qs = qs.select_related('created_by', 'modified_by', 'job_template', 'inventory', - 'project', 'credential', 'cloud_credential', 'job_template') + 'project', 'credential', 'job_template') qs = qs.prefetch_related('unified_job_template') if self.user.is_superuser or self.user.is_system_auditor: return qs.all() @@ -1907,7 +1898,6 @@ class UnifiedJobTemplateAccess(BaseAccess): # 'project', # 'inventory', # 'credential', - # 'cloud_credential', #) return qs.all() @@ -1957,14 +1947,12 @@ class UnifiedJobAccess(BaseAccess): # 'credential', # 'job_template', # 'inventory_source', - # 'cloud_credential', # 'project___credential', # 'inventory_source___credential', # 'inventory_source___inventory', # 'job_template__inventory', # 'job_template__project', # 'job_template__credential', - # 'job_template__cloud_credential', #) return qs.all() diff --git a/awx/main/migrations/0043_v320_job_template_multi_credential.py b/awx/main/migrations/0043_v320_job_template_multi_credential.py new file mode 100644 index 0000000000..ff06b44228 --- /dev/null +++ b/awx/main/migrations/0043_v320_job_template_multi_credential.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from awx.main.migrations import _credentialtypes as credentialtypes +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0042_v320_drop_v1_credential_fields'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='extra_credentials', + field=models.ManyToManyField(related_name='_job_extra_credentials_+', to='main.Credential'), + ), + migrations.AddField( + model_name='jobtemplate', + name='extra_credentials', + field=models.ManyToManyField(related_name='_jobtemplate_extra_credentials_+', to='main.Credential'), + ), + migrations.RunPython(credentialtypes.migrate_job_credentials), + migrations.RemoveField( + model_name='job', + name='cloud_credential', + ), + migrations.RemoveField( + model_name='job', + name='network_credential', + ), + migrations.RemoveField( + model_name='jobtemplate', + name='cloud_credential', + ), + migrations.RemoveField( + model_name='jobtemplate', + name='network_credential', + ), + ] diff --git a/awx/main/migrations/_credentialtypes.py b/awx/main/migrations/_credentialtypes.py index 3e91dc3fd3..7aac3eb4b9 100644 --- a/awx/main/migrations/_credentialtypes.py +++ b/awx/main/migrations/_credentialtypes.py @@ -81,3 +81,21 @@ def migrate_to_v2_credentials(apps, schema_editor): new_cred.save() finally: utils.get_current_apps = orig_current_apps + + +def migrate_job_credentials(apps, schema_editor): + # this monkey-patch is necessary to make the implicit role generation save + # signal use the correct Role model (the version active at this point in + # migration, not the one at HEAD) + orig_current_apps = utils.get_current_apps + try: + utils.get_current_apps = lambda: apps + for type_ in ('Job', 'JobTemplate'): + for obj in apps.get_model('main', type_).objects.all(): + if obj.cloud_credential: + obj.extra_credentials.add(obj.cloud_credential) + if obj.network_credential: + obj.extra_credentials.add(obj.network_credential) + obj.save() + finally: + utils.get_current_apps = orig_current_apps diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index c15ab663a3..d8f1427109 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -96,21 +96,9 @@ class JobOptions(BaseModel): default=None, on_delete=models.SET_NULL, ) - cloud_credential = models.ForeignKey( + extra_credentials = models.ManyToManyField( 'Credential', - related_name='%(class)ss_as_cloud_credential+', - blank=True, - null=True, - 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, + related_name='%(class)ss_as_extra_credential+', ) forks = models.PositiveIntegerField( blank=True, @@ -170,26 +158,44 @@ class JobOptions(BaseModel): cred = self.credential if cred and cred.kind != 'ssh': raise ValidationError( - _('You must provide a machine / SSH credential.'), + _('You must provide an SSH credential.'), ) return cred - def clean_network_credential(self): - cred = self.network_credential - if cred and cred.kind != 'net': + def clean_vault_credential(self): + cred = self.vault_credential + if cred and cred.kind != 'vault': raise ValidationError( - _('You must provide a network credential.'), + _('You must provide a Vault credential.'), ) return cred - def clean_cloud_credential(self): - cred = self.cloud_credential - if cred and cred.kind not in CLOUD_PROVIDERS + ('aws',): - raise ValidationError( - _('Must provide a credential for a cloud provider, such as ' - 'Amazon Web Services or Rackspace.'), - ) - return cred + def clean(self): + super(JobOptions, self).clean() + # extra_credentials M2M can't be accessed until a primary key exists + if self.pk: + for cred in self.extra_credentials.all(): + if cred.credential_type.kind not in ('net', 'cloud'): + raise ValidationError( + _('Extra credentials must be network or cloud.'), + ) + + @property + def all_credentials(self): + credentials = self.extra_credentials.all() + if self.vault_credential: + credentials.insert(0, self.vault_credential) + if self.credential: + credentials.insert(0, self.credential) + return credentials + + @property + def network_credentials(self): + return [cred for cred in self.extra_credentials.all() if cred.credential_type.kind == 'net'] + + @property + def cloud_credentials(self): + return [cred for cred in self.extra_credentials.all() if cred.credential_type.kind == 'cloud'] @property def passwords_needed_to_start(self): @@ -262,11 +268,11 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour @classmethod def _get_unified_job_field_names(cls): return ['name', 'description', 'job_type', 'inventory', 'project', - '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', 'survey_passwords', 'allow_simultaneous', 'timeout', - 'store_facts',] + 'playbook', 'credential', 'extra_credentials', 'forks', + 'schedule', 'limit', 'verbosity', 'job_tags', 'extra_vars', + 'launch_type', 'force_handlers', 'skip_tags', 'start_at_task', + 'become_enabled', 'labels', 'survey_passwords', + 'allow_simultaneous', 'timeout', 'store_facts',] def resource_validation_data(self): ''' diff --git a/awx/main/tasks.py b/awx/main/tasks.py index fd8bfd078d..5364834b79 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -397,31 +397,40 @@ class BaseTask(Task): def build_private_data_files(self, instance, **kwargs): ''' - Create a temporary files containing the private data. - Returns a dictionary with keys from build_private_data - (i.e. 'credential', 'cloud_credential', 'network_credential') and values the file path. + Creates temporary files containing the private data. + Returns a dictionary i.e., + + { + 'credentials': { + : '/path/to/decrypted/data', + : '/path/to/decrypted/data', + : '/path/to/decrypted/data', + } + } ''' private_data = self.build_private_data(instance, **kwargs) - private_data_files = {} + private_data_files = {'credentials': {}} if private_data is not None: ssh_ver = get_ssh_version() ssh_too_old = True if ssh_ver == "unknown" else Version(ssh_ver) < Version("6.0") openssh_keys_supported = ssh_ver != "unknown" and Version(ssh_ver) >= Version("6.5") - for name, data in private_data.iteritems(): + for credential, data in private_data.get('credentials', {}).iteritems(): # Bail out now if a private key was provided in OpenSSH format # and we're running an earlier version (<6.5). if 'OPENSSH PRIVATE KEY' in data and not openssh_keys_supported: raise RuntimeError(OPENSSH_KEY_ERROR) - for name, data in private_data.iteritems(): + for credential, data in private_data.get('credentials', {}).iteritems(): + name = 'credential_%d' % credential.pk # OpenSSH formatted keys must have a trailing newline to be # accepted by ssh-add. if 'OPENSSH PRIVATE KEY' in data and not data.endswith('\n'): 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 credential.kind in ('ssh', 'scm') and not ssh_too_old: path = os.path.join(kwargs.get('private_data_dir', tempfile.gettempdir()), name) self.open_fifo_write(path, data) + private_data_files['credentials']['ssh'] = path # Ansible network modules do not yet support ssh-agent. # Instead, ssh private key file is explicitly passed via an # env variable. @@ -431,7 +440,7 @@ class BaseTask(Task): f.write(data) f.close() os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) - private_data_files[name] = path + private_data_files['credentials'][credential] = path return private_data_files def open_fifo_write(self, path, data): @@ -515,12 +524,6 @@ class BaseTask(Task): def args2cmdline(self, *args): return ' '.join([pipes.quote(a) for a in args]) - def get_ssh_key_path(self, instance, **kwargs): - ''' - Return the path to the SSH key file, if present. - ''' - return '' - def wrap_args_with_ssh_agent(self, args, ssh_key_path, ssh_auth_sock=None): if ssh_key_path: cmd = ' && '.join([self.args2cmdline('ssh-add', ssh_key_path), @@ -724,8 +727,11 @@ class BaseTask(Task): safe_env = self.build_safe_env(env, **kwargs) # handle custom injectors specified on the CredentialType - for type_ in ('credential', 'cloud_credential', 'network_credential'): - credential = getattr(instance, type_, None) + if hasattr(instance, 'all_credentials'): + credentials = instance.all_credentials + else: + credentials = [instance.credential] + for credential in credentials: if credential: credential.credential_type.inject_credential( credential, env, safe_env, args, safe_args, kwargs['private_data_dir'] @@ -788,6 +794,22 @@ class BaseTask(Task): if not hasattr(settings, 'CELERY_UNIT_TEST'): self.signal_finished(pk) + def get_ssh_key_path(self, instance, **kwargs): + ''' + If using an SSH key, return the path for use by ssh-agent. + ''' + private_data_files = kwargs.get('private_data_files', {}) + if 'ssh' in private_data_files.get('credentials', {}): + return private_data_files['credentials']['ssh'] + ''' + Note: Don't inject network ssh key data into ssh-agent for network + credentials because the ansible modules do not yet support it. + We will want to add back in support when/if Ansible network modules + support this. + ''' + + return '' + class RunJob(BaseTask): ''' @@ -800,36 +822,36 @@ class RunJob(BaseTask): def build_private_data(self, job, **kwargs): ''' Returns a dict of the form - dict['credential'] = - dict['cloud_credential'] = - dict['network_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: - if credential.ssh_key_data not in (None, ''): - private_data[cred_name] = decrypt_field(credential, 'ssh_key_data') or '' - - if job.cloud_credential and job.cloud_credential.kind == 'openstack': - credential = job.cloud_credential - openstack_auth = dict(auth_url=credential.host, - username=credential.username, - password=decrypt_field(credential, "password"), - project_name=credential.project) - if credential.domain not in (None, ''): - openstack_auth['domain_name'] = credential.domain - openstack_data = { - 'clouds': { - 'devstack': { - 'auth': openstack_auth, - }, - }, + { + 'credentials': { + : , + : , + : } - private_data['cloud_credential'] = yaml.safe_dump(openstack_data, default_flow_style=False, allow_unicode=True) + } + ''' + private_data = {'credentials': {}} + for credential in job.all_credentials: + # If we were sent SSH credentials, decrypt them and send them + # back (they will be written to a temporary file). + if credential.ssh_key_data not in (None, ''): + private_data['credentials'][credential] = decrypt_field(credential, 'ssh_key_data') or '' + + if credential.kind == 'openstack': + openstack_auth = dict(auth_url=credential.host, + username=credential.username, + password=decrypt_field(credential, "password"), + project_name=credential.project) + if credential.domain not in (None, ''): + openstack_auth['domain_name'] = credential.domain + openstack_data = { + 'clouds': { + 'devstack': { + 'auth': openstack_auth, + }, + }, + } + private_data['credentials'][credential] = yaml.safe_dump(openstack_data, default_flow_style=False, allow_unicode=True) return private_data @@ -894,47 +916,47 @@ class RunJob(BaseTask): env['INVENTORY_HOSTVARS'] = str(True) # Set environment variables for cloud credentials. - cloud_cred = job.cloud_credential - if cloud_cred and cloud_cred.kind == 'aws': - env['AWS_ACCESS_KEY'] = cloud_cred.username - env['AWS_SECRET_KEY'] = decrypt_field(cloud_cred, 'password') - if len(cloud_cred.security_token) > 0: - env['AWS_SECURITY_TOKEN'] = decrypt_field(cloud_cred, 'security_token') - # FIXME: Add EC2_URL, maybe EC2_REGION! - elif cloud_cred and cloud_cred.kind == 'rax': - env['RAX_USERNAME'] = cloud_cred.username - env['RAX_API_KEY'] = decrypt_field(cloud_cred, 'password') - env['CLOUD_VERIFY_SSL'] = str(False) - elif cloud_cred and cloud_cred.kind == 'gce': - env['GCE_EMAIL'] = cloud_cred.username - env['GCE_PROJECT'] = cloud_cred.project - env['GCE_PEM_FILE_PATH'] = kwargs.get('private_data_files', {}).get('cloud_credential', '') - elif cloud_cred and cloud_cred.kind == 'azure': - env['AZURE_SUBSCRIPTION_ID'] = cloud_cred.username - env['AZURE_CERT_PATH'] = kwargs.get('private_data_files', {}).get('cloud_credential', '') - elif cloud_cred and cloud_cred.kind == 'azure_rm': - if len(cloud_cred.client) and len(cloud_cred.tenant): - env['AZURE_CLIENT_ID'] = cloud_cred.client - env['AZURE_SECRET'] = decrypt_field(cloud_cred, 'secret') - env['AZURE_TENANT'] = cloud_cred.tenant - env['AZURE_SUBSCRIPTION_ID'] = cloud_cred.subscription - else: - env['AZURE_SUBSCRIPTION_ID'] = cloud_cred.subscription - env['AZURE_AD_USER'] = cloud_cred.username - env['AZURE_PASSWORD'] = decrypt_field(cloud_cred, 'password') - elif cloud_cred and cloud_cred.kind == 'vmware': - env['VMWARE_USER'] = cloud_cred.username - env['VMWARE_PASSWORD'] = decrypt_field(cloud_cred, 'password') - env['VMWARE_HOST'] = cloud_cred.host - elif cloud_cred and cloud_cred.kind == 'openstack': - env['OS_CLIENT_CONFIG_FILE'] = kwargs.get('private_data_files', {}).get('cloud_credential', '') + cred_files = kwargs.get('private_data_files', {}).get('credentials', {}) + for cloud_cred in job.cloud_credentials: + if cloud_cred and cloud_cred.kind == 'aws': + env['AWS_ACCESS_KEY'] = cloud_cred.username + env['AWS_SECRET_KEY'] = decrypt_field(cloud_cred, 'password') + if len(cloud_cred.security_token) > 0: + env['AWS_SECURITY_TOKEN'] = decrypt_field(cloud_cred, 'security_token') + # FIXME: Add EC2_URL, maybe EC2_REGION! + elif cloud_cred and cloud_cred.kind == 'rax': + env['RAX_USERNAME'] = cloud_cred.username + env['RAX_API_KEY'] = decrypt_field(cloud_cred, 'password') + env['CLOUD_VERIFY_SSL'] = str(False) + elif cloud_cred and cloud_cred.kind == 'gce': + env['GCE_EMAIL'] = cloud_cred.username + env['GCE_PROJECT'] = cloud_cred.project + env['GCE_PEM_FILE_PATH'] = cred_files.get(cloud_cred, '') + elif cloud_cred and cloud_cred.kind == 'azure': + env['AZURE_SUBSCRIPTION_ID'] = cloud_cred.username + env['AZURE_CERT_PATH'] = cred_files.get(cloud_cred, '') + elif cloud_cred and cloud_cred.kind == 'azure_rm': + if len(cloud_cred.client) and len(cloud_cred.tenant): + env['AZURE_CLIENT_ID'] = cloud_cred.client + env['AZURE_SECRET'] = decrypt_field(cloud_cred, 'secret') + env['AZURE_TENANT'] = cloud_cred.tenant + env['AZURE_SUBSCRIPTION_ID'] = cloud_cred.subscription + else: + env['AZURE_SUBSCRIPTION_ID'] = cloud_cred.subscription + env['AZURE_AD_USER'] = cloud_cred.username + env['AZURE_PASSWORD'] = decrypt_field(cloud_cred, 'password') + elif cloud_cred and cloud_cred.kind == 'vmware': + env['VMWARE_USER'] = cloud_cred.username + env['VMWARE_PASSWORD'] = decrypt_field(cloud_cred, 'password') + env['VMWARE_HOST'] = cloud_cred.host + elif cloud_cred and cloud_cred.kind == 'openstack': + env['OS_CLIENT_CONFIG_FILE'] = cred_files.get(cloud_cred, '') - network_cred = job.network_credential - if network_cred: + for network_cred in job.network_credentials: env['ANSIBLE_NET_USERNAME'] = network_cred.username env['ANSIBLE_NET_PASSWORD'] = decrypt_field(network_cred, 'password') - ssh_keyfile = kwargs.get('private_data_files', {}).get('network_credential', '') + ssh_keyfile = cred_files.get(network_cred, '') if ssh_keyfile: env['ANSIBLE_NET_SSH_KEYFILE'] = ssh_keyfile @@ -1099,24 +1121,6 @@ class RunJob(BaseTask): return OutputEventFilter(stdout_handle, job_event_callback) - def get_ssh_key_path(self, instance, **kwargs): - ''' - If using an SSH key, return the path for use by ssh-agent. - ''' - private_data_files = kwargs.get('private_data_files', {}) - if 'credential' in private_data_files: - return private_data_files.get('credential') - ''' - Note: Don't inject network ssh key data into ssh-agent for network - credentials because the ansible modules do not yet support it. - We will want to add back in support when/if Ansible network modules - support this. - ''' - #elif 'network_credential' in private_data_files: - # return private_data_files.get('network_credential') - - return '' - def should_use_proot(self, instance, **kwargs): ''' Return whether this task should use proot. @@ -1169,13 +1173,22 @@ class RunProjectUpdate(BaseTask): def build_private_data(self, project_update, **kwargs): ''' Return SSH private key data needed for this project update. + + Returns a dict of the form + { + 'credentials': { + : , + : , + : + } + } ''' handle, self.revision_path = tempfile.mkstemp() - private_data = {} + private_data = {'credentials': {}} if project_update.credential: credential = project_update.credential if credential.ssh_key_data not in (None, ''): - private_data['scm_credential'] = decrypt_field(project_update.credential, 'ssh_key_data') + private_data['credentials'][credential] = decrypt_field(credential, 'ssh_key_data') return private_data def build_passwords(self, project_update, **kwargs): @@ -1334,12 +1347,6 @@ class RunProjectUpdate(BaseTask): def get_idle_timeout(self): return getattr(settings, 'PROJECT_UPDATE_IDLE_TIMEOUT', None) - def get_ssh_key_path(self, instance, **kwargs): - ''' - If using an SSH key, return the path for use by ssh-agent. - ''' - return kwargs.get('private_data_files', {}).get('scm_credential', '') - def get_stdout_handle(self, instance): stdout_handle = super(RunProjectUpdate, self).get_stdout_handle(instance) @@ -1450,13 +1457,26 @@ class RunInventoryUpdate(BaseTask): model = InventoryUpdate def build_private_data(self, inventory_update, **kwargs): - """Return private data needed for inventory update. + """ + Return private data needed for inventory update. + + Returns a dict of the form + { + 'credentials': { + : , + : , + : + } + } + If no private data is needed, return None. """ + private_data = {'credentials': {}} # If this is Microsoft Azure or GCE, return the RSA key if inventory_update.source in ('azure', 'gce'): credential = inventory_update.credential - return dict(cloud_credential=decrypt_field(credential, 'ssh_key_data')) + private_data['credentials'][credential] = decrypt_field(credential, 'ssh_key_data') + return private_data if inventory_update.source == 'openstack': credential = inventory_update.credential @@ -1484,7 +1504,10 @@ class RunInventoryUpdate(BaseTask): }, 'cache': cache, } - return dict(cloud_credential=yaml.safe_dump(openstack_data, default_flow_style=False, allow_unicode=True)) + private_data['credentials'][credential] = yaml.safe_dump( + openstack_data, default_flow_style=False, allow_unicode=True + ) + return private_data cp = ConfigParser.ConfigParser() # Build custom ec2.ini for ec2 inventory script to use. @@ -1600,7 +1623,8 @@ class RunInventoryUpdate(BaseTask): if cp.sections(): f = cStringIO.StringIO() cp.write(f) - return dict(cloud_credential=f.getvalue()) + private_data['credentials'][inventory_update.credential] = f.getvalue() + return private_data def build_passwords(self, inventory_update, **kwargs): """Build a dictionary of authentication/credential information for @@ -1646,7 +1670,8 @@ class RunInventoryUpdate(BaseTask): # `awx/plugins/inventory` directory; those files should be kept in # sync with those in Ansible core at all times. passwords = kwargs.get('passwords', {}) - cloud_credential = kwargs.get('private_data_files', {}).get('cloud_credential', '') + cred_data = kwargs.get('private_data_files', {}).get('credentials', '') + cloud_credential = cred_data.get(inventory_update.credential, '') if inventory_update.source == 'ec2': if passwords.get('source_username', '') and passwords.get('source_password', ''): env['AWS_ACCESS_KEY_ID'] = passwords['source_username'] @@ -1823,13 +1848,22 @@ class RunAdHocCommand(BaseTask): ''' Return SSH private key data needed for this ad hoc command (only if stored in DB as ssh_key_data). + + Returns a dict of the form + { + 'credentials': { + : , + : , + : + } + } ''' # If we were sent SSH credentials, decrypt them and send them # back (they will be written to a temporary file). creds = ad_hoc_command.credential - private_data = {} + private_data = {'credentials': {}} if creds and creds.ssh_key_data not in (None, ''): - private_data['ad_hoc_credential'] = decrypt_field(creds, 'ssh_key_data') or '' + private_data['credentials'][creds] = decrypt_field(creds, 'ssh_key_data') or '' return private_data def build_passwords(self, ad_hoc_command, **kwargs): @@ -1986,12 +2020,6 @@ class RunAdHocCommand(BaseTask): return OutputEventFilter(stdout_handle, ad_hoc_command_event_callback) - def get_ssh_key_path(self, instance, **kwargs): - ''' - If using an SSH key, return the path for use by ssh-agent. - ''' - return kwargs.get('private_data_files', {}).get('ad_hoc_credential', '') - def should_use_proot(self, instance, **kwargs): ''' Return whether this task should use proot. diff --git a/awx/main/tests/functional/api/test_rbac_displays.py b/awx/main/tests/functional/api/test_rbac_displays.py index c905c9da35..115cbf997b 100644 --- a/awx/main/tests/functional/api/test_rbac_displays.py +++ b/awx/main/tests/functional/api/test_rbac_displays.py @@ -316,7 +316,6 @@ def test_prefetch_jt_copy_capability(job_template, project, inventory, machine_c qs = JobTemplate.objects.all() cache_list_capabilities(qs, [{'copy': [ 'project.use', 'inventory.use', 'credential.use', - 'cloud_credential.use', 'network_credential.use' ]}], JobTemplate, rando) assert qs[0].capabilities_cache == {'copy': False} @@ -326,7 +325,6 @@ def test_prefetch_jt_copy_capability(job_template, project, inventory, machine_c cache_list_capabilities(qs, [{'copy': [ 'project.use', 'inventory.use', 'credential.use', - 'cloud_credential.use', 'network_credential.use' ]}], JobTemplate, rando) assert qs[0].capabilities_cache == {'copy': True} diff --git a/awx/main/tests/functional/models/test_job_options.py b/awx/main/tests/functional/models/test_job_options.py new file mode 100644 index 0000000000..34bb7d7cae --- /dev/null +++ b/awx/main/tests/functional/models/test_job_options.py @@ -0,0 +1,60 @@ +import pytest + +from django.core.exceptions import ValidationError +from awx.main.models import Credential + + +@pytest.mark.django_db +def test_clean_credential_with_ssh_type(credentialtype_ssh, job_template): + credential = Credential( + name='My Credential', + credential_type=credentialtype_ssh + ) + credential.save() + + job_template.credential = credential + job_template.full_clean() + + +@pytest.mark.django_db +def test_clean_credential_with_invalid_type_xfail(credentialtype_aws, job_template): + credential = Credential( + name='My Credential', + credential_type=credentialtype_aws + ) + credential.save() + + with pytest.raises(ValidationError): + job_template.credential = credential + job_template.full_clean() + + +@pytest.mark.django_db +def test_clean_credential_with_custom_types(credentialtype_aws, credentialtype_net, job_template): + aws = Credential( + name='AWS Credential', + credential_type=credentialtype_aws + ) + aws.save() + net = Credential( + name='Net Credential', + credential_type=credentialtype_net + ) + net.save() + + job_template.extra_credentials.add(aws) + job_template.extra_credentials.add(net) + job_template.full_clean() + + +@pytest.mark.django_db +def test_clean_credential_with_custom_types_xfail(credentialtype_ssh, job_template): + ssh = Credential( + name='SSH Credential', + credential_type=credentialtype_ssh + ) + ssh.save() + + with pytest.raises(ValidationError): + job_template.extra_credentials.add(ssh) + job_template.full_clean() diff --git a/awx/main/tests/unit/test_access.py b/awx/main/tests/unit/test_access.py index 05199fd5e3..c0753bb8a3 100644 --- a/awx/main/tests/unit/test_access.py +++ b/awx/main/tests/unit/test_access.py @@ -159,6 +159,7 @@ def test_jt_existing_values_are_nonsensitive(job_template_with_ids, user_unit): assert access.changes_are_non_sensitive(job_template_with_ids, data) +@pytest.mark.xfail # TODO: update this to respect JT.extra_credentials def test_change_jt_sensitive_data(job_template_with_ids, mocker, user_unit): """Assure that can_add is called with all ForeignKeys.""" diff --git a/awx/main/tests/unit/test_network_credential.py b/awx/main/tests/unit/test_network_credential.py deleted file mode 100644 index 7435f86617..0000000000 --- a/awx/main/tests/unit/test_network_credential.py +++ /dev/null @@ -1,133 +0,0 @@ -import pytest - -from awx.main.models.credential import CredentialType, Credential -from awx.main.models.jobs import Job -from awx.main.models.inventory import Inventory -from awx.main.tasks import RunJob - - -def test_aws_cred_parse(mocker): - with mocker.patch('django.db.ConnectionRouter.db_for_write'): - job = Job(id=1) - job.inventory = mocker.MagicMock(spec=Inventory, id=2) - aws = CredentialType.defaults['aws']() - - options = { - 'credential_type': aws, - 'inputs': { - 'username': 'aws_user', - 'password': 'aws_passwd', - 'security_token': 'token', - } - } - job.cloud_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['AWS_ACCESS_KEY'] == options['inputs']['username'] - assert env['AWS_SECRET_KEY'] == options['inputs']['password'] - assert env['AWS_SECURITY_TOKEN'] == options['inputs']['security_token'] - - -def test_net_cred_parse(mocker): - with mocker.patch('django.db.ConnectionRouter.db_for_write'): - job = Job(id=1) - job.inventory = mocker.MagicMock(spec=Inventory, id=2) - net = CredentialType.defaults['aws']() - - options = { - 'credential_type': net, - 'inputs': { - 'username':'test', - 'password':'test', - 'authorize': True, - 'authorize_password': 'passwd', - 'ssh_key_data': """-----BEGIN PRIVATE KEY-----\nstuff==\n-----END PRIVATE KEY-----""", - } - } - private_data_files = { - 'network_credential': '/tmp/this_file_does_not_exist_during_test_but_the_path_is_real', - } - 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', private_data_files=private_data_files) - assert env['ANSIBLE_NET_USERNAME'] == options['inputs']['username'] - assert env['ANSIBLE_NET_PASSWORD'] == options['inputs']['password'] - assert env['ANSIBLE_NET_AUTHORIZE'] == '1' - assert env['ANSIBLE_NET_AUTH_PASS'] == options['inputs']['authorize_password'] - assert env['ANSIBLE_NET_SSH_KEYFILE'] == private_data_files['network_credential'] - - -@pytest.fixture -def mock_job(mocker): - ssh = CredentialType.defaults['ssh']() - options = { - 'credential_type': ssh, - 'inputs': { - 'username':'test', - 'password':'test', - 'ssh_key_data': """-----BEGIN PRIVATE KEY-----\nstuff==\n-----END PRIVATE KEY-----""", - 'authorize': True, - 'authorize_password': 'passwd', - } - } - - 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': None, - 'start_at_task': None, 'pk': 1, 'launch_type': 'normal', 'job_template':None, - 'created_by': None, 'extra_vars_dict': None, 'project':None, 'playbook': 'test.yml', - 'store_facts': False,} - mock_job = mocker.MagicMock(spec=Job, **mock_job_attrs) - return mock_job - - -@pytest.fixture -def run_job_net_cred(mocker, get_ssh_version, mock_job): - mocker.patch('django.db.ConnectionRouter.db_for_write') - run_job = RunJob() - - 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) - mocker.patch.object(run_job, 'post_run_hook', return_value=None) - - return run_job - - -@pytest.mark.skip(reason="Note: Ansible network modules don't yet support ssh-agent added keys.") -def test_net_cred_ssh_agent(run_job_net_cred, mock_job): - run_job = run_job_net_cred - run_job.run(mock_job.id) - - assert run_job.update_model.call_count == 4 - - 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 - - -def test_net_cred_job_model_env(run_job_net_cred, mock_job): - run_job = run_job_net_cred - run_job.run(mock_job.id) - - assert run_job.update_model.call_count == 4 - - job_args = run_job.update_model.call_args_list[1][1].get('job_env') - assert 'ANSIBLE_NET_USERNAME' in job_args - assert 'ANSIBLE_NET_PASSWORD' in job_args - assert 'ANSIBLE_NET_AUTHORIZE' in job_args - assert 'ANSIBLE_NET_AUTH_PASS' in job_args - assert 'ANSIBLE_NET_SSH_KEYFILE' in job_args - - diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 597b7aaa38..ed945cb0fd 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -126,7 +126,9 @@ def test_openstack_client_config_generation(mocker): 'source_vars_dict': {} }) cloud_config = update.build_private_data(inventory_update) - cloud_credential = yaml.load(cloud_config['cloud_credential']) + cloud_credential = yaml.load( + cloud_config.get('credentials')[inventory_update.credential] + ) assert cloud_credential['clouds'] == { 'devstack': { 'auth': { @@ -155,7 +157,9 @@ def test_openstack_client_config_generation_with_private_source_vars(mocker, sou 'source_vars_dict': {'private': source} }) cloud_config = update.build_private_data(inventory_update) - cloud_credential = yaml.load(cloud_config['cloud_credential']) + cloud_credential = yaml.load( + cloud_config.get('credentials')[inventory_update.credential] + ) assert cloud_credential['clouds'] == { 'devstack': { 'auth': { @@ -227,18 +231,27 @@ class TestJobExecution: p.stop() def get_instance(self): - return Job( + job = Job( pk=1, created=datetime.utcnow(), status='new', job_type='run', cancel_flag=False, - credential=None, - cloud_credential=None, - network_credential=None, project=Project() ) + # mock the job.extra_credentials M2M relation so we can avoid DB access + job._extra_credentials = [] + patch = mock.patch.object(Job, 'extra_credentials', mock.Mock( + all=lambda: job._extra_credentials, + add=job._extra_credentials.append, + spec_set=['all', 'add'] + )) + self.patches.append(patch) + patch.start() + + return job + @property def pk(self): return self.instance.pk @@ -278,13 +291,13 @@ class TestJobCredentials(TestJobExecution): def test_ssh_passwords(self, field, password_name, expected_flag): ssh = CredentialType.defaults['ssh']() - self.instance.credential = Credential( + credential = Credential( + pk=1, credential_type=ssh, inputs = {'username': 'bob', field: 'secret'} ) - self.instance.credential.inputs[field] = encrypt_field( - self.instance.credential, field - ) + credential.inputs[field] = encrypt_field(credential, field) + self.instance.credential = credential self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -298,20 +311,20 @@ class TestJobCredentials(TestJobExecution): def test_ssh_key_with_agent(self): ssh = CredentialType.defaults['ssh']() - self.instance.credential = Credential( + credential = Credential( + pk=1, credential_type=ssh, inputs = { 'username': 'bob', 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY } ) - self.instance.credential.inputs['ssh_key_data'] = encrypt_field( - self.instance.credential, 'ssh_key_data' - ) + credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data') + self.instance.credential = credential def run_pexpect_side_effect(private_data, *args, **kwargs): job, args, cwd, env, passwords, stdout = args - ssh_key_data_fifo = '/'.join([private_data, 'credential']) + ssh_key_data_fifo = '/'.join([private_data, 'credential_1']) assert open(ssh_key_data_fifo, 'r').read() == self.EXAMPLE_PRIVATE_KEY assert ' '.join(args).startswith( 'ssh-agent -a %s sh -c ssh-add %s && rm -f %s' % ( @@ -331,13 +344,13 @@ class TestJobCredentials(TestJobExecution): def test_aws_cloud_credential(self): aws = CredentialType.defaults['aws']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=aws, inputs = {'username': 'bob', 'password': 'secret'} ) - self.instance.cloud_credential.inputs['password'] = encrypt_field( - self.instance.cloud_credential, 'password' - ) + credential.inputs['password'] = encrypt_field(credential, 'password') + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -350,14 +363,14 @@ class TestJobCredentials(TestJobExecution): def test_aws_cloud_credential_with_sts_token(self): aws = CredentialType.defaults['aws']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=aws, inputs = {'username': 'bob', 'password': 'secret', 'security_token': 'token'} ) for key in ('password', 'security_token'): - self.instance.cloud_credential.inputs[key] = encrypt_field( - self.instance.cloud_credential, key - ) + credential.inputs[key] = encrypt_field(credential, key) + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -370,13 +383,13 @@ class TestJobCredentials(TestJobExecution): def test_rax_credential(self): rax = CredentialType.defaults['rackspace']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=rax, inputs = {'username': 'bob', 'password': 'secret'} ) - self.instance.cloud_credential.inputs['password'] = encrypt_field( - self.instance.cloud_credential, 'password' - ) + credential.inputs['password'] = encrypt_field(credential, 'password') + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -389,7 +402,8 @@ class TestJobCredentials(TestJobExecution): def test_gce_credentials(self): gce = CredentialType.defaults['gce']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=gce, inputs = { 'username': 'bob', @@ -397,9 +411,8 @@ class TestJobCredentials(TestJobExecution): 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY } ) - self.instance.cloud_credential.inputs['ssh_key_data'] = encrypt_field( - self.instance.cloud_credential, 'ssh_key_data' - ) + credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data') + self.instance.extra_credentials.add(credential) def run_pexpect_side_effect(*args, **kwargs): job, args, cwd, env, passwords, stdout = args @@ -414,16 +427,16 @@ class TestJobCredentials(TestJobExecution): def test_azure_credentials(self): azure = CredentialType.defaults['azure']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=azure, inputs = { 'username': 'bob', 'ssh_key_data': self.EXAMPLE_PRIVATE_KEY } ) - self.instance.cloud_credential.inputs['ssh_key_data'] = encrypt_field( - self.instance.cloud_credential, 'ssh_key_data' - ) + credential.inputs['ssh_key_data'] = encrypt_field(credential, 'ssh_key_data') + self.instance.extra_credentials.add(credential) def run_pexpect_side_effect(*args, **kwargs): job, args, cwd, env, passwords, stdout = args @@ -437,7 +450,8 @@ class TestJobCredentials(TestJobExecution): def test_azure_rm_with_tenant(self): azure = CredentialType.defaults['azure_rm']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=azure, inputs = { 'client': 'some-client', @@ -446,9 +460,8 @@ class TestJobCredentials(TestJobExecution): 'subscription': 'some-subscription' } ) - self.instance.cloud_credential.inputs['secret'] = encrypt_field( - self.instance.cloud_credential, 'secret' - ) + credential.inputs['secret'] = encrypt_field(credential, 'secret') + self.instance.extra_credentials.add(credential) self.task.run(self.pk) @@ -463,7 +476,8 @@ class TestJobCredentials(TestJobExecution): def test_azure_rm_with_password(self): azure = CredentialType.defaults['azure_rm']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=azure, inputs = { 'subscription': 'some-subscription', @@ -471,9 +485,8 @@ class TestJobCredentials(TestJobExecution): 'password': 'secret' } ) - self.instance.cloud_credential.inputs['password'] = encrypt_field( - self.instance.cloud_credential, 'password' - ) + credential.inputs['password'] = encrypt_field(credential, 'password') + self.instance.extra_credentials.add(credential) self.task.run(self.pk) @@ -487,13 +500,13 @@ class TestJobCredentials(TestJobExecution): def test_vmware_credentials(self): vmware = CredentialType.defaults['vmware']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=vmware, inputs = {'username': 'bob', 'password': 'secret', 'host': 'https://example.org'} ) - self.instance.cloud_credential.inputs['password'] = encrypt_field( - self.instance.cloud_credential, 'password' - ) + credential.inputs['password'] = encrypt_field(credential, 'password') + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -506,7 +519,8 @@ class TestJobCredentials(TestJobExecution): def test_openstack_credentials(self): openstack = CredentialType.defaults['openstack']() - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=openstack, inputs = { 'username': 'bob', @@ -515,9 +529,8 @@ class TestJobCredentials(TestJobExecution): 'host': 'https://keystone.example.org' } ) - self.instance.cloud_credential.inputs['password'] = encrypt_field( - self.instance.cloud_credential, 'password' - ) + credential.inputs['password'] = encrypt_field(credential, 'password') + self.instance.extra_credentials.add(credential) def run_pexpect_side_effect(*args, **kwargs): job, args, cwd, env, passwords, stdout = args @@ -539,7 +552,8 @@ class TestJobCredentials(TestJobExecution): def test_net_credentials(self): net = CredentialType.defaults['net']() - self.instance.network_credential = Credential( + credential = Credential( + pk=1, credential_type=net, inputs = { 'username': 'bob', @@ -550,9 +564,8 @@ class TestJobCredentials(TestJobExecution): } ) for field in ('password', 'ssh_key_data', 'authorize_password'): - self.instance.network_credential.inputs[field] = encrypt_field( - self.instance.network_credential, field - ) + credential.inputs[field] = encrypt_field(credential, field) + self.instance.extra_credentials.add(credential) def run_pexpect_side_effect(*args, **kwargs): job, args, cwd, env, passwords, stdout = args @@ -584,10 +597,12 @@ class TestJobCredentials(TestJobExecution): } } ) - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) + self.instance.extra_credentials.add(credential) with pytest.raises(Exception): self.task.run(self.pk) @@ -609,10 +624,12 @@ class TestJobCredentials(TestJobExecution): } } ) - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -639,10 +656,12 @@ class TestJobCredentials(TestJobExecution): } } ) - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -670,13 +689,13 @@ class TestJobCredentials(TestJobExecution): } } ) - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=some_cloud, inputs = {'password': 'SUPER-SECRET-123'} ) - self.instance.cloud_credential.inputs['password'] = encrypt_field( - self.instance.cloud_credential, 'password' - ) + credential.inputs['password'] = encrypt_field(credential, 'password') + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -704,10 +723,12 @@ class TestJobCredentials(TestJobExecution): } } ) - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -738,13 +759,13 @@ class TestJobCredentials(TestJobExecution): } } ) - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=some_cloud, inputs = {'password': 'SUPER-SECRET-123'} ) - self.instance.cloud_credential.inputs['password'] = encrypt_field( - self.instance.cloud_credential, 'password' - ) + credential.inputs['password'] = encrypt_field(credential, 'password') + self.instance.extra_credentials.add(credential) self.task.run(self.pk) assert self.task.run_pexpect.call_count == 1 @@ -775,10 +796,12 @@ class TestJobCredentials(TestJobExecution): } } ) - self.instance.cloud_credential = Credential( + credential = Credential( + pk=1, credential_type=some_cloud, inputs = {'api_token': 'ABC123'} ) + self.instance.extra_credentials.add(credential) self.task.run(self.pk) def run_pexpect_side_effect(*args, **kwargs): @@ -789,6 +812,49 @@ class TestJobCredentials(TestJobExecution): self.task.run_pexpect = mock.Mock(side_effect=run_pexpect_side_effect) self.task.run(self.pk) + def test_multi_cloud(self): + gce = CredentialType.defaults['gce']() + gce_credential = Credential( + pk=1, + credential_type=gce, + inputs = { + 'username': 'bob', + 'project': 'some-project', + 'ssh_key_data': 'GCE: %s' % self.EXAMPLE_PRIVATE_KEY + } + ) + gce_credential.inputs['ssh_key_data'] = encrypt_field(gce_credential, 'ssh_key_data') + self.instance.extra_credentials.add(gce_credential) + + azure = CredentialType.defaults['azure']() + azure_credential = Credential( + pk=2, + credential_type=azure, + inputs = { + 'username': 'joe', + 'ssh_key_data': 'AZURE: %s' % self.EXAMPLE_PRIVATE_KEY + } + ) + azure_credential.inputs['ssh_key_data'] = encrypt_field(azure_credential, 'ssh_key_data') + self.instance.extra_credentials.add(azure_credential) + + def run_pexpect_side_effect(*args, **kwargs): + job, args, cwd, env, passwords, stdout = args + + assert env['GCE_EMAIL'] == 'bob' + assert env['GCE_PROJECT'] == 'some-project' + ssh_key_data = env['GCE_PEM_FILE_PATH'] + assert open(ssh_key_data, 'rb').read() == 'GCE: %s' % self.EXAMPLE_PRIVATE_KEY + + assert env['AZURE_SUBSCRIPTION_ID'] == 'joe' + ssh_key_data = env['AZURE_CERT_PATH'] + assert open(ssh_key_data, 'rb').read() == 'AZURE: %s' % self.EXAMPLE_PRIVATE_KEY + + return ['successful', 0] + + self.task.run_pexpect = mock.Mock(side_effect=run_pexpect_side_effect) + self.task.run(self.pk) + class TestProjectUpdateCredentials(TestJobExecution): @@ -817,6 +883,7 @@ class TestProjectUpdateCredentials(TestJobExecution): ssh = CredentialType.defaults['ssh']() self.instance.scm_type = scm_type self.instance.credential = Credential( + pk=1, credential_type=ssh, inputs = {'username': 'bob', 'password': 'secret'} ) @@ -836,6 +903,7 @@ class TestProjectUpdateCredentials(TestJobExecution): ssh = CredentialType.defaults['ssh']() self.instance.scm_type = scm_type self.instance.credential = Credential( + pk=1, credential_type=ssh, inputs = { 'username': 'bob', @@ -848,7 +916,7 @@ class TestProjectUpdateCredentials(TestJobExecution): def run_pexpect_side_effect(private_data, *args, **kwargs): job, args, cwd, env, passwords, stdout = args - ssh_key_data_fifo = '/'.join([private_data, 'scm_credential']) + ssh_key_data_fifo = '/'.join([private_data, 'credential_1']) assert open(ssh_key_data_fifo, 'r').read() == self.EXAMPLE_PRIVATE_KEY assert ' '.join(args).startswith( 'ssh-agent -a %s sh -c ssh-add %s && rm -f %s' % ( @@ -885,6 +953,7 @@ class TestInventoryUpdateCredentials(TestJobExecution): aws = CredentialType.defaults['aws']() self.instance.source = 'ec2' self.instance.credential = Credential( + pk=1, credential_type=aws, inputs = {'username': 'bob', 'password': 'secret'} ) @@ -911,6 +980,7 @@ class TestInventoryUpdateCredentials(TestJobExecution): vmware = CredentialType.defaults['vmware']() self.instance.source = 'vmware' self.instance.credential = Credential( + pk=1, credential_type=vmware, inputs = {'username': 'bob', 'password': 'secret', 'host': 'https://example.org'} ) @@ -935,6 +1005,7 @@ class TestInventoryUpdateCredentials(TestJobExecution): azure = CredentialType.defaults['azure']() self.instance.source = 'azure' self.instance.credential = Credential( + pk=1, credential_type=azure, inputs = { 'username': 'bob', @@ -959,6 +1030,7 @@ class TestInventoryUpdateCredentials(TestJobExecution): gce = CredentialType.defaults['gce']() self.instance.source = 'gce' self.instance.credential = Credential( + pk=1, credential_type=gce, inputs = { 'username': 'bob', @@ -985,6 +1057,7 @@ class TestInventoryUpdateCredentials(TestJobExecution): openstack = CredentialType.defaults['openstack']() self.instance.source = 'openstack' self.instance.credential = Credential( + pk=1, credential_type=openstack, inputs = { 'username': 'bob', @@ -1019,6 +1092,7 @@ class TestInventoryUpdateCredentials(TestJobExecution): satellite6 = CredentialType.defaults['satellite6']() self.instance.source = 'satellite6' self.instance.credential = Credential( + pk=1, credential_type=satellite6, inputs = { 'username': 'bob', @@ -1046,6 +1120,7 @@ class TestInventoryUpdateCredentials(TestJobExecution): cloudforms = CredentialType.defaults['cloudforms']() self.instance.source = 'cloudforms' self.instance.credential = Credential( + pk=1, credential_type=cloudforms, inputs = { 'username': 'bob',