From c807d5dcf373cc84bbac34b21db09a1746b46ae7 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 1 Mar 2016 09:33:17 -0500 Subject: [PATCH 01/13] Add keystone v3 support via new domain field on credential --- awx/api/serializers.py | 3 ++- awx/main/migrations/0007_v300_changes.py | 19 +++++++++++++++++++ awx/main/models/credential.py | 7 +++++++ awx/main/tasks.py | 4 ++++ awx/main/tests/old/inventory.py | 20 ++++++++++++++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 awx/main/migrations/0007_v300_changes.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 6ca73cf6a0..e48f12e6c4 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/migrations/0007_v300_changes.py b/awx/main/migrations/0007_v300_changes.py new file mode 100644 index 0000000000..f6d0ec1410 --- /dev/null +++ b/awx/main/migrations/0007_v300_changes.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_changes'), + ] + + 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/credential.py b/awx/main/models/credential.py index 82e0f576e1..328c738cc0 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -114,6 +114,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='', diff --git a/awx/main/tasks.py b/awx/main/tasks.py index ec34886632..ba54e17e9b 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -712,6 +712,8 @@ class RunJob(BaseTask): 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': { @@ -1151,6 +1153,8 @@ class RunInventoryUpdate(BaseTask): 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. diff --git a/awx/main/tests/old/inventory.py b/awx/main/tests/old/inventory.py index 5c48f30bb6..ccfb7138c2 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', + 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', 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', '') From f4b1de766dd0670cd9243088ee1e9525d1fd415f Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Wed, 16 Mar 2016 15:32:05 -0400 Subject: [PATCH 02/13] Adding OpenStack v3 cred type --- awx/main/constants.py | 2 +- awx/main/models/base.py | 2 +- awx/main/models/credential.py | 18 ++++++++++---- awx/main/models/inventory.py | 6 +++++ awx/main/tasks.py | 13 ++++++---- awx/main/tests/old/inventory.py | 28 ++++++++++++++++++++-- awx/ui/client/src/forms/Credentials.js | 23 +++++++++++++++--- awx/ui/client/src/forms/Source.js | 3 ++- awx/ui/client/src/helpers/Credentials.js | 19 +++++++++++++++ awx/ui/client/src/helpers/Groups.js | 12 ++++++---- awx/ui/client/src/lists/HomeGroups.js | 5 +++- awx/ui/client/src/lists/InventoryGroups.js | 5 +++- 12 files changed, 114 insertions(+), 22 deletions(-) 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/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 328c738cc0..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 = [ @@ -210,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': @@ -223,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 @@ -235,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 ba54e17e9b..381ea31623 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -706,7 +706,7 @@ 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, @@ -798,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 @@ -1147,7 +1147,7 @@ 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, @@ -1302,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. @@ -1345,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 ccfb7138c2..3ac2310160 100644 --- a/awx/main/tests/old/inventory.py +++ b/awx/main/tests/old/inventory.py @@ -2078,13 +2078,13 @@ class InventoryUpdatesTest(BaseTransactionTest): 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', + 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', credential=credential) + 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()) @@ -2132,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

"; + scope.domainPopOver = "

Domain used for Keystone v3 " + + "
identity service.

"; break; } } diff --git a/awx/ui/client/src/helpers/Groups.js b/awx/ui/client/src/helpers/Groups.js index eeebb9d8bf..4e95d96857 100644 --- a/awx/ui/client/src/helpers/Groups.js +++ b/awx/ui/client/src/helpers/Groups.js @@ -305,7 +305,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name field_id: 'source_extra_vars', onReady: callback }); } if(scope.source.value==="vmware" || - scope.source.value==="openstack"){ + scope.source.value==="openstack" || + scope.source.value==="openstack_v3"){ scope.inventory_variables = (Empty(scope.source_vars)) ? "---" : scope.source_vars; ParseTypeChange({ scope: scope, variable: 'inventory_variables', parse_variable: form.fields.inventory_variables.parseTypeName, field_id: 'source_inventory_variables', onReady: callback }); @@ -315,7 +316,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name scope.source.value==='gce' || scope.source.value === 'azure' || scope.source.value === 'vmware' || - scope.source.value === 'openstack') { + scope.source.value === 'openstack' || + scope.source.value === 'openstack_v3') { if (scope.source.value === 'ec2') { kind = 'aws'; } else { @@ -924,7 +926,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name ParseTypeChange({ scope: sources_scope, variable: 'source_vars', parse_variable: SourceForm.fields.source_vars.parseTypeName, field_id: 'source_source_vars', onReady: waitStop }); } else if (sources_scope.source && (sources_scope.source.value === 'vmware' || - sources_scope.source.value === 'openstack')) { + sources_scope.source.value === 'openstack' || + sources_scope.source.value === 'openstack_v3')) { Wait('start'); ParseTypeChange({ scope: sources_scope, variable: 'inventory_variables', parse_variable: SourceForm.fields.inventory_variables.parseTypeName, field_id: 'source_inventory_variables', onReady: waitStop }); @@ -1303,7 +1306,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name } if (sources_scope.source && (sources_scope.source.value === 'vmware' || - sources_scope.source.value === 'openstack')) { + sources_scope.source.value === 'openstack' || + sources_scope.source.value === 'openstack_v3')) { data.source_vars = ToJSON(sources_scope.envParseType, sources_scope.inventory_variables, true); } diff --git a/awx/ui/client/src/lists/HomeGroups.js b/awx/ui/client/src/lists/HomeGroups.js index ad7180dff0..1c21fb6268 100644 --- a/awx/ui/client/src/lists/HomeGroups.js +++ b/awx/ui/client/src/lists/HomeGroups.js @@ -76,6 +76,9 @@ export default },{ name: "OpenStack", value: "openstack" + },{ + name: "OpenStack V3", + value: "openstack_v3" }], sourceModel: 'inventory_source', sourceField: 'source', @@ -84,7 +87,7 @@ export default has_external_source: { label: 'Has external source?', searchType: 'in', - searchValue: 'ec2,rax,vmware,azure,gce,openstack', + searchValue: 'ec2,rax,vmware,azure,gce,openstack,openstack_v3', searchOnly: true, sourceModel: 'inventory_source', sourceField: 'source' diff --git a/awx/ui/client/src/lists/InventoryGroups.js b/awx/ui/client/src/lists/InventoryGroups.js index 53881f3d7c..3b221e54e0 100644 --- a/awx/ui/client/src/lists/InventoryGroups.js +++ b/awx/ui/client/src/lists/InventoryGroups.js @@ -51,6 +51,9 @@ export default },{ name: "OpenStack", value: "openstack" + },{ + name: "OpenStack V3", + value: "openstack_v3" }], sourceModel: 'inventory_source', sourceField: 'source', @@ -59,7 +62,7 @@ export default has_external_source: { label: 'Has external source?', searchType: 'in', - searchValue: 'ec2,rax,vmware,azure,gce,openstack', + searchValue: 'ec2,rax,vmware,azure,gce,openstack,openstack_v3', searchOnly: true, sourceModel: 'inventory_source', sourceField: 'source' From fcc02f7678dd47d6ece37ad6b86e3de872ab81c3 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 18 Mar 2016 16:45:06 -0400 Subject: [PATCH 03/13] rebase and rename migrations corresponding to devel change --- ...007_v300_changes.py => 0007_v300_credential_domain_field.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0007_v300_changes.py => 0007_v300_credential_domain_field.py} (88%) diff --git a/awx/main/migrations/0007_v300_changes.py b/awx/main/migrations/0007_v300_credential_domain_field.py similarity index 88% rename from awx/main/migrations/0007_v300_changes.py rename to awx/main/migrations/0007_v300_credential_domain_field.py index f6d0ec1410..8875f9071f 100644 --- a/awx/main/migrations/0007_v300_changes.py +++ b/awx/main/migrations/0007_v300_credential_domain_field.py @@ -7,7 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0006_v300_changes'), + ('main', '0006_v300_create_system_job_templates'), ] operations = [ From 7c1efea037ca2af6d332ff59b0f3ffdd7d39e1b6 Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Sat, 19 Mar 2016 19:36:15 -0400 Subject: [PATCH 04/13] add onExit & onEnter hooks to $stateExtender, raze HostEventViewer and replace with host-events module, resolves #1132 --- awx/ui/client/src/about/about.route.js | 5 + awx/ui/client/src/app.js | 1 - awx/ui/client/src/helpers.js | 2 - awx/ui/client/src/helpers/HostEventsViewer.js | 287 ------------------ .../job-detail/host-event/host-event.route.js | 0 .../host-events/host-events.block.less | 82 +++++ .../host-events/host-events.controller.js | 184 +++++++++++ .../host-events/host-events.partial.html | 59 ++++ .../host-events/host-events.route.js | 27 ++ .../client/src/job-detail/host-events/main.js | 15 + .../src/job-detail/job-detail.controller.js | 17 +- .../src/job-detail/job-detail.partial.html | 47 +-- awx/ui/client/src/job-detail/main.js | 5 +- .../src/shared/stateExtender.provider.js | 4 +- 14 files changed, 388 insertions(+), 347 deletions(-) delete mode 100644 awx/ui/client/src/helpers/HostEventsViewer.js delete mode 100644 awx/ui/client/src/job-detail/host-event/host-event.route.js create mode 100644 awx/ui/client/src/job-detail/host-events/host-events.block.less create mode 100644 awx/ui/client/src/job-detail/host-events/host-events.controller.js create mode 100644 awx/ui/client/src/job-detail/host-events/host-events.partial.html create mode 100644 awx/ui/client/src/job-detail/host-events/host-events.route.js create mode 100644 awx/ui/client/src/job-detail/host-events/main.js diff --git a/awx/ui/client/src/about/about.route.js b/awx/ui/client/src/about/about.route.js index 5f8b5e9220..475cf1aea0 100644 --- a/awx/ui/client/src/about/about.route.js +++ b/awx/ui/client/src/about/about.route.js @@ -8,5 +8,10 @@ export default { ncyBreadcrumb: { label: "ABOUT" }, + onExit: function(){ + // hacky way to handle user browsing away via URL bar + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + }, templateUrl: templateUrl('about/about') }; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index cbf50b22b6..48d7d07019 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -177,7 +177,6 @@ var tower = angular.module('Tower', [ 'StandardOutHelper', 'LogViewerOptionsDefinition', 'EventViewerHelper', - 'HostEventsViewerHelper', 'JobDetailHelper', 'SocketIO', 'lrInfiniteScroll', diff --git a/awx/ui/client/src/helpers.js b/awx/ui/client/src/helpers.js index b298a635ef..e8190ea50e 100644 --- a/awx/ui/client/src/helpers.js +++ b/awx/ui/client/src/helpers.js @@ -12,7 +12,6 @@ import Credentials from "./helpers/Credentials"; import EventViewer from "./helpers/EventViewer"; import Events from "./helpers/Events"; import Groups from "./helpers/Groups"; -import HostEventsViewer from "./helpers/HostEventsViewer"; import Hosts from "./helpers/Hosts"; import JobDetail from "./helpers/JobDetail"; import JobSubmission from "./helpers/JobSubmission"; @@ -46,7 +45,6 @@ export EventViewer, Events, Groups, - HostEventsViewer, Hosts, JobDetail, JobSubmission, diff --git a/awx/ui/client/src/helpers/HostEventsViewer.js b/awx/ui/client/src/helpers/HostEventsViewer.js deleted file mode 100644 index e8fc5a940a..0000000000 --- a/awx/ui/client/src/helpers/HostEventsViewer.js +++ /dev/null @@ -1,287 +0,0 @@ -/************************************************* - * Copyright (c) 2015 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - - /** - * @ngdoc function - * @name helpers.function:HostEventsViewer - * @description view a list of events for a given job and host -*/ - -export default - angular.module('HostEventsViewerHelper', ['ModalDialog', 'Utilities', 'EventViewerHelper']) - - .factory('HostEventsViewer', ['$log', '$compile', 'CreateDialog', 'Wait', 'GetBasePath', 'Empty', 'GetEvents', 'EventViewer', - function($log, $compile, CreateDialog, Wait, GetBasePath, Empty, GetEvents, EventViewer) { - return function(params) { - var parent_scope = params.scope, - scope = parent_scope.$new(true), - job_id = params.job_id, - url = params.url, - title = params.title, //optional - fixHeight, buildTable, - lastID, setStatus, buildRow, status; - - // initialize the status dropdown - scope.host_events_status_options = [ - { value: "all", name: "All" }, - { value: "changed", name: "Changed" }, - { value: "failed", name: "Failed" }, - { value: "ok", name: "OK" }, - { value: "unreachable", name: "Unreachable" } - ]; - scope.host_events_search_name = params.name; - status = (params.status) ? params.status : 'all'; - scope.host_events_status_options.every(function(opt, idx) { - if (opt.value === status) { - scope.host_events_search_status = scope.host_events_status_options[idx]; - return false; - } - return true; - }); - if (!scope.host_events_search_status) { - scope.host_events_search_status = scope.host_events_status_options[0]; - } - - $log.debug('job_id: ' + job_id + ' url: ' + url + ' title: ' + title + ' name: ' + name + ' status: ' + status); - - scope.eventsSearchActive = (scope.host_events_search_name) ? true : false; - - if (scope.removeModalReady) { - scope.removeModalReady(); - } - scope.removeModalReady = scope.$on('ModalReady', function() { - scope.hostViewSearching = false; - $('#host-events-modal-dialog').dialog('open'); - }); - - if (scope.removeJobReady) { - scope.removeJobReady(); - } - scope.removeEventReady = scope.$on('EventsReady', function(e, data, maxID) { - var elem, html; - - lastID = maxID; - html = buildTable(data); - $('#host-events').html(html); - elem = angular.element(document.getElementById('host-events-modal-dialog')); - $compile(elem)(scope); - - CreateDialog({ - scope: scope, - width: 675, - height: 600, - minWidth: 450, - callback: 'ModalReady', - id: 'host-events-modal-dialog', - onResizeStop: fixHeight, - title: ( (title) ? title : 'Host Events' ), - onClose: function() { - try { - scope.$destroy(); - } - catch(e) { - //ignore - } - }, - onOpen: function() { - fixHeight(); - } - }); - }); - - if (scope.removeRefreshHTML) { - scope.removeRefreshHTML(); - } - scope.removeRefreshHTML = scope.$on('RefreshHTML', function(e, data) { - var elem, html = buildTable(data); - $('#host-events').html(html); - scope.hostViewSearching = false; - elem = angular.element(document.getElementById('host-events')); - $compile(elem)(scope); - }); - - setStatus = function(result) { - var msg = '', status = 'ok', status_text = 'OK'; - if (!result.task && result.event_data && result.event_data.res && result.event_data.res.ansible_facts) { - result.task = "Gathering Facts"; - } - if (result.event === "runner_on_no_hosts") { - msg = "No hosts remaining"; - } - if (result.event === 'runner_on_unreachable') { - status = 'unreachable'; - status_text = 'Unreachable'; - } - else if (result.failed) { - status = 'failed'; - status_text = 'Failed'; - } - else if (result.changed) { - status = 'changed'; - status_text = 'Changed'; - } - if (result.event_data.res && result.event_data.res.msg) { - msg = result.event_data.res.msg; - } - result.msg = msg; - result.status = status; - result.status_text = status_text; - return result; - }; - - buildRow = function(res) { - var html = ''; - html += "\n"; - html += " " + res.status_text + "\n"; - html += "" + res.host_name + "\n"; - html += "" + res.play + "\n"; - html += "" + res.task + "\n"; - html += ""; - return html; - }; - - buildTable = function(data) { - var html = "\n"; - html += "\n"; - data.results.forEach(function(result) { - var res = setStatus(result); - html += buildRow(res); - }); - html += "\n"; - html += "
\n"; - return html; - }; - - fixHeight = function() { - var available_height = $('#host-events-modal-dialog').height() - $('#host-events-modal-dialog #search-form').height() - $('#host-events-modal-dialog #fixed-table-header').height(); - $('#host-events').height(available_height); - $log.debug('set height to: ' + available_height); - // Check width and reset search fields - if ($('#host-events-modal-dialog').width() <= 450) { - $('#host-events-modal-dialog #status-field').css({'margin-left': '7px'}); - } - else { - $('#host-events-modal-dialog #status-field').css({'margin-left': '15px'}); - } - }; - - GetEvents({ - url: url, - scope: scope, - callback: 'EventsReady' - }); - - scope.modalOK = function() { - $('#host-events-modal-dialog').dialog('close'); - scope.$destroy(); - }; - - scope.searchEvents = function() { - scope.eventsSearchActive = (scope.host_events_search_name) ? true : false; - GetEvents({ - scope: scope, - url: url, - callback: 'RefreshHTML' - }); - }; - - scope.searchEventKeyPress = function(e) { - if (e.keyCode === 13) { - scope.searchEvents(); - } - }; - - scope.showDetails = function(id) { - EventViewer({ - scope: parent_scope, - url: GetBasePath('jobs') + job_id + '/job_events/?id=' + id, - }); - }; - - if (scope.removeEventsScrollDownBuild) { - scope.removeEventsScrollDownBuild(); - } - scope.removeEventsScrollDownBuild = scope.$on('EventScrollDownBuild', function(e, data, maxID) { - var elem, html = ''; - lastID = maxID; - data.results.forEach(function(result) { - var res = setStatus(result); - html += buildRow(res); - }); - if (html) { - $('#host-events table tbody').append(html); - elem = angular.element(document.getElementById('host-events')); - $compile(elem)(scope); - } - }); - - scope.hostEventsScrollDown = function() { - GetEvents({ - scope: scope, - url: url, - gt: lastID, - callback: 'EventScrollDownBuild' - }); - }; - - }; - }]) - - .factory('GetEvents', ['Rest', 'ProcessErrors', function(Rest, ProcessErrors) { - return function(params) { - var url = params.url, - scope = params.scope, - gt = params.gt, - callback = params.callback; - - if (scope.host_events_search_name) { - url += '?host_name=' + scope.host_events_search_name; - } - else { - url += '?host_name__isnull=false'; - } - - if (scope.host_events_search_status.value === 'changed') { - url += '&event__icontains=runner&changed=true'; - } - else if (scope.host_events_search_status.value === 'failed') { - url += '&event__icontains=runner&failed=true'; - } - else if (scope.host_events_search_status.value === 'ok') { - url += '&event=runner_on_ok&changed=false'; - } - else if (scope.host_events_search_status.value === 'unreachable') { - url += '&event=runner_on_unreachable'; - } - else if (scope.host_events_search_status.value === 'all') { - url += '&event__icontains=runner¬__event=runner_on_skipped'; - } - - if (gt) { - // used for endless scroll - url += '&id__gt=' + gt; - } - - url += '&page_size=50&order=id'; - - scope.hostViewSearching = true; - Rest.setUrl(url); - Rest.get() - .success(function(data) { - var lastID; - scope.hostViewSearching = false; - if (data.results.length > 0) { - lastID = data.results[data.results.length - 1].id; - } - scope.$emit(callback, data, lastID); - }) - .error(function(data, status) { - scope.hostViewSearching = false; - ProcessErrors(scope, data, status, null, { hdr: 'Error!', - msg: 'Failed to get events ' + url + '. GET returned: ' + status }); - }); - }; - }]); diff --git a/awx/ui/client/src/job-detail/host-event/host-event.route.js b/awx/ui/client/src/job-detail/host-event/host-event.route.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/awx/ui/client/src/job-detail/host-events/host-events.block.less b/awx/ui/client/src/job-detail/host-events/host-events.block.less new file mode 100644 index 0000000000..bde3fe72fd --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/host-events.block.less @@ -0,0 +1,82 @@ +@import "awx/ui/client/src/shared/branding/colors.less"; +@import "awx/ui/client/src/shared/branding/colors.default.less"; + +.HostEvents .modal-footer{ + border: 0; + margin-top: 0px; + padding-top: 5px; +} +.HostEvents-status--ok{ + color: @green; +} +.HostEvents-status--unreachable{ + color: @unreachable; +} +.HostEvents-status--changed{ + color: @changed; +} +.HostEvents-status--failed{ + color: @warning; +} +.HostEvents-status--skipped{ + color: @skipped; +} +.HostEvents-search--form{ + max-width: 420px; + display: inline-block; +} +.HostEvents-close{ + width: 70px; +} +.HostEvents-filter--form{ + padding-top: 15px; + padding-bottom: 15px; + float: right; + display: inline-block; +} +.HostEvents .modal-body{ + padding: 20px; +} +.HostEvents .select2-container{ + text-transform: capitalize; + max-width: 220px; + float: right; +} +.HostEvents-form--container{ + padding-top: 15px; + padding-bottom: 15px; +} +.HostEvents-title{ + color: @default-interface-txt; + font-weight: 600; +} +.HostEvents-status i { + padding-right: 10px; +} +.HostEvents-table--header { + height: 30px; + font-size: 14px; + font-weight: normal; + text-transform: uppercase; + color: #848992; + background-color: #EBEBEB; + padding-left: 15px; + padding-right: 15px; + border-bottom-width: 0px; +} +.HostEvents-table--header:first-of-type{ + border-top-left-radius: 5px; +} +.HostEvents-table--header:last-of-type{ + border-top-right-radius: 5px; +} +.HostEvents-table--row{ + color: @default-data-txt; + border: 0 !important; +} +.HostEvents-table--row:nth-child(odd){ + background: @default-tertiary-bg; +} +.HostEvents-table--cell{ + border: 0 !important; +} diff --git a/awx/ui/client/src/job-detail/host-events/host-events.controller.js b/awx/ui/client/src/job-detail/host-events/host-events.controller.js new file mode 100644 index 0000000000..45a7268ed7 --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/host-events.controller.js @@ -0,0 +1,184 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + + export default + ['$stateParams', '$scope', '$rootScope', '$state', 'Wait', + 'JobDetailService', 'CreateSelect2', 'PaginateInit', + function($stateParams, $scope, $rootScope, $state, Wait, + JobDetailService, CreateSelect2, PaginateInit){ + + + $scope.search = function(){ + Wait('start'); + if ($scope.searchStr == undefined){ + return + } + // The API treats params as AND query + // We should discuss the possibility of an OR array + + // search play description + /* + JobDetailService.getRelatedJobEvents($stateParams.id, { + play: $scope.searchStr}) + .success(function(res){ + results.push(res.results); + }); + */ + // search host name + JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $scope.searchStr}) + .success(function(res){ + $scope.results = res.results; + Wait('Stop') + }); + // search task + /* + JobDetailService.getRelatedJobEvents($stateParams.id, { + task: $scope.searchStr}) + .success(function(res){ + results.push(res.results); + }); + */ + }; + + $scope.filters = ['all', 'changed', 'failed', 'ok', 'unreachable', 'skipped']; + + var filter = function(filter){ + Wait('start'); + if (filter == 'all'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, {host_name: $stateParams.hostName}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + // handle runner cases + if (filter == 'skipped'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + event: 'runner_on_skipped'}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + if (filter == 'unreachable'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + event: 'runner_on_unreachable'}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + if (filter == 'ok'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + event: 'runner_on_ok' + // add param changed: false if 'ok' shouldn't display changed hosts + }) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + // handle convience properties .changed .failed + if (filter == 'changed'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + changed: true}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + if (filter == 'failed'){ + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + failed: true}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + }); + } + }; + + // watch select2 for changes + $('.HostEvents-select').on("select2:select", function (e) { + filter($('.HostEvents-select').val()); + }); + + $scope.processStatus = function(event, $index){ + // the stack for which status to display is + // unreachable > failed > changed > ok + // uses the API's runner events and convenience properties .failed .changed to determine status. + // see: job_event_callback.py + if (event.event == 'runner_on_unreachable'){ + $scope.results[$index].status = 'Unreachable'; + return 'HostEvents-status--unreachable' + } + // equiv to 'runner_on_error' && 'runner on failed' + if (event.failed){ + $scope.results[$index].status = 'Failed'; + return 'HostEvents-status--failed' + } + // catch the changed case before ok, because both can be true + if (event.changed){ + $scope.results[$index].status = 'Changed'; + return 'HostEvents-status--changed' + } + if (event.event == 'runner_on_ok'){ + $scope.results[$index].status = 'OK'; + return 'HostEvents-status--ok' + } + if (event.event == 'runner_on_skipped'){ + $scope.results[$index].status = 'Skipped'; + return 'HostEvents-status--skipped' + } + else{ + // study a case where none of these apply + } + }; + + + var init = function(){ + // create filter dropdown + CreateSelect2({ + element: '.HostEvents-select', + multiple: false + }); + // process the filter if one was passed + if ($stateParams.filter){ + filter($stateParams.filter).success(function(res){ + $scope.results = res.results; + PaginateInit({ scope: $scope, list: defaultUrl }); + Wait('stop'); + $('#HostEvents').modal('show'); + + + });; + } + else{ + Wait('start'); + JobDetailService.getRelatedJobEvents($stateParams.id, {host_name: $stateParams.hostName}) + .success(function(res){ + $scope.results = res.results; + Wait('stop'); + $('#HostEvents').modal('show'); + + }); + } + }; + + $scope.goBack = function(){ + // go back to the job details state + // we're leaning on $stateProvider's onExit to close the modal + $state.go('jobDetail'); + }; + + init(); + + }]; \ No newline at end of file diff --git a/awx/ui/client/src/job-detail/host-events/host-events.partial.html b/awx/ui/client/src/job-detail/host-events/host-events.partial.html new file mode 100644 index 0000000000..a0ee6956bb --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/host-events.partial.html @@ -0,0 +1,59 @@ + \ No newline at end of file diff --git a/awx/ui/client/src/job-detail/host-events/host-events.route.js b/awx/ui/client/src/job-detail/host-events/host-events.route.js new file mode 100644 index 0000000000..5365fea95c --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/host-events.route.js @@ -0,0 +1,27 @@ +/************************************************* + * Copyright (c) 2015 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import {templateUrl} from '../../shared/template-url/template-url.factory'; + +export default { + name: 'jobDetail.hostEvents', + url: '/host-events/:hostName?:filter', + controller: 'HostEventsController', + templateUrl: templateUrl('job-detail/host-events/host-events'), + onExit: function(){ + // close the modal + // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" + $('#HostEvents').modal('hide'); + // hacky way to handle user browsing away via URL bar + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); + }, + resolve: { + features: ['FeaturesService', function(FeaturesService) { + return FeaturesService.get(); + }] + } +}; diff --git a/awx/ui/client/src/job-detail/host-events/main.js b/awx/ui/client/src/job-detail/host-events/main.js new file mode 100644 index 0000000000..8a9487aec4 --- /dev/null +++ b/awx/ui/client/src/job-detail/host-events/main.js @@ -0,0 +1,15 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +import route from './host-events.route'; +import controller from './host-events.controller'; + +export default + angular.module('jobDetail.hostEvents', []) + .controller('HostEventsController', controller) + .run(['$stateExtender', function($stateExtender){ + $stateExtender.addState(route) + }]); \ No newline at end of file diff --git a/awx/ui/client/src/job-detail/job-detail.controller.js b/awx/ui/client/src/job-detail/job-detail.controller.js index e36dbb13de..1383f04c35 100644 --- a/awx/ui/client/src/job-detail/job-detail.controller.js +++ b/awx/ui/client/src/job-detail/job-detail.controller.js @@ -15,7 +15,7 @@ export default '$stateParams', '$log', 'ClearScope', 'GetBasePath', 'Wait', 'ProcessErrors', 'SelectPlay', 'SelectTask', 'Socket', 'GetElapsed', 'DrawGraph', 'LoadHostSummary', 'ReloadHostSummaryList', - 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun', 'HostEventsViewer', + 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun', 'LoadPlays', 'LoadTasks', 'LoadHosts', 'HostsEdit', 'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels', 'EditSchedule', 'ParseTypeChange', 'JobDetailService', 'EventViewer', @@ -25,7 +25,7 @@ export default SelectPlay, SelectTask, Socket, GetElapsed, DrawGraph, LoadHostSummary, ReloadHostSummaryList, JobIsFinished, SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, - PlaybookRun, HostEventsViewer, LoadPlays, LoadTasks, LoadHosts, + PlaybookRun, LoadPlays, LoadTasks, LoadHosts, HostsEdit, ParseVariableString, GetChoices, fieldChoices, fieldLabels, EditSchedule, ParseTypeChange, JobDetailService, EventViewer ) { @@ -43,7 +43,7 @@ export default scope.parseType = 'yaml'; scope.previousTaskFailed = false; $scope.stdoutFullScreen = false; - + scope.$watch('job_status', function(job_status) { if (job_status && job_status.explanation && job_status.explanation.split(":")[0] === "Previous Task Failed") { scope.previousTaskFailed = true; @@ -1400,17 +1400,6 @@ export default } }; - scope.hostEventsViewer = function(id, name, status) { - HostEventsViewer({ - scope: scope, - id: id, - name: name, - url: scope.job.related.job_events, - job_id: scope.job.id, - status: status - }); - }; - scope.refresh = function(){ $scope.$emit('LoadJob'); }; diff --git a/awx/ui/client/src/job-detail/job-detail.partial.html b/awx/ui/client/src/job-detail/job-detail.partial.html index 3ff7262d1c..aed0a5446e 100644 --- a/awx/ui/client/src/job-detail/job-detail.partial.html +++ b/awx/ui/client/src/job-detail/job-detail.partial.html @@ -1,9 +1,8 @@
- +
-
@@ -423,13 +422,13 @@ - {{ host.name }} + {{ host.name }} - {{ host.ok }} - {{ host.changed }} - {{ host.unreachable }} - {{ host.failed }} + {{ host.ok }} + {{ host.changed }} + {{ host.unreachable }} + {{ host.failed }} @@ -483,40 +482,6 @@
- -
diff --git a/awx/ui/client/src/job-detail/main.js b/awx/ui/client/src/job-detail/main.js index d985a310e6..8a9fc30aff 100644 --- a/awx/ui/client/src/job-detail/main.js +++ b/awx/ui/client/src/job-detail/main.js @@ -7,9 +7,12 @@ import route from './job-detail.route'; import controller from './job-detail.controller'; import service from './job-detail.service'; +import hostEvents from './host-events/main'; export default - angular.module('jobDetail', []) + angular.module('jobDetail', [ + hostEvents.name + ]) .controller('JobDetailController', controller) .service('JobDetailService', service) .run(['$stateExtender', function($stateExtender) { diff --git a/awx/ui/client/src/shared/stateExtender.provider.js b/awx/ui/client/src/shared/stateExtender.provider.js index f899f00c32..07b3c2051e 100644 --- a/awx/ui/client/src/shared/stateExtender.provider.js +++ b/awx/ui/client/src/shared/stateExtender.provider.js @@ -11,7 +11,9 @@ export default function($stateProvider){ resolve: state.resolve, params: state.params, data: state.data, - ncyBreadcrumb: state.ncyBreadcrumb + ncyBreadcrumb: state.ncyBreadcrumb, + onEnter: state.onEnter, + onExit: state.onExit }); } }; From 3ada60d7d462ae6fcf6a6a383e8b4ac3acaf6b97 Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Sun, 20 Mar 2016 14:17:08 -0400 Subject: [PATCH 05/13] Host Events - exclude changed events from ok filter #1132 --- .../src/job-detail/host-events/host-events.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/job-detail/host-events/host-events.controller.js b/awx/ui/client/src/job-detail/host-events/host-events.controller.js index 45a7268ed7..86a0a27dcf 100644 --- a/awx/ui/client/src/job-detail/host-events/host-events.controller.js +++ b/awx/ui/client/src/job-detail/host-events/host-events.controller.js @@ -77,8 +77,8 @@ if (filter == 'ok'){ return JobDetailService.getRelatedJobEvents($stateParams.id, { host_name: $stateParams.hostName, - event: 'runner_on_ok' - // add param changed: false if 'ok' shouldn't display changed hosts + event: 'runner_on_ok', + changed: false }) .success(function(res){ $scope.results = res.results; From f286dc748670c67845df226ca7d400e745dd502f Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Mon, 21 Mar 2016 15:40:41 -0400 Subject: [PATCH 06/13] Fix an issue with the email notifier Incorrect body format assumptions in the email notifier --- awx/main/notifications/email_backend.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/awx/main/notifications/email_backend.py b/awx/main/notifications/email_backend.py index 9a9d0a9e2d..0c5b6efa2d 100644 --- a/awx/main/notifications/email_backend.py +++ b/awx/main/notifications/email_backend.py @@ -20,9 +20,12 @@ class CustomEmailBackend(EmailBackend): sender_parameter = "sender" def format_body(self, body): - body_actual = smart_text("{} #{} had status {} on Ansible Tower, view details at {}\n\n".format(body['friendly_name'], - body['id'], - body['status'], - body['url'])) - body_actual += pprint.pformat(body, indent=4) + if "body" in body: + body_actual = body['body'] + else: + body_actual = smart_text("{} #{} had status {} on Ansible Tower, view details at {}\n\n".format(body['friendly_name'], + body['id'], + body['status'], + body['url'])) + body_actual += pprint.pformat(body, indent=4) return body_actual From 7354d1da2c5d458a2e136a8567d48e2af1d1e246 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 22 Mar 2016 09:46:46 -0400 Subject: [PATCH 07/13] Fix test_coverage make target --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c539cf9fd5..0d0217b339 100644 --- a/Makefile +++ b/Makefile @@ -368,7 +368,7 @@ test_unit: # Run all API unit tests with coverage enabled. test_coverage: - py.test --create-db --cov=awx --cov-report=xml --junitxml=./reports/junit.xml awx/main/tests awx/api/tests awx/fact/tests + py.test --create-db --cov=awx --cov-report=xml --junitxml=./reports/junit.xml awx/main/tests awx/api/tests # Output test coverage as HTML (into htmlcov directory). coverage_html: From 5ce4cb95fa4a953929ed33c91933d877de620c2a Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 22 Mar 2016 10:28:05 -0400 Subject: [PATCH 08/13] Update test make target, no fact/tests --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0d0217b339..daa9c35a71 100644 --- a/Makefile +++ b/Makefile @@ -361,7 +361,7 @@ check: flake8 pep8 # pyflakes pylint # Run all API unit tests. test: - py.test awx/main/tests awx/api/tests awx/fact/tests + py.test awx/main/tests awx/api/tests test_unit: py.test awx/main/tests/unit From 3889e32fd995da0f74da1c3b36b840bd7fca66cd Mon Sep 17 00:00:00 2001 From: Leigh Johnson Date: Tue, 22 Mar 2016 10:57:48 -0400 Subject: [PATCH 09/13] Host Events - support no results found, better searching, style tweaks, #1132 #1284 --- .../host-events/host-events.block.less | 4 +- .../host-events/host-events.controller.js | 53 ++++++++----------- .../host-events/host-events.partial.html | 21 +++++--- .../host-events/host-events.route.js | 5 +- .../src/job-detail/job-detail.partial.html | 10 ++-- 5 files changed, 47 insertions(+), 46 deletions(-) diff --git a/awx/ui/client/src/job-detail/host-events/host-events.block.less b/awx/ui/client/src/job-detail/host-events/host-events.block.less index bde3fe72fd..17d318dc89 100644 --- a/awx/ui/client/src/job-detail/host-events/host-events.block.less +++ b/awx/ui/client/src/job-detail/host-events/host-events.block.less @@ -58,8 +58,8 @@ font-size: 14px; font-weight: normal; text-transform: uppercase; - color: #848992; - background-color: #EBEBEB; + color: @default-interface-txt; + background-color: @default-list-header-bg; padding-left: 15px; padding-right: 15px; border-bottom-width: 0px; diff --git a/awx/ui/client/src/job-detail/host-events/host-events.controller.js b/awx/ui/client/src/job-detail/host-events/host-events.controller.js index 86a0a27dcf..a3a1c8faaf 100644 --- a/awx/ui/client/src/job-detail/host-events/host-events.controller.js +++ b/awx/ui/client/src/job-detail/host-events/host-events.controller.js @@ -6,42 +6,33 @@ export default ['$stateParams', '$scope', '$rootScope', '$state', 'Wait', - 'JobDetailService', 'CreateSelect2', 'PaginateInit', + 'JobDetailService', 'CreateSelect2', function($stateParams, $scope, $rootScope, $state, Wait, - JobDetailService, CreateSelect2, PaginateInit){ + JobDetailService, CreateSelect2){ + + // pagination not implemented yet, but it'll depend on this + $scope.page_size = $stateParams.page_size; + + $scope.activeFilter = $stateParams.filter || null; - $scope.search = function(){ Wait('start'); if ($scope.searchStr == undefined){ return } - // The API treats params as AND query - // We should discuss the possibility of an OR array - - // search play description - /* - JobDetailService.getRelatedJobEvents($stateParams.id, { - play: $scope.searchStr}) - .success(function(res){ - results.push(res.results); - }); - */ - // search host name + //http://docs.ansible.com/ansible-tower/latest/html/towerapi/intro.html#filtering + // SELECT WHERE host_name LIKE str OR WHERE play LIKE str OR WHERE task LIKE str AND host_name NOT "" + // selecting non-empty host_name fields prevents us from displaying non-runner events, like playbook_on_task_start JobDetailService.getRelatedJobEvents($stateParams.id, { - host_name: $scope.searchStr}) + or__host_name__icontains: $scope.searchStr, + or__play__icontains: $scope.searchStr, + or__task__icontains: $scope.searchStr, + not__host_name: "" , + page_size: $scope.pageSize}) .success(function(res){ $scope.results = res.results; - Wait('Stop') + Wait('stop') }); - // search task - /* - JobDetailService.getRelatedJobEvents($stateParams.id, { - task: $scope.searchStr}) - .success(function(res){ - results.push(res.results); - }); - */ }; $scope.filters = ['all', 'changed', 'failed', 'ok', 'unreachable', 'skipped']; @@ -49,7 +40,9 @@ var filter = function(filter){ Wait('start'); if (filter == 'all'){ - return JobDetailService.getRelatedJobEvents($stateParams.id, {host_name: $stateParams.hostName}) + return JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + page_size: $scope.pageSize}) .success(function(res){ $scope.results = res.results; Wait('stop'); @@ -154,17 +147,17 @@ if ($stateParams.filter){ filter($stateParams.filter).success(function(res){ $scope.results = res.results; - PaginateInit({ scope: $scope, list: defaultUrl }); Wait('stop'); $('#HostEvents').modal('show'); - - });; } else{ Wait('start'); - JobDetailService.getRelatedJobEvents($stateParams.id, {host_name: $stateParams.hostName}) + JobDetailService.getRelatedJobEvents($stateParams.id, { + host_name: $stateParams.hostName, + page_size: $stateParams.page_size}) .success(function(res){ + $scope.pagination = res; $scope.results = res.results; Wait('stop'); $('#HostEvents').modal('show'); diff --git a/awx/ui/client/src/job-detail/host-events/host-events.partial.html b/awx/ui/client/src/job-detail/host-events/host-events.partial.html index a0ee6956bb..ff2d21714a 100644 --- a/awx/ui/client/src/job-detail/host-events/host-events.partial.html +++ b/awx/ui/client/src/job-detail/host-events/host-events.partial.html @@ -6,7 +6,7 @@ HOST EVENTS
@@ -21,19 +21,19 @@
- - - - + + + + - + + + +
STATUSHOSTPLAYTASKSTATUSHOSTPLAYTASK
@@ -45,12 +45,17 @@ {{event.play}} {{event.task}}
+ No results were found. +
diff --git a/awx/ui/client/src/job-detail/host-events/host-events.route.js b/awx/ui/client/src/job-detail/host-events/host-events.route.js index 5365fea95c..ebb2bb7bdd 100644 --- a/awx/ui/client/src/job-detail/host-events/host-events.route.js +++ b/awx/ui/client/src/job-detail/host-events/host-events.route.js @@ -7,9 +7,12 @@ import {templateUrl} from '../../shared/template-url/template-url.factory'; export default { - name: 'jobDetail.hostEvents', + name: 'jobDetail.host-events', url: '/host-events/:hostName?:filter', controller: 'HostEventsController', + params: { + page_size: 10 + }, templateUrl: templateUrl('job-detail/host-events/host-events'), onExit: function(){ // close the modal diff --git a/awx/ui/client/src/job-detail/job-detail.partial.html b/awx/ui/client/src/job-detail/job-detail.partial.html index aed0a5446e..8daba354b5 100644 --- a/awx/ui/client/src/job-detail/job-detail.partial.html +++ b/awx/ui/client/src/job-detail/job-detail.partial.html @@ -422,13 +422,13 @@ - {{ host.name }} + {{ host.name }} - {{ host.ok }} - {{ host.changed }} - {{ host.unreachable }} - {{ host.failed }} + {{ host.ok }} + {{ host.changed }} + {{ host.unreachable }} + {{ host.failed }} From 0e2184902edbc55c88ade1377ddddbb0a518b921 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Tue, 22 Mar 2016 12:27:23 -0400 Subject: [PATCH 10/13] Handle runner items from ansible v2 Also denote whether the trailing runner_on_ was a loop event --- .../commands/run_callback_receiver.py | 2 +- awx/plugins/callback/job_event_callback.py | 24 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index d06ed1edd8..01ebbafea6 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -137,7 +137,7 @@ class CallbackReceiver(object): 'playbook_on_import_for_host', 'playbook_on_not_import_for_host'): parent = job_parent_events.get('playbook_on_play_start', None) - elif message['event'].startswith('runner_on_'): + elif message['event'].startswith('runner_on_') or message['event'].startswith('runner_item_on_'): list_parents = [] list_parents.append(job_parent_events.get('playbook_on_setup', None)) list_parents.append(job_parent_events.get('playbook_on_task_start', None)) diff --git a/awx/plugins/callback/job_event_callback.py b/awx/plugins/callback/job_event_callback.py index ddffcaf974..3a70c03085 100644 --- a/awx/plugins/callback/job_event_callback.py +++ b/awx/plugins/callback/job_event_callback.py @@ -196,7 +196,7 @@ class BaseCallbackModule(object): self._init_connection() if self.context is None: self._start_connection() - if 'res' in event_data \ + if 'res' in event_data and hasattr(event_data['res'], 'get') \ and event_data['res'].get('_ansible_no_log', False): res = event_data['res'] if 'stdout' in res and res['stdout']: @@ -271,16 +271,19 @@ class BaseCallbackModule(object): ignore_errors=ignore_errors) def v2_runner_on_failed(self, result, ignore_errors=False): + event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None self._log_event('runner_on_failed', host=result._host.name, res=result._result, task=result._task, - ignore_errors=ignore_errors) + ignore_errors=ignore_errors, event_loop=event_is_loop) def runner_on_ok(self, host, res): self._log_event('runner_on_ok', host=host, res=res) def v2_runner_on_ok(self, result): + event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None self._log_event('runner_on_ok', host=result._host.name, - task=result._task, res=result._result) + task=result._task, res=result._result, + event_loop=event_is_loop) def runner_on_error(self, host, msg): self._log_event('runner_on_error', host=host, msg=msg) @@ -292,8 +295,9 @@ class BaseCallbackModule(object): self._log_event('runner_on_skipped', host=host, item=item) def v2_runner_on_skipped(self, result): + event_is_loop = result._task.loop if hasattr(result._task, 'loop') else None self._log_event('runner_on_skipped', host=result._host.name, - task=result._task) + task=result._task, event_loop=event_is_loop) def runner_on_unreachable(self, host, res): self._log_event('runner_on_unreachable', host=host, res=res) @@ -327,6 +331,18 @@ class BaseCallbackModule(object): self._log_event('runner_on_file_diff', host=result._host.name, task=result._task, diff=diff) + def v2_runner_item_on_ok(self, result): + self._log_event('runner_item_on_ok', res=result._result, host=result._host.name, + task=result._task) + + def v2_runner_item_on_failed(self, result): + self._log_event('runner_item_on_failed', res=result._result, host=result._host.name, + task=result._task) + + def v2_runner_item_on_skipped(self, result): + self._log_event('runner_item_on_skipped', res=result._result, host=result._host.name, + task=result._task) + @staticmethod @statsd.timer('terminate_ssh_control_masters') def terminate_ssh_control_masters(): From 7f8fae566eb56f6fd2b0ed7b17b63656b0aa9173 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Tue, 22 Mar 2016 14:59:56 -0400 Subject: [PATCH 11/13] Further decouple survey spec from enablement We now show the survey summary field only if the contents of the survey spec or valid (not the default {}) --- awx/api/serializers.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 6ca73cf6a0..f51fb7a96c 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1598,16 +1598,15 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): notifiers_any = reverse('api:job_template_notifiers_any_list', args=(obj.pk,)), notifiers_success = reverse('api:job_template_notifiers_success_list', args=(obj.pk,)), notifiers_error = reverse('api:job_template_notifiers_error_list', args=(obj.pk,)), + survey_spec = reverse('api:job_template_survey_spec', args=(obj.pk,)) )) if obj.host_config_key: res['callback'] = reverse('api:job_template_callback', args=(obj.pk,)) - if obj.survey_enabled: - res['survey_spec'] = reverse('api:job_template_survey_spec', args=(obj.pk,)) return res def get_summary_fields(self, obj): d = super(JobTemplateSerializer, self).get_summary_fields(obj) - if obj.survey_enabled and ('name' in obj.survey_spec and 'description' in obj.survey_spec): + if obj.survey_spec is not None and ('name' in obj.survey_spec and 'description' in obj.survey_spec): d['survey'] = dict(title=obj.survey_spec['name'], description=obj.survey_spec['description']) request = self.context.get('request', None) if request is not None and request.user is not None and obj.inventory is not None and obj.project is not None: From d07da55eacc752dea2f2f36a7690311cb6ad715f Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Thu, 24 Mar 2016 13:54:03 -0400 Subject: [PATCH 12/13] fix merge fail --- awx/api/serializers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e850997ca9..be34117c2a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1665,11 +1665,8 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer): notifiers_any = reverse('api:job_template_notifiers_any_list', args=(obj.pk,)), notifiers_success = reverse('api:job_template_notifiers_success_list', args=(obj.pk,)), notifiers_error = reverse('api:job_template_notifiers_error_list', args=(obj.pk,)), -<<<<<<< HEAD access_list = reverse('api:job_template_access_list', args=(obj.pk,)), -======= survey_spec = reverse('api:job_template_survey_spec', args=(obj.pk,)) ->>>>>>> ddd163c21bb6b6a2c83f90cb38421d201f936130 )) if obj.host_config_key: res['callback'] = reverse('api:job_template_callback', args=(obj.pk,)) From b932174ee24a923bd651eba166a5be075326f47b Mon Sep 17 00:00:00 2001 From: Akita Noek Date: Thu, 24 Mar 2016 13:58:05 -0400 Subject: [PATCH 13/13] Fixed up migrations after merge --- ...ial_domain_field.py => 0010_v300_credential_domain_field.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename awx/main/migrations/{0007_v300_credential_domain_field.py => 0010_v300_credential_domain_field.py} (88%) diff --git a/awx/main/migrations/0007_v300_credential_domain_field.py b/awx/main/migrations/0010_v300_credential_domain_field.py similarity index 88% rename from awx/main/migrations/0007_v300_credential_domain_field.py rename to awx/main/migrations/0010_v300_credential_domain_field.py index 8875f9071f..fc77d9999e 100644 --- a/awx/main/migrations/0007_v300_credential_domain_field.py +++ b/awx/main/migrations/0010_v300_credential_domain_field.py @@ -7,7 +7,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0006_v300_create_system_job_templates'), + ('main', '0009_v300_create_system_job_templates'), ] operations = [