diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f51fb7a96c..fa4c774e6b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1484,7 +1484,8 @@ class CredentialSerializer(BaseSerializer): class Meta: model = Credential fields = ('*', 'user', 'team', 'kind', 'cloud', 'host', 'username', - 'password', 'security_token', 'project', 'ssh_key_data', 'ssh_key_unlock', + 'password', 'security_token', 'project', 'domain', + 'ssh_key_data', 'ssh_key_unlock', 'become_method', 'become_username', 'become_password', 'vault_password') diff --git a/awx/main/constants.py b/awx/main/constants.py index 64f6265569..a6bdafdf5a 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -1,5 +1,5 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. -CLOUD_PROVIDERS = ('azure', 'ec2', 'gce', 'rax', 'vmware', 'openstack') +CLOUD_PROVIDERS = ('azure', 'ec2', 'gce', 'rax', 'vmware', 'openstack', 'openstack_v3') SCHEDULEABLE_PROVIDERS = CLOUD_PROVIDERS + ('custom',) diff --git a/awx/main/migrations/0007_v300_credential_domain_field.py b/awx/main/migrations/0007_v300_credential_domain_field.py new file mode 100644 index 0000000000..8875f9071f --- /dev/null +++ b/awx/main/migrations/0007_v300_credential_domain_field.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0006_v300_create_system_job_templates'), + ] + + operations = [ + migrations.AddField( + model_name='credential', + name='domain', + field=models.CharField(default=b'', help_text='The identifier for the domain.', max_length=100, verbose_name='Domain', blank=True), + ), + ] diff --git a/awx/main/models/base.py b/awx/main/models/base.py index c4edfbd8ba..b912a71572 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -56,7 +56,7 @@ PERMISSION_TYPE_CHOICES = [ (PERM_JOBTEMPLATE_CREATE, _('Create a Job Template')), ] -CLOUD_INVENTORY_SOURCES = ['ec2', 'rax', 'vmware', 'gce', 'azure', 'openstack', 'custom'] +CLOUD_INVENTORY_SOURCES = ['ec2', 'rax', 'vmware', 'gce', 'azure', 'openstack', 'openstack_v3', 'custom'] VERBOSITY_CHOICES = [ (0, '0 (Normal)'), diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index 82e0f576e1..0293d18e00 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -34,6 +34,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): ('gce', _('Google Compute Engine')), ('azure', _('Microsoft Azure')), ('openstack', _('OpenStack')), + ('openstack_v3', _('OpenStack V3')), ] BECOME_METHOD_CHOICES = [ @@ -114,6 +115,13 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): verbose_name=_('Project'), help_text=_('The identifier for the project.'), ) + domain = models.CharField( + blank=True, + default='', + max_length=100, + verbose_name=_('Domain'), + help_text=_('The identifier for the domain.'), + ) ssh_key_data = models.TextField( blank=True, default='', @@ -203,10 +211,19 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): host = self.host or '' if not host and self.kind == 'vmware': raise ValidationError('Host required for VMware credential.') - if not host and self.kind == 'openstack': + if not host and self.kind in ('openstack', 'openstack_v3'): raise ValidationError('Host required for OpenStack credential.') return host + def clean_domain(self): + """For case of Keystone v3 identity service that requires a + `domain`, that a domain is provided. + """ + domain = self.domain or '' + if not domain and self.kind == 'openstack_v3': + raise ValidationError('Domain required for OpenStack with Keystone v3.') + return domain + def clean_username(self): username = self.username or '' if not username and self.kind == 'aws': @@ -216,7 +233,7 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): 'credential.') if not username and self.kind == 'vmware': raise ValidationError('Username required for VMware credential.') - if not username and self.kind == 'openstack': + if not username and self.kind in ('openstack', 'openstack_v3'): raise ValidationError('Username required for OpenStack credential.') return username @@ -228,13 +245,13 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique): raise ValidationError('API key required for Rackspace credential.') if not password and self.kind == 'vmware': raise ValidationError('Password required for VMware credential.') - if not password and self.kind == 'openstack': + if not password and self.kind in ('openstack', 'openstack_v3'): raise ValidationError('Password or API key required for OpenStack credential.') return password def clean_project(self): project = self.project or '' - if self.kind == 'openstack' and not project: + if self.kind in ('openstack', 'openstack_v3') and not project: raise ValidationError('Project name required for OpenStack credential.') return project diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index c95c8488bd..e1dd36e64d 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -748,6 +748,7 @@ class InventorySourceOptions(BaseModel): ('azure', _('Microsoft Azure')), ('vmware', _('VMware vCenter')), ('openstack', _('OpenStack')), + ('openstack_v3', _('OpenStack V3')), ('custom', _('Custom Script')), ] @@ -976,6 +977,11 @@ class InventorySourceOptions(BaseModel): """I don't think openstack has regions""" return [('all', 'All')] + @classmethod + def get_openstack_v3_region_choices(self): + """Defer to the behavior of openstack""" + return self.get_openstack_region_choices() + def clean_credential(self): if not self.source: return None diff --git a/awx/main/tasks.py b/awx/main/tasks.py index ec34886632..381ea31623 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -706,12 +706,14 @@ class RunJob(BaseTask): 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': + if job.cloud_credential and job.cloud_credential.kind in ('openstack', 'openstack_v3'): 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': { @@ -796,7 +798,7 @@ class RunJob(BaseTask): 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': + elif cloud_cred and cloud_cred.kind in ('openstack', 'openstack_v3'): env['OS_CLIENT_CONFIG_FILE'] = kwargs.get('private_data_files', {}).get('cloud_credential', '') # Set environment variables related to scan jobs @@ -1145,12 +1147,14 @@ class RunInventoryUpdate(BaseTask): credential = inventory_update.credential return dict(cloud_credential=decrypt_field(credential, 'ssh_key_data')) - if inventory_update.source == 'openstack': + if inventory_update.source in ('openstack', 'openstack_v3'): credential = inventory_update.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 private_state = str(inventory_update.source_vars_dict.get('private', 'true')) # Retrieve cache path from inventory update vars if available, # otherwise create a temporary cache path only for this update. @@ -1298,7 +1302,7 @@ class RunInventoryUpdate(BaseTask): env['GCE_PROJECT'] = passwords.get('source_project', '') env['GCE_PEM_FILE_PATH'] = cloud_credential env['GCE_ZONE'] = inventory_update.source_regions - elif inventory_update.source == 'openstack': + elif inventory_update.source in ('openstack', 'openstack_v3'): env['OS_CLIENT_CONFIG_FILE'] = cloud_credential elif inventory_update.source == 'file': # FIXME: Parse source_env to dict, update env. @@ -1341,6 +1345,11 @@ class RunInventoryUpdate(BaseTask): # to a shorter variable. :) src = inventory_update.source + # OpenStack V3 has everything in common with OpenStack aside + # from one extra parameter, so share these resources between them. + if src == 'openstack_v3': + src = 'openstack' + # Get the path to the inventory plugin, and append it to our # arguments. plugin_path = self.get_path_to('..', 'plugins', 'inventory', diff --git a/awx/main/tests/old/inventory.py b/awx/main/tests/old/inventory.py index 5c48f30bb6..3ac2310160 100644 --- a/awx/main/tests/old/inventory.py +++ b/awx/main/tests/old/inventory.py @@ -2068,6 +2068,26 @@ class InventoryUpdatesTest(BaseTransactionTest): self.check_inventory_source(inventory_source) self.assertFalse(self.group.all_hosts.filter(instance_id='').exists()) + def test_update_from_openstack_v3(self): + # Check that update works with Keystone v3 identity service + api_url = getattr(settings, 'TEST_OPENSTACK_HOST_V3', '') + api_user = getattr(settings, 'TEST_OPENSTACK_USER', '') + api_password = getattr(settings, 'TEST_OPENSTACK_PASSWORD', '') + api_project = getattr(settings, 'TEST_OPENSTACK_PROJECT', '') + api_domain = getattr(settings, 'TEST_OPENSTACK_DOMAIN', '') + if not all([api_url, api_user, api_password, api_project, api_domain]): + self.skipTest("No test openstack v3 credentials defined") + self.create_test_license_file() + credential = Credential.objects.create(kind='openstack_v3', + host=api_url, + username=api_user, + password=api_password, + project=api_project, + domain=api_domain) + inventory_source = self.update_inventory_source(self.group, source='openstack_v3', credential=credential) + self.check_inventory_source(inventory_source) + self.assertFalse(self.group.all_hosts.filter(instance_id='').exists()) + def test_update_from_azure(self): source_username = getattr(settings, 'TEST_AZURE_USERNAME', '') source_key_data = getattr(settings, 'TEST_AZURE_KEY_DATA', '') @@ -2112,3 +2132,27 @@ class InventoryCredentialTest(BaseTest): self.assertIn('password', response) self.assertIn('host', response) self.assertIn('project', response) + + def test_openstack_v3_create_ok(self): + data = { + 'kind': 'openstack_v3', + 'name': 'Best credential ever', + 'username': 'some_user', + 'password': 'some_password', + 'project': 'some_project', + 'host': 'some_host', + 'domain': 'some_domain', + } + self.post(self.url, data=data, expect=201, auth=self.get_super_credentials()) + + def test_openstack_v3_create_fail_required_fields(self): + data = { + 'kind': 'openstack_v3', + 'name': 'Best credential ever', + } + response = self.post(self.url, data=data, expect=400, auth=self.get_super_credentials()) + self.assertIn('username', response) + self.assertIn('password', response) + self.assertIn('host', response) + self.assertIn('project', response) + self.assertIn('domain', response) diff --git a/awx/ui/client/src/forms/Credentials.js b/awx/ui/client/src/forms/Credentials.js index 221ab12b22..84aaf804e8 100644 --- a/awx/ui/client/src/forms/Credentials.js +++ b/awx/ui/client/src/forms/Credentials.js @@ -169,7 +169,7 @@ export default "host": { labelBind: 'hostLabel', type: 'text', - ngShow: "kind.value == 'vmware' || kind.value == 'openstack'", + ngShow: "kind.value == 'vmware' || kind.value == 'openstack' || kind.value === 'openstack_v3'", awPopOverWatch: "hostPopOver", awPopOver: "set in helpers/credentials", dataTitle: 'Host', @@ -243,7 +243,7 @@ export default "password": { labelBind: 'passwordLabel', type: 'sensitive', - ngShow: "kind.value == 'scm' || kind.value == 'vmware' || kind.value == 'openstack'", + ngShow: "kind.value == 'scm' || kind.value == 'vmware' || kind.value == 'openstack' || kind.value == 'openstack_v3'", addRequired: false, editRequired: false, ask: false, @@ -338,7 +338,7 @@ export default "project": { labelBind: 'projectLabel', type: 'text', - ngShow: "kind.value == 'gce' || kind.value == 'openstack'", + ngShow: "kind.value == 'gce' || kind.value == 'openstack' || kind.value == 'openstack_v3'", awPopOverWatch: "projectPopOver", awPopOver: "set in helpers/credentials", dataTitle: 'Project ID', @@ -352,6 +352,23 @@ export default }, subForm: 'credentialSubForm' }, + "domain": { + labelBind: 'domainLabel', + type: 'text', + ngShow: "kind.value == 'openstack_v3'", + awPopOverWatch: "domainPopOver", + awPopOver: "set in helpers/credentials", + dataTitle: 'Domain Name', + dataPlacement: 'right', + dataContainer: "body", + addRequired: false, + editRequired: false, + awRequiredWhen: { + variable: 'domain_required', + init: false + }, + subForm: 'credentialSubForm' + }, "vault_password": { label: "Vault Password", type: 'sensitive', diff --git a/awx/ui/client/src/forms/Source.js b/awx/ui/client/src/forms/Source.js index 1eee07b344..86e6db5477 100644 --- a/awx/ui/client/src/forms/Source.js +++ b/awx/ui/client/src/forms/Source.js @@ -169,7 +169,8 @@ export default label: 'Source Variables', //"{{vars_label}}" , ngShow: "source && (source.value == 'vmware' || " + - "source.value == 'openstack')", + "source.value == 'openstack' || " + + "source.value == 'openstack_v3')", type: 'textarea', addRequired: false, class: 'Form-textAreaLabel', diff --git a/awx/ui/client/src/helpers/Credentials.js b/awx/ui/client/src/helpers/Credentials.js index f986f06e4e..f1af37a011 100644 --- a/awx/ui/client/src/helpers/Credentials.js +++ b/awx/ui/client/src/helpers/Credentials.js @@ -62,6 +62,7 @@ angular.module('CredentialsHelper', ['Utilities']) scope.username_required = false; // JT-- added username_required b/c mutliple 'kinds' need username to be required (GCE) scope.key_required = false; // JT -- doing the same for key and project scope.project_required = false; + scope.domain_required = false; scope.subscription_required = false; scope.key_description = "Paste the contents of the SSH private key file."; scope.key_hint= "drag and drop an SSH private key file on the field below"; @@ -69,9 +70,11 @@ angular.module('CredentialsHelper', ['Utilities']) scope.password_required = false; scope.hostLabel = ''; scope.projectLabel = ''; + scope.domainLabel = ''; scope.project_required = false; scope.passwordLabel = 'Password (API Key)'; scope.projectPopOver = "
The project value
"; + scope.domainPopOver = "The domain name
"; scope.hostPopOver = "The host value
"; if (!Empty(scope.kind)) { @@ -133,6 +136,22 @@ angular.module('CredentialsHelper', ['Utilities']) " as the username."; scope.hostPopOver = "The host to authenticate with." +
"
For example, https://openstack.business.com/v2.0/";
+ case 'openstack_v3':
+ scope.hostLabel = "Host (Authentication URL)";
+ scope.projectLabel = "Project (Tenet Name/ID)";
+ scope.domainLabel = "Domain Name";
+ scope.password_required = true;
+ scope.project_required = true;
+ scope.domain_required = true;
+ scope.host_required = true;
+ scope.username_required = true;
+ scope.projectPopOver = "
This is the tenant name " + + "or tenant id. This value is usually the same " + + " as the username.
"; + scope.hostPopOver = "The host to authenticate with." +
+ "
For example, https://openstack.business.com/v3
Domain used for Keystone v3 " +
+ "
identity service.