From eab417cf8b7cca51e4b9face8fbeb99b631d2453 Mon Sep 17 00:00:00 2001 From: Antony PERIGAULT Date: Thu, 15 Mar 2018 17:37:32 +0100 Subject: [PATCH 001/762] Map users in organizations based on saml groups --- awx/sso/pipeline.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/awx/sso/pipeline.py b/awx/sso/pipeline.py index 23d603275f..8c89d629a0 100644 --- a/awx/sso/pipeline.py +++ b/awx/sso/pipeline.py @@ -54,7 +54,7 @@ def prevent_inactive_login(backend, details, user=None, *args, **kwargs): raise AuthInactive(backend) -def _update_m2m_from_expression(user, rel, expr, remove=True): +def _update_m2m_from_expression(user, rel, expr, remove=True, saml_team_names=False): ''' Helper function to update m2m relationship based on user matching one or more expressions. @@ -70,6 +70,9 @@ def _update_m2m_from_expression(user, rel, expr, remove=True): if isinstance(expr, (six.string_types, type(re.compile('')))): expr = [expr] for ex in expr: + if saml_team_names: + if ex in saml_team_names: + should_add = True if isinstance(ex, six.string_types): if user.username == ex or user.email == ex: should_add = True @@ -104,16 +107,24 @@ def update_user_orgs(backend, details, user=None, *args, **kwargs): except IndexError: continue + team_map = backend.setting('SOCIAL_AUTH_SAML_TEAM_ATTR') or {} + saml_team_names = False + if team_map.get('saml_attr'): + saml_team_names = set(kwargs + .get('response', {}) + .get('attributes', {}) + .get(team_map['saml_attr'], [])) + # Update org admins from expression(s). remove = bool(org_opts.get('remove', True)) admins_expr = org_opts.get('admins', None) remove_admins = bool(org_opts.get('remove_admins', remove)) - _update_m2m_from_expression(user, org.admin_role.members, admins_expr, remove_admins) + _update_m2m_from_expression(user, org.admin_role.members, admins_expr, remove_admins, saml_team_names) # Update org users from expression(s). users_expr = org_opts.get('users', None) remove_users = bool(org_opts.get('remove_users', remove)) - _update_m2m_from_expression(user, org.member_role.members, users_expr, remove_users) + _update_m2m_from_expression(user, org.member_role.members, users_expr, remove_users, saml_team_names) def update_user_teams(backend, details, user=None, *args, **kwargs): From 13cd57febaba7ffce8aa5a9808c151fe4320f056 Mon Sep 17 00:00:00 2001 From: Antony PERIGAULT Date: Fri, 27 Apr 2018 12:05:18 +0200 Subject: [PATCH 002/762] Revert "Map users in organizations based on saml groups" This reverts commit b4e0ff650165e6b0ab08d9a78be85f2f46182b94. --- awx/sso/pipeline.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/awx/sso/pipeline.py b/awx/sso/pipeline.py index 8c89d629a0..23d603275f 100644 --- a/awx/sso/pipeline.py +++ b/awx/sso/pipeline.py @@ -54,7 +54,7 @@ def prevent_inactive_login(backend, details, user=None, *args, **kwargs): raise AuthInactive(backend) -def _update_m2m_from_expression(user, rel, expr, remove=True, saml_team_names=False): +def _update_m2m_from_expression(user, rel, expr, remove=True): ''' Helper function to update m2m relationship based on user matching one or more expressions. @@ -70,9 +70,6 @@ def _update_m2m_from_expression(user, rel, expr, remove=True, saml_team_names=Fa if isinstance(expr, (six.string_types, type(re.compile('')))): expr = [expr] for ex in expr: - if saml_team_names: - if ex in saml_team_names: - should_add = True if isinstance(ex, six.string_types): if user.username == ex or user.email == ex: should_add = True @@ -107,24 +104,16 @@ def update_user_orgs(backend, details, user=None, *args, **kwargs): except IndexError: continue - team_map = backend.setting('SOCIAL_AUTH_SAML_TEAM_ATTR') or {} - saml_team_names = False - if team_map.get('saml_attr'): - saml_team_names = set(kwargs - .get('response', {}) - .get('attributes', {}) - .get(team_map['saml_attr'], [])) - # Update org admins from expression(s). remove = bool(org_opts.get('remove', True)) admins_expr = org_opts.get('admins', None) remove_admins = bool(org_opts.get('remove_admins', remove)) - _update_m2m_from_expression(user, org.admin_role.members, admins_expr, remove_admins, saml_team_names) + _update_m2m_from_expression(user, org.admin_role.members, admins_expr, remove_admins) # Update org users from expression(s). users_expr = org_opts.get('users', None) remove_users = bool(org_opts.get('remove_users', remove)) - _update_m2m_from_expression(user, org.member_role.members, users_expr, remove_users, saml_team_names) + _update_m2m_from_expression(user, org.member_role.members, users_expr, remove_users) def update_user_teams(backend, details, user=None, *args, **kwargs): From 9bfac4f44b49e5c2cf33d707def908a7dc04025b Mon Sep 17 00:00:00 2001 From: Antony PERIGAULT Date: Wed, 2 May 2018 15:00:39 +0200 Subject: [PATCH 003/762] New feature: Add SAML users as organization admins --- awx/sso/conf.py | 2 ++ awx/sso/pipeline.py | 54 ++++++++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 75178d6d12..c9393160b2 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -1196,7 +1196,9 @@ register( category_slug='saml', placeholder=collections.OrderedDict([ ('saml_attr', 'organization'), + ('saml_admin_attr', 'organization_admin'), ('remove', True), + ('remove_admins', True), ]), feature_required='enterprise_auth', ) diff --git a/awx/sso/pipeline.py b/awx/sso/pipeline.py index 23d603275f..420e4e1930 100644 --- a/awx/sso/pipeline.py +++ b/awx/sso/pipeline.py @@ -82,6 +82,33 @@ def _update_m2m_from_expression(user, rel, expr, remove=True): rel.remove(user) +def _update_org_from_attr(user, rel, attr, remove, remove_admins): + from awx.main.models import Organization + multiple_orgs = feature_enabled('multiple_organizations') + + org_ids = [] + + for org_name in attr: + if multiple_orgs: + org = Organization.objects.get_or_create(name=org_name)[0] + else: + try: + org = Organization.objects.order_by('pk')[0] + except IndexError: + continue + + org_ids.append(org.id) + getattr(org, rel).members.add(user) + + if remove: + [o.member_role.members.remove(user) for o in + Organization.objects.filter(Q(member_role__members=user) & ~Q(id__in=org_ids))] + + if remove_admins: + [o.admin_role.members.remove(user) for o in + Organization.objects.filter(Q(admin_role__members=user) & ~Q(id__in=org_ids))] + + def update_user_orgs(backend, details, user=None, *args, **kwargs): ''' Update organization memberships for the given user based on mapping rules @@ -150,32 +177,19 @@ def update_user_teams(backend, details, user=None, *args, **kwargs): def update_user_orgs_by_saml_attr(backend, details, user=None, *args, **kwargs): if not user: return - from awx.main.models import Organization from django.conf import settings - multiple_orgs = feature_enabled('multiple_organizations') org_map = settings.SOCIAL_AUTH_SAML_ORGANIZATION_ATTR - if org_map.get('saml_attr') is None: + if org_map.get('saml_attr') is None and org_map.get('saml_admin_attr') is None: return + remove = bool(org_map.get('remove', True)) + remove_admins = bool(org_map.get('remove_admins', True)) + attr_values = kwargs.get('response', {}).get('attributes', {}).get(org_map['saml_attr'], []) + attr_admin_values = kwargs.get('response', {}).get('attributes', {}).get(org_map['saml_admin_attr'], []) - org_ids = [] - - for org_name in attr_values: - if multiple_orgs: - org = Organization.objects.get_or_create(name=org_name)[0] - else: - try: - org = Organization.objects.order_by('pk')[0] - except IndexError: - continue - - org_ids.append(org.id) - org.member_role.members.add(user) - - if org_map.get('remove', True): - [o.member_role.members.remove(user) for o in - Organization.objects.filter(Q(member_role__members=user) & ~Q(id__in=org_ids))] + _update_org_from_attr(user, "member_role", attr_values, remove, False) + _update_org_from_attr(user, "admin_role", attr_admin_values, False, remove_admins) def update_user_teams_by_saml_attr(backend, details, user=None, *args, **kwargs): From 4090fe6d11982ac90350f0efd133d9eb115ea846 Mon Sep 17 00:00:00 2001 From: Antony PERIGAULT Date: Wed, 2 May 2018 16:25:44 +0200 Subject: [PATCH 004/762] Fix functional tests --- awx/sso/tests/functional/test_pipeline.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/sso/tests/functional/test_pipeline.py b/awx/sso/tests/functional/test_pipeline.py index 0e2abe67a3..57a6eed7bb 100644 --- a/awx/sso/tests/functional/test_pipeline.py +++ b/awx/sso/tests/functional/test_pipeline.py @@ -149,6 +149,7 @@ class TestSAMLAttr(): 'idp_name': u'idp', 'attributes': { 'memberOf': ['Default1', 'Default2'], + 'admins': ['Default3'], 'groups': ['Blue', 'Red'], 'User.email': ['cmeyers@redhat.com'], 'User.LastName': ['Meyers'], @@ -176,7 +177,9 @@ class TestSAMLAttr(): class MockSettings(): SOCIAL_AUTH_SAML_ORGANIZATION_ATTR = { 'saml_attr': 'memberOf', + 'saml_admin_attr': 'admins', 'remove': True, + 'remove_admins': True, } SOCIAL_AUTH_SAML_TEAM_ATTR = { 'saml_attr': 'groups', From a096051dc355f5f470fd6226f6837ec3bc31a903 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Fri, 11 May 2018 13:54:25 -0400 Subject: [PATCH 005/762] update app tokens search path --- .../list-applications-users.controller.js | 14 ++++++++++++-- .../applications/list-applications-users.view.html | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/features/applications/list-applications-users.controller.js b/awx/ui/client/features/applications/list-applications-users.controller.js index 1c6856498b..7271fbc5da 100644 --- a/awx/ui/client/features/applications/list-applications-users.controller.js +++ b/awx/ui/client/features/applications/list-applications-users.controller.js @@ -8,7 +8,9 @@ function ListApplicationsUsersController ( $scope, Dataset, resolvedModels, - strings + strings, + $stateParams, + GetBasePath ) { const vm = this || {}; // const application = resolvedModels; @@ -21,9 +23,15 @@ function ListApplicationsUsersController ( $scope.list = { iterator, name, basePath: 'applications' }; $scope.collection = { iterator }; + $scope.tokenBasePath = `${GetBasePath('applications')}${$stateParams.application_id}/tokens`; $scope[key] = Dataset.data; vm.usersCount = Dataset.data.count; $scope[name] = Dataset.data.results; + $scope.$on('updateDataset', (e, dataset) => { + $scope[key] = dataset; + $scope[name] = dataset.results; + vm.usersCount = dataset.count; + }); vm.getLastUsed = user => { const lastUsed = _.get(user, 'last_used'); @@ -49,7 +57,9 @@ ListApplicationsUsersController.$inject = [ '$scope', 'Dataset', 'resolvedModels', - 'ApplicationsStrings' + 'ApplicationsStrings', + '$stateParams', + 'GetBasePath' ]; export default ListApplicationsUsersController; diff --git a/awx/ui/client/features/applications/list-applications-users.view.html b/awx/ui/client/features/applications/list-applications-users.view.html index 3acb66242d..f3e9375782 100644 --- a/awx/ui/client/features/applications/list-applications-users.view.html +++ b/awx/ui/client/features/applications/list-applications-users.view.html @@ -2,7 +2,7 @@ Date: Wed, 14 Mar 2018 16:00:40 -0400 Subject: [PATCH 006/762] AWX launchers should wait for other containers to be ready --- installer/roles/image_build/files/launch_awx.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/installer/roles/image_build/files/launch_awx.sh b/installer/roles/image_build/files/launch_awx.sh index 58a142aafa..071da9c0bf 100755 --- a/installer/roles/image_build/files/launch_awx.sh +++ b/installer/roles/image_build/files/launch_awx.sh @@ -4,5 +4,11 @@ if [ `id -u` -ge 500 ]; then cat /tmp/passwd > /etc/passwd rm /tmp/passwd fi + +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$DATABASE_HOST port=$DATABASE_PORT" all +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$MEMCACHED_HOST port=11211" all +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$RABBITMQ_HOST port=5672" all +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m postgresql_db -U $DATABASE_USER -a "name=$DATABASE_NAME owner=$DATABASE_USER login_user=$DATABASE_USER login_host=$DATABASE_HOST login_password=$DATABASE_PASSWORD port=$DATABASE_PORT" all + awx-manage collectstatic --noinput --clear supervisord -c /supervisor.conf From b4bee93b3533f4edbfeb308b451c1d3ca77e0d26 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 17 May 2018 12:24:57 -0400 Subject: [PATCH 007/762] Fix position of CTiT logger test dialog --- awx/ui/client/src/configuration/configuration.block.less | 3 ++- awx/ui/client/src/notifications/notifications.block.less | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/awx/ui/client/src/configuration/configuration.block.less b/awx/ui/client/src/configuration/configuration.block.less index 693fd9a9d5..8b10cf6f87 100644 --- a/awx/ui/client/src/configuration/configuration.block.less +++ b/awx/ui/client/src/configuration/configuration.block.less @@ -179,7 +179,8 @@ input#filePickerText { } .LogAggregator-failedNotification{ - max-width: 300px; + max-width: 500px; + word-break: break-word; } hr { diff --git a/awx/ui/client/src/notifications/notifications.block.less b/awx/ui/client/src/notifications/notifications.block.less index 73d0dd6856..7860fdb58f 100644 --- a/awx/ui/client/src/notifications/notifications.block.less +++ b/awx/ui/client/src/notifications/notifications.block.less @@ -35,11 +35,12 @@ } .ng-toast { + left: 0; + .alert { margin: 0; - } - .alert span:first-of-type { - margin-right: 10px; + position: relative; + text-align: left; } .close { From 85caf6253ce48057e24625e580afb26d07f5d3e6 Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Wed, 16 May 2018 17:35:32 -0700 Subject: [PATCH 008/762] Fixes a portal mode breadcrumb and capitalizes the instance groups breadcrumb --- awx/ui/client/features/jobs/routes/portalModeAllJobs.route.js | 3 +++ awx/ui/client/lib/components/components.strings.js | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/features/jobs/routes/portalModeAllJobs.route.js b/awx/ui/client/features/jobs/routes/portalModeAllJobs.route.js index 9595da3547..cada35d56b 100644 --- a/awx/ui/client/features/jobs/routes/portalModeAllJobs.route.js +++ b/awx/ui/client/features/jobs/routes/portalModeAllJobs.route.js @@ -5,6 +5,9 @@ const jobsListTemplate = require('~features/jobs/jobsList.view.html'); export default { name: 'portalMode.allJobs', url: '/alljobs?{job_search:queryset}', + ncyBreadcrumb: { + skip: true + }, params: { job_search: { value: { diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index a53da0676f..072e1e82ce 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -72,7 +72,7 @@ function ComponentsStrings (BaseString) { NOTIFICATIONS: t.s('Notifications'), MANAGEMENT_JOBS: t.s('Management Jobs'), INSTANCES: t.s('Instances'), - INSTANCE_GROUPS: t.s('Instance Groups'), + INSTANCE_GROUPS: t.s('INSTANCE GROUPS'), APPLICATIONS: t.s('Applications'), SETTINGS: t.s('Settings'), FOOTER_ABOUT: t.s('About'), From db6cc7c50b1d775eeaf9bcf1660a3da99858057a Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 11 May 2018 09:05:25 -0400 Subject: [PATCH 009/762] Add exception to allow relaunching callback jobs allows for execute_role level users to directly relaunch callback-type jobs, even though limit has changed from JT, it is a down-selection --- awx/main/access.py | 2 +- awx/main/models/jobs.py | 2 ++ awx/main/models/unified_jobs.py | 12 +++++++----- awx/main/tests/functional/api/test_job.py | 2 +- awx/main/tests/functional/test_rbac_job.py | 11 +++++++++++ 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 67bb01905d..47534928bd 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1507,7 +1507,7 @@ class JobAccess(BaseAccess): elif not jt_access: return False - org_access = obj.inventory and self.user in obj.inventory.organization.inventory_admin_role + org_access = bool(obj.inventory) and self.user in obj.inventory.organization.inventory_admin_role project_access = obj.project is None or self.user in obj.project.admin_role credential_access = all([self.user in cred.use_role for cred in obj.credentials.all()]) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 80280636cc..dd05e2359a 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -975,6 +975,8 @@ class JobLaunchConfig(LaunchTimeConfig): return True for field_name, ask_field_name in ask_mapping.items(): if field_name in prompts and not getattr(template, ask_field_name): + if field_name == 'limit' and self.job and self.job.launch_type == 'callback': + continue # exception for relaunching callbacks return True else: return False diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index f758a230a9..fd3d6f4082 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -814,7 +814,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique # Done. return result - def copy_unified_job(self, limit=None): + def copy_unified_job(self, _eager_fields=None, **new_prompts): ''' Returns saved object, including related fields. Create a copy of this unified job for the purpose of relaunch @@ -824,12 +824,14 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique parent_field_name = unified_job_class._get_parent_field_name() fields = unified_jt_class._get_unified_job_field_names() | set([parent_field_name]) - create_data = {"launch_type": "relaunch"} - if limit: - create_data["limit"] = limit + create_data = {} + if _eager_fields: + create_data = _eager_fields.copy() + create_data["launch_type"] = "relaunch" prompts = self.launch_prompts() - if self.unified_job_template and prompts: + if self.unified_job_template and (prompts is not None): + prompts.update(new_prompts) prompts['_eager_fields'] = create_data unified_job = self.unified_job_template.create_unified_job(**prompts) else: diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index db5da93c2f..0e939269b6 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -75,7 +75,7 @@ def test_job_relaunch_on_failed_hosts(post, inventory, project, machine_credenti project=project ) jt.credentials.add(machine_credential) - job = jt.create_unified_job(_eager_fields={'status': 'failed', 'limit': 'host1,host2,host3'}) + job = jt.create_unified_job(_eager_fields={'status': 'failed'}, limit='host1,host2,host3') job.job_events.create(event='playbook_on_stats') job.job_host_summaries.create(host=h1, failed=False, ok=1, changed=0, failures=0, host_name=h1.name) job.job_host_summaries.create(host=h2, failed=False, ok=0, changed=1, failures=0, host_name=h2.name) diff --git a/awx/main/tests/functional/test_rbac_job.py b/awx/main/tests/functional/test_rbac_job.py index 55785f9c73..9c4b2b100a 100644 --- a/awx/main/tests/functional/test_rbac_job.py +++ b/awx/main/tests/functional/test_rbac_job.py @@ -223,6 +223,17 @@ class TestJobRelaunchAccess: job.credentials.add(net_credential) assert not rando.can_access(Job, 'start', job, validate_license=False) + @pytest.mark.job_runtime_vars + def test_callback_relaunchable_by_user(self, job_template, rando): + job = job_template.create_unified_job( + _eager_fields={'launch_type': 'callback'}, + limit='host2' + ) + assert 'limit' in job.launch_config.prompts_dict() # sanity assertion + job_template.execute_role.members.add(rando) + can_access, messages = rando.can_access_with_errors(Job, 'start', job, validate_license=False) + assert can_access, messages + @pytest.mark.django_db class TestJobAndUpdateCancels: From 02417dc668fb42c26798b3086190d7cf9e400bd7 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Thu, 17 May 2018 15:46:06 -0400 Subject: [PATCH 010/762] Fix position in Firefox --- awx/ui/client/src/notifications/notifications.block.less | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/ui/client/src/notifications/notifications.block.less b/awx/ui/client/src/notifications/notifications.block.less index 7860fdb58f..465d7b767b 100644 --- a/awx/ui/client/src/notifications/notifications.block.less +++ b/awx/ui/client/src/notifications/notifications.block.less @@ -41,6 +41,8 @@ margin: 0; position: relative; text-align: left; + width: 100%; + word-wrap: break-word; } .close { From 967624c5765cc963d6d065ef8baab8c92e040b3b Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 17 May 2018 14:41:19 -0400 Subject: [PATCH 011/762] fix schedules modified_by getting nulled --- awx/main/models/base.py | 40 ++++++++++++++++--- awx/main/models/projects.py | 22 +++------- .../tests/functional/models/test_project.py | 8 ++++ .../tests/functional/models/test_schedule.py | 18 +++++++++ 4 files changed, 65 insertions(+), 23 deletions(-) diff --git a/awx/main/models/base.py b/awx/main/models/base.py index fcca82474c..709e603967 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -254,9 +254,13 @@ class PrimordialModel(CreatedModifiedModel): tags = TaggableManager(blank=True) + def __init__(self, *args, **kwargs): + r = super(PrimordialModel, self).__init__(*args, **kwargs) + self._prior_values_store = self._get_fields_snapshot() + return r + def save(self, *args, **kwargs): update_fields = kwargs.get('update_fields', []) - fields_are_specified = bool(update_fields) user = get_current_user() if user and not user.id: user = None @@ -264,15 +268,39 @@ class PrimordialModel(CreatedModifiedModel): self.created_by = user if 'created_by' not in update_fields: update_fields.append('created_by') - # Update modified_by if not called with update_fields, or if any - # editable fields are present in update_fields - if ( - (not fields_are_specified) or - any(getattr(self._meta.get_field(name), 'editable', True) for name in update_fields)): + # Update modified_by if any editable fields have changed + new_values = self._get_fields_snapshot() + if (not self.pk and not self.modified_by) or self.has_user_edits(new_values): self.modified_by = user if 'modified_by' not in update_fields: update_fields.append('modified_by') super(PrimordialModel, self).save(*args, **kwargs) + self._prior_values_store = new_values + + def has_user_edits(self, new_values): + return any( + new_values.get(fd_name, None) != self._prior_values_store.get(fd_name, None) + for fd_name in new_values.keys() + ) + + @classmethod + def _get_editable_fields(cls): + fds = set([]) + for field in cls._meta.concrete_fields: + if hasattr(field, 'attname'): + if field.attname == 'id': + continue + if getattr(field, 'editable', True): + fds.add(field.attname) + return fds + + def _get_fields_snapshot(self): + new_values = {} + editable_set = self._get_editable_fields() + for attr, val in self.__dict__.items(): + if attr in editable_set: + new_values[attr] = val + return new_values def clean_description(self): # Description should always be empty string, never null. diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 3bad19c8eb..dce47eb8dd 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -324,13 +324,9 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn ['name', 'description', 'schedule'] ) - def __init__(self, *args, **kwargs): - r = super(Project, self).__init__(*args, **kwargs) - self._prior_values_store = self._current_sensitive_fields() - return r - def save(self, *args, **kwargs): new_instance = not bool(self.pk) + pre_save_vals = getattr(self, '_prior_values_store', {}) # If update_fields has been specified, add our field names to it, # if it hasn't been specified, then we're just doing a normal save. update_fields = kwargs.get('update_fields', []) @@ -361,21 +357,13 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn self.save(update_fields=update_fields) # If we just created a new project with SCM, start the initial update. # also update if certain fields have changed - relevant_change = False - new_values = self._current_sensitive_fields() - if hasattr(self, '_prior_values_store') and self._prior_values_store != new_values: - relevant_change = True - self._prior_values_store = new_values + relevant_change = any( + pre_save_vals.get(fd_name, None) != self._prior_values_store.get(fd_name, None) + for fd_name in self.FIELDS_TRIGGER_UPDATE + ) if (relevant_change or new_instance) and (not skip_update) and self.scm_type: self.update() - def _current_sensitive_fields(self): - new_values = {} - for attr, val in self.__dict__.items(): - if attr in Project.FIELDS_TRIGGER_UPDATE: - new_values[attr] = val - return new_values - def _get_current_status(self): if self.scm_type: if self.current_job and self.current_job.status: diff --git a/awx/main/tests/functional/models/test_project.py b/awx/main/tests/functional/models/test_project.py index 71352ed633..f150dbe00a 100644 --- a/awx/main/tests/functional/models/test_project.py +++ b/awx/main/tests/functional/models/test_project.py @@ -2,6 +2,7 @@ import pytest import mock from awx.main.models import Project +from awx.main.models.organization import Organization @pytest.mark.django_db @@ -31,3 +32,10 @@ def test_sensitive_change_triggers_update(project): project.scm_url = 'https://foo2.invalid' project.save() mock_update.assert_called_once_with() + + +@pytest.mark.django_db +def test_foreign_key_change_changes_modified_by(project, organization): + assert project._get_fields_snapshot()['organization_id'] == organization.id + project.organization = Organization(name='foo', pk=41) + assert project._get_fields_snapshot()['organization_id'] == 41 diff --git a/awx/main/tests/functional/models/test_schedule.py b/awx/main/tests/functional/models/test_schedule.py index d18e848d97..0921971c47 100644 --- a/awx/main/tests/functional/models/test_schedule.py +++ b/awx/main/tests/functional/models/test_schedule.py @@ -7,6 +7,8 @@ import pytz from awx.main.models import JobTemplate, Schedule +from crum import impersonate + @pytest.fixture def job_template(inventory, project): @@ -18,6 +20,22 @@ def job_template(inventory, project): ) +@pytest.mark.django_db +def test_computed_fields_modified_by_retained(job_template, admin_user): + with impersonate(admin_user): + s = Schedule.objects.create( + name='Some Schedule', + rrule='DTSTART:20300112T210000Z RRULE:FREQ=DAILY;INTERVAL=1', + unified_job_template=job_template + ) + s.refresh_from_db() + assert s.created_by == admin_user + assert s.modified_by == admin_user + s.update_computed_fields() + s.save() + assert s.modified_by == admin_user + + @pytest.mark.django_db def test_repeats_forever(job_template): s = Schedule( From 41fe9e1caf0d171f2d2da9c5be84332654a2c2e1 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 18 May 2018 12:46:54 -0400 Subject: [PATCH 012/762] Check if the project update for the project we are trying to lock is canceled --- awx/main/tasks.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 68dd21e804..79cc93bc51 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -5,6 +5,7 @@ from collections import OrderedDict, namedtuple import ConfigParser import cStringIO +import errno import functools import importlib import json @@ -1668,18 +1669,28 @@ class RunProjectUpdate(BaseTask): logger.error(six.text_type("I/O error({0}) while trying to open lock file [{1}]: {2}").format(e.errno, lock_path, e.strerror)) raise - try: - start_time = time.time() - fcntl.flock(self.lock_fd, fcntl.LOCK_EX) - waiting_time = time.time() - start_time - if waiting_time > 1.0: - logger.info(six.text_type( - '{} spent {} waiting to acquire lock for local source tree ' - 'for path {}.').format(instance.log_format, waiting_time, lock_path)) - except IOError as e: - os.close(self.lock_fd) - logger.error(six.text_type("I/O error({0}) while trying to aquire lock on file [{1}]: {2}").format(e.errno, lock_path, e.strerror)) - raise + start_time = time.time() + while True: + try: + instance.refresh_from_db() + if instance.cancel_flag: + logger.info(six.text_type("ProjectUpdate({0}) was cancelled".format(instance.pk))) + return + fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except IOError as e: + if e.errno != errno.EAGAIN: + os.close(self.lock_fd) + logger.error(six.text_type("I/O error({0}) while trying to aquire lock on file [{1}]: {2}").format(e.errno, lock_path, e.strerror)) + raise + else: + time.sleep(1.0) + waiting_time = time.time() - start_time + + if waiting_time > 1.0: + logger.info(six.text_type( + '{} spent {} waiting to acquire lock for local source tree ' + 'for path {}.').format(instance.log_format, waiting_time, lock_path)) def pre_run_hook(self, instance, **kwargs): # re-create root project folder if a natural disaster has destroyed it From 5279b102cba874cb533d1bf19a8362b31dceb401 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 18 May 2018 13:52:51 -0400 Subject: [PATCH 013/762] Fix task unit test --- awx/main/tests/unit/test_tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index c4e3abe9b9..bc403bd015 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -2171,6 +2171,7 @@ def test_aquire_lock_acquisition_fail_logged(fcntl_flock, logging_getLogger, os_ instance = mock.Mock() instance.get_lock_file.return_value = 'this_file_does_not_exist' + instance.cancel_flag = False os_open.return_value = 3 @@ -2180,7 +2181,6 @@ def test_aquire_lock_acquisition_fail_logged(fcntl_flock, logging_getLogger, os_ fcntl_flock.side_effect = err ProjectUpdate = tasks.RunProjectUpdate() - with pytest.raises(IOError, message='dummy message'): ProjectUpdate.acquire_lock(instance) os_close.assert_called_with(3) From 969fb21e982e68b7778472f0183e8142caceb92b Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 17 May 2018 12:46:40 -0400 Subject: [PATCH 014/762] restrict network_ui to inv admins --- awx/network_ui/consumers.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/awx/network_ui/consumers.py b/awx/network_ui/consumers.py index 9cf8c72982..bd5dd90994 100644 --- a/awx/network_ui/consumers.py +++ b/awx/network_ui/consumers.py @@ -3,6 +3,7 @@ import channels from channels.auth import channel_session_user, channel_session_user_from_http from awx.network_ui.models import Topology, Device, Link, Client, Interface from awx.network_ui.models import TopologyInventory +from awx.main.models.inventory import Inventory import urlparse from django.db.models import Q from collections import defaultdict @@ -217,6 +218,18 @@ def ws_connect(message): data = urlparse.parse_qs(message.content['query_string']) inventory_id = parse_inventory_id(data) + try: + inventory = Inventory.objects.get(id=inventory_id) + except Inventory.DoesNotExist: + logger.error("User {} attempted connecting inventory_id {} that does not exist.".format( + message.user.id, inventory_id) + ) + message.reply_channel.send({"close": True}) + if message.user not in inventory.admin_role: + logger.warn("User {} attempted connecting to inventory_id {} without permission.".format( + message.user.id, inventory_id + )) + message.reply_channel.send({"close": True}) topology_ids = list(TopologyInventory.objects.filter(inventory_id=inventory_id).values_list('pk', flat=True)) topology_id = None if len(topology_ids) > 0: From 222fbfc328181e91d4ca19b83d9d7072f87a88b6 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 18 May 2018 14:16:59 -0400 Subject: [PATCH 015/762] add help text, deprecate field --- awx/api/serializers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 8e8b9930c5..28432c9ab0 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1407,6 +1407,9 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): 'admin', 'update', {'copy': 'organization.project_admin'} ] + scm_delete_on_next_update = serializers.BooleanField( + read_only=True, + help_text=_('This field has been deprecated and will be removed in a future release')) class Meta: model = Project From 9fe44cfaae0be7ee78987683b59deb3a38fff6a4 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 18 May 2018 14:00:33 -0400 Subject: [PATCH 016/762] check EACCES and only refresh cancel_flag --- awx/main/tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 79cc93bc51..ad22a7d163 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1672,14 +1672,14 @@ class RunProjectUpdate(BaseTask): start_time = time.time() while True: try: - instance.refresh_from_db() + instance.refresh_from_db(fields=['cancel_flag']) if instance.cancel_flag: logger.info(six.text_type("ProjectUpdate({0}) was cancelled".format(instance.pk))) return fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) break except IOError as e: - if e.errno != errno.EAGAIN: + if e.errno not in (errno.EAGAIN, errno.EACCES): os.close(self.lock_fd) logger.error(six.text_type("I/O error({0}) while trying to aquire lock on file [{1}]: {2}").format(e.errno, lock_path, e.strerror)) raise From 4abac4441168ef6c9081e63462565c8ea9b068c1 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 18 May 2018 14:38:08 -0400 Subject: [PATCH 017/762] prevent unicode in instance hostnames and instance group names see: https://github.com/ansible/tower/issues/1721 --- awx/main/models/ha.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 471276c560..42e8117061 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -3,6 +3,7 @@ from decimal import Decimal +from django.core.exceptions import ValidationError from django.db import models, connection from django.db.models.signals import post_save, post_delete from django.dispatch import receiver @@ -10,12 +11,14 @@ from django.utils.translation import ugettext_lazy as _ from django.conf import settings from django.utils.timezone import now, timedelta +import six from solo.models import SingletonModel from awx import __version__ as awx_application_version from awx.api.versioning import reverse from awx.main.managers import InstanceManager, InstanceGroupManager from awx.main.fields import JSONField +from awx.main.models.base import BaseModel from awx.main.models.inventory import InventoryUpdate from awx.main.models.jobs import Job from awx.main.models.projects import ProjectUpdate @@ -26,7 +29,16 @@ from awx.main.models.mixins import RelatedJobsMixin __all__ = ('Instance', 'InstanceGroup', 'JobOrigin', 'TowerScheduleState',) -class Instance(models.Model): +def validate_queuename(v): + # celery and kombu don't play nice with unicode in queue names + if v: + try: + '{}'.format(v.decode('utf-8')) + except UnicodeEncodeError: + raise ValidationError(_(six.text_type('{} contains unsupported characters')).format(v)) + + +class Instance(BaseModel): """A model representing an AWX instance running against this database.""" objects = InstanceManager() @@ -113,9 +125,13 @@ class Instance(models.Model): self.save(update_fields=['capacity', 'version', 'modified', 'cpu', 'memory', 'cpu_capacity', 'mem_capacity']) + def clean_hostname(self): + validate_queuename(self.hostname) + return self.hostname + -class InstanceGroup(models.Model, RelatedJobsMixin): +class InstanceGroup(BaseModel, RelatedJobsMixin): """A model representing a Queue/Group of AWX Instances.""" objects = InstanceGroupManager() @@ -167,6 +183,10 @@ class InstanceGroup(models.Model, RelatedJobsMixin): class Meta: app_label = 'main' + def clean_name(self): + validate_queuename(self.name) + return self.name + class TowerScheduleState(SingletonModel): schedule_last_run = models.DateTimeField(auto_now_add=True) From 7610c660cb8171c4bd04d426289432dfe77f544b Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Fri, 18 May 2018 15:03:44 -0700 Subject: [PATCH 018/762] Fixes the CTiT save errors. I added a toast message for successful saves too. --- awx/ui/client/legacy/styles/ansible-ui.less | 4 ++ .../configuration-auth.controller.js | 3 + .../configuration/configuration.block.less | 5 ++ .../configuration/configuration.controller.js | 63 ++++++++++++------- .../configuration/configuration.service.js | 4 +- awx/ui/client/src/shared/Utilities.js | 7 ++- 6 files changed, 59 insertions(+), 27 deletions(-) diff --git a/awx/ui/client/legacy/styles/ansible-ui.less b/awx/ui/client/legacy/styles/ansible-ui.less index 6349dfbd61..1717e2a7a6 100644 --- a/awx/ui/client/legacy/styles/ansible-ui.less +++ b/awx/ui/client/legacy/styles/ansible-ui.less @@ -1256,6 +1256,10 @@ input[type="checkbox"].checkbox-no-label { color:@red; } + .error-border { + border-color:@red; + } + .connecting-color { color: @warning; } diff --git a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js index 66d2f8818d..75235a3984 100644 --- a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js +++ b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js @@ -110,6 +110,9 @@ export default [ authVm.activeAuthForm = getActiveAuthForm(); formTracker.setCurrentAuth(authVm.activeAuthForm); $('#FormModal-dialog').dialog('close'); + }).catch(() => { + event.preventDefault(); + $('#FormModal-dialog').dialog('close'); }); }, "class": "btn btn-primary", diff --git a/awx/ui/client/src/configuration/configuration.block.less b/awx/ui/client/src/configuration/configuration.block.less index 8b10cf6f87..8d85b6bcea 100644 --- a/awx/ui/client/src/configuration/configuration.block.less +++ b/awx/ui/client/src/configuration/configuration.block.less @@ -186,3 +186,8 @@ input#filePickerText { hr { height: 1px; } + +.ConfigureTower-errorIcon{ + margin-right:5px; + color:@red; +} diff --git a/awx/ui/client/src/configuration/configuration.controller.js b/awx/ui/client/src/configuration/configuration.controller.js index 0f65097f71..f34aecb70b 100644 --- a/awx/ui/client/src/configuration/configuration.controller.js +++ b/awx/ui/client/src/configuration/configuration.controller.js @@ -8,7 +8,7 @@ import defaultStrings from '~assets/default.strings.json'; export default [ '$scope', '$rootScope', '$state', '$stateParams', '$timeout', '$q', 'Alert', 'ConfigurationService', 'ConfigurationUtils', 'CreateDialog', 'CreateSelect2', 'i18n', 'ParseTypeChange', 'ProcessErrors', 'Store', - 'Wait', 'configDataResolve', 'ToJSON', 'ConfigService', + 'Wait', 'configDataResolve', 'ToJSON', 'ConfigService', 'ngToast', //Form definitions 'configurationAzureForm', 'configurationGithubForm', @@ -32,7 +32,7 @@ export default [ function( $scope, $rootScope, $state, $stateParams, $timeout, $q, Alert, ConfigurationService, ConfigurationUtils, CreateDialog, CreateSelect2, i18n, ParseTypeChange, ProcessErrors, Store, - Wait, configDataResolve, ToJSON, ConfigService, + Wait, configDataResolve, ToJSON, ConfigService, ngToast, //Form definitions configurationAzureForm, configurationGithubForm, @@ -241,10 +241,15 @@ export default [ }, { label: i18n._("Save changes"), onClick: function() { - vm.formSave(); - $scope[formTracker.currentFormName()].$setPristine(); - $('#FormModal-dialog').dialog('close'); - active(setForm); + vm.formSave().then(() => { + $scope[formTracker.currentFormName()].$setPristine(); + $('#FormModal-dialog').dialog('close'); + active(setForm); + }).catch(()=> { + event.preventDefault(); + $('#FormModal-dialog').dialog('close'); + }); + }, "class": "btn btn-primary", "id": "formmodal-save-button" @@ -396,11 +401,12 @@ export default [ } loginUpdate(); }) - .catch(function(error) { - ProcessErrors($scope, error, status, formDefs[formTracker.getCurrent()], + .catch(function(data) { + ProcessErrors($scope, data.error, data.status, formDefs[formTracker.getCurrent()], { - hdr: i18n._('Error!'), - msg: i18n._('There was an error resetting value. Returned status: ') + error.detail + hdr: ` + ${ i18n._('Error!')} `, + msg: i18n._('There was an error resetting value. Returned status: ') + data.error.detail }); }) @@ -495,14 +501,23 @@ export default [ saveDeferred.resolve(data); $scope[formTracker.currentFormName()].$setPristine(); + ngToast.success({ + timeout: 2000, + dismissButton: false, + dismissOnTimeout: true, + content: `` + + i18n._('Save Complete') + }); }) - .catch(function(error, status) { - ProcessErrors($scope, error, status, formDefs[formTracker.getCurrent()], + .catch(function(data) { + ProcessErrors($scope, data.data, data.status, formDefs[formTracker.getCurrent()], { - hdr: i18n._('Error!'), - msg: i18n._('Failed to save settings. Returned status: ') + status + hdr: ` + ${ i18n._('Error!')} `, + msg: i18n._('Failed to save settings. Returned status: ') + data.status }); - saveDeferred.reject(error); + saveDeferred.reject(data); }) .finally(function() { Wait('stop'); @@ -528,13 +543,14 @@ export default [ .then(function() { //TODO consider updating form values with returned data here }) - .catch(function(error, status) { + .catch(function(data) { //Change back on unsuccessful update $scope[key] = !$scope[key]; - ProcessErrors($scope, error, status, formDefs[formTracker.getCurrent()], + ProcessErrors($scope, data.data, data.status, formDefs[formTracker.getCurrent()], { - hdr: i18n._('Error!'), - msg: i18n._('Failed to save toggle settings. Returned status: ') + error.detail + hdr: ` + ${ i18n._('Error!')} `, + msg: i18n._('Failed to save toggle settings. Returned status: ') + data.status }); }) .finally(function() { @@ -577,11 +593,12 @@ export default [ }); }) - .catch(function(error) { - ProcessErrors($scope, error, status, formDefs[formTracker.getCurrent()], + .catch(function(data) { + ProcessErrors($scope, data.error, data.status, formDefs[formTracker.getCurrent()], { - hdr: i18n._('Error!'), - msg: i18n._('There was an error resetting values. Returned status: ') + error.detail + hdr: ` + ${ i18n._('Error!')} `, + msg: i18n._('There was an error resetting values. Returned status: ') + data.error.detail }); }) .finally(function() { diff --git a/awx/ui/client/src/configuration/configuration.service.js b/awx/ui/client/src/configuration/configuration.service.js index bd25d7179c..1d437dc2cd 100644 --- a/awx/ui/client/src/configuration/configuration.service.js +++ b/awx/ui/client/src/configuration/configuration.service.js @@ -50,7 +50,7 @@ export default ['$rootScope', 'GetBasePath', 'ProcessErrors', '$q', '$http', 'Re .then(({data}) => { deferred.resolve(data); }) - .catch(({error}) => { + .catch((error) => { deferred.reject(error); }); @@ -64,7 +64,7 @@ export default ['$rootScope', 'GetBasePath', 'ProcessErrors', '$q', '$http', 'Re .then(({data}) => { deferred.resolve(data); }) - .catch(({error}) => { + .catch((error) => { deferred.reject(error); }); diff --git a/awx/ui/client/src/shared/Utilities.js b/awx/ui/client/src/shared/Utilities.js index 301a7eae9d..b0f273c320 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -209,14 +209,17 @@ angular.module('Utilities', ['RestServices', 'Utilities']) } else { if (data[field]) { scope[field + '_api_error'] = data[field][0]; - //scope[form.name + '_form'][field].$setValidity('apiError', false); $('[name="' + field + '"]').addClass('ng-invalid'); + $('label[for="' + field + '"] span').addClass('error-color'); $('html, body').animate({scrollTop: $('[name="' + field + '"]').offset().top}, 0); fieldErrors = true; + if(form.fields[field].codeMirror){ + $(`#cm-${field}-container .CodeMirror`).addClass('error-border'); + } } } } - if ((!fieldErrors) && defaultMsg) { + if (defaultMsg) { Alert(defaultMsg.hdr, defaultMsg.msg); } } else if (typeof data === 'object' && data !== null) { From 2cbadcd392f48e843fe8c759750d4f5e954e6c3f Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 21 May 2018 11:25:47 -0400 Subject: [PATCH 019/762] make prior output viewable for partial job runs --- .../features/output/api.events.service.js | 28 ++++-- .../client/features/output/engine.service.js | 10 +- .../features/output/index.controller.js | 99 +++++++++++++------ awx/ui/client/features/output/page.service.js | 2 +- .../client/features/output/status.service.js | 10 +- 5 files changed, 104 insertions(+), 45 deletions(-) diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index 39499eff46..ecf247633c 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -29,7 +29,7 @@ function JobEventsApiService ($http, $q) { this.getLastPage = count => Math.ceil(count / this.state.params.page_size); - this.fetch = () => { + this.clearCache = () => { delete this.cache; delete this.keys; delete this.pageSizes; @@ -37,10 +37,10 @@ function JobEventsApiService ($http, $q) { this.cache = {}; this.keys = []; this.pageSizes = {}; - - return this.getPage(1).then(() => this); }; + this.fetch = () => this.first().then(() => this); + this.getPage = number => { if (number < 1 || number > this.state.last) { return $q.resolve(); @@ -79,11 +79,18 @@ function JobEventsApiService ($http, $q) { return { results, page: number }; }); + if (number === 1) { + this.clearCache(); + } + this.cache[number] = promise; this.keys.push(number); if (this.keys.length > PAGE_LIMIT) { - delete this.cache[this.keys.shift()]; + const remove = this.keys.shift(); + + delete this.cache[remove]; + delete this.pageSizes[remove]; } return promise; @@ -107,17 +114,22 @@ function JobEventsApiService ($http, $q) { const { results, count } = data; const lastPage = this.getLastPage(count); - results.reverse(); - const shifted = results.splice(count % PAGE_SIZE); + if (count > PAGE_SIZE) { + results.splice(count % PAGE_SIZE); + } - this.state.results = shifted; + results.reverse(); + + this.state.results = results; this.state.count = count; this.state.page = lastPage; this.state.next = lastPage; this.state.last = lastPage; this.state.previous = Math.max(1, this.state.page - 1); - return { results: shifted, page: lastPage }; + this.clearCache(); + + return { results, page: lastPage }; }); return promise; diff --git a/awx/ui/client/features/output/engine.service.js b/awx/ui/client/features/output/engine.service.js index 2e6371520f..2d08b4ed6e 100644 --- a/awx/ui/client/features/output/engine.service.js +++ b/awx/ui/client/features/output/engine.service.js @@ -73,9 +73,8 @@ function JobEventEngine ($q) { this.buffer = data => { const pageAdded = this.page.addToBuffer(data); - this.pageCount++; - if (pageAdded) { + this.pageCount++; this.setBatchFrameCount(); if (this.isPausing()) { @@ -117,6 +116,9 @@ function JobEventEngine ($q) { this.chain = this.chain .then(() => { if (!this.isActive()) { + if (data.start_line < (this.lines.min)) { + return $q.resolve(); + } this.start(); } else if (data.event === JOB_END) { if (this.isPaused()) { @@ -146,6 +148,10 @@ function JobEventEngine ($q) { this.renderFrame = events => this.hooks.onEventFrame(events) .then(() => { + if (this.scroll.isLocked()) { + this.scroll.scrollToBottom(); + } + if (this.isEnding()) { const lastEvents = this.page.emptyBuffer(); diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 935d0a7c79..9663749557 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -50,8 +50,8 @@ function JobsIndexController ( // Stdout Navigation vm.scroll = { showBackToTop: false, - home: scrollHome, - end: scrollEnd, + home: scrollFirst, + end: scrollLast, down: scrollPageDown, up: scrollPageUp }; @@ -97,7 +97,14 @@ function init () { }); streaming = false; - return next().then(() => startListening()); + + if (status.state.running) { + return scrollLast().then(() => startListening()); + } else if (!status.state.finished) { + return scrollFirst().then(() => startListening()); + } + + return scrollLast(); } function stopListening () { @@ -117,30 +124,13 @@ function handleStatusEvent (data) { function handleJobEvent (data) { streaming = streaming || attachToRunningJob(); + streaming.then(() => { engine.pushJobEvent(data); status.pushJobEvent(data); }); } -function attachToRunningJob () { - if (!status.state.running) { - return $q.resolve(); - } - - return page.last() - .then(events => { - if (!events) { - return $q.resolve(); - } - - const minLine = 1 + Math.max(...events.map(event => event.end_line)); - - return render.clear() - .then(() => engine.setMinLine(minLine)); - }); -} - function next () { return page.next() .then(events => { @@ -217,8 +207,16 @@ function shift () { return render.shift(lines); } -function scrollHome () { - if (scroll.isPaused()) { +function scrollFirst () { + if (engine.isActive()) { + if (engine.isTransitioning()) { + return $q.resolve(); + } + + if (!engine.isPaused()) { + engine.pause(true); + } + } else if (scroll.isPaused()) { return $q.resolve(); } @@ -246,19 +244,57 @@ function scrollHome () { }); } -function scrollEnd () { +function scrollLast () { if (engine.isActive()) { if (engine.isTransitioning()) { return $q.resolve(); } if (engine.isPaused()) { - engine.resume(); - } else { - engine.pause(); + engine.resume(true); + } + } else if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + return render.clear() + .then(() => page.last()) + .then(events => { + if (!events) { + return $q.resolve(); + } + + const minLine = 1 + Math.max(...events.map(event => event.end_line)); + engine.setMinLine(minLine); + + return append(events); + }) + .then(() => { + if (!engine.isActive()) { + scroll.resume(); + } + scroll.setScrollPosition(scroll.getScrollHeight()); + }) + .then(() => { + if (!engine.isActive() && scroll.isMissing()) { + return previous(); + } + + return $q.resolve(); + }); +} + +function attachToRunningJob () { + if (engine.isActive()) { + if (engine.isTransitioning()) { + return $q.resolve(); } - return $q.resolve(); + if (engine.isPaused()) { + engine.resume(true); + } } else if (scroll.isPaused()) { return $q.resolve(); } @@ -271,12 +307,13 @@ function scrollEnd () { return $q.resolve(); } - return render.clear() - .then(() => append(events)); + const minLine = 1 + Math.max(...events.map(event => event.end_line)); + engine.setMinLine(minLine); + + return append(events); }) .then(() => { scroll.setScrollPosition(scroll.getScrollHeight()); - scroll.resume(); }); } diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js index 854bda23b0..b8d9f96fb2 100644 --- a/awx/ui/client/features/output/page.service.js +++ b/awx/ui/client/features/output/page.service.js @@ -17,7 +17,7 @@ function JobPageService ($q) { this.bookmark = { pending: false, - set: false, + set: true, cache: [], state: { count: 0, diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index 29558c70df..704d87ed18 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -4,7 +4,9 @@ const PLAY_START = 'playbook_on_play_start'; const TASK_START = 'playbook_on_task_start'; const HOST_STATUS_KEYS = ['dark', 'failures', 'changed', 'ok', 'skipped']; -const FINISHED = ['successful', 'failed', 'error']; +const COMPLETE = ['successful', 'failed']; +const INCOMPLETE = ['canceled', 'error']; +const FINISHED = COMPLETE.concat(INCOMPLETE); function JobStatusService (moment, message) { this.dispatch = () => message.dispatch('status', this.state); @@ -105,8 +107,10 @@ function JobStatusService (moment, message) { } }; - this.isExpectingStatsEvent = () => (this.jobType === 'job') || - (this.jobType === 'project_update'); + this.isExpectingStatsEvent = () => ( + (this.jobType === 'job') || + (this.jobType === 'project_update')) && + (!_.includes(INCOMPLETE, this.state.status)); this.updateStats = () => { this.updateHostCounts(); From d493a4e0d09b7d1a9a5f5306dd89697056bac9a3 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 21 May 2018 12:19:12 -0400 Subject: [PATCH 020/762] revert expect(use_poll=True) due to a bug that causes `os.read()` hangs There's a bug in the implementation in pexpect: pexpect/pexpect#491 Issues that could be related to this: ansible/awx#1870 ansible/awx#1840 Rolling this back will cause #861 to no longer be "fixed". We'll have to solve it another way, or wait for pexpect to fix the poll bug. --- awx/main/expect/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/expect/run.py b/awx/main/expect/run.py index 0c8881a85c..ed26464c8e 100755 --- a/awx/main/expect/run.py +++ b/awx/main/expect/run.py @@ -101,7 +101,7 @@ def run_pexpect(args, cwd, env, logfile, child = pexpect.spawn( args[0], args[1:], cwd=cwd, env=env, ignore_sighup=True, - encoding='utf-8', echo=False, use_poll=True + encoding='utf-8', echo=False ) child.logfile_read = logfile canceled = False From f54ac776cd7aa438cee06e78525379a99c4e1d80 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 21 May 2018 15:21:35 -0400 Subject: [PATCH 021/762] add final_line_count to EOF websocket --- awx/main/management/commands/run_callback_receiver.py | 3 ++- awx/main/utils/common.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index 7bb33b033c..0ffa218348 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -162,6 +162,7 @@ class CallbackBrokerWorker(ConsumerMixin): if body.get('event') == 'EOF': try: + final_line_count = body.get('final_line_count', 0) logger.info('Event processing is finished for Job {}, sending notifications'.format(job_identifier)) # EOF events are sent when stdout for the running task is # closed. don't actually persist them to the database; we @@ -169,7 +170,7 @@ class CallbackBrokerWorker(ConsumerMixin): # approximation for when a job is "done" emit_channel_notification( 'jobs-summary', - dict(group_name='jobs', unified_job_id=job_identifier) + dict(group_name='jobs', unified_job_id=job_identifier, final_line_count=final_line_count) ) # Additionally, when we've processed all events, we should # have all the data we need to send out success/failure diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index c9914cd1c3..59499acb2e 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -989,7 +989,7 @@ class OutputEventFilter(object): if value: self._emit_event(value) self._buffer = StringIO() - self._event_callback(dict(event='EOF')) + self._event_callback(dict(event='EOF', final_line_count=self._start_line)) def _emit_event(self, buffered_stdout, next_event_data=None): next_event_data = next_event_data or {} From c5f0d665547c67fe5990e54600c777ea9d57e704 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 22 May 2018 13:48:00 -0400 Subject: [PATCH 022/762] Only check the launch endpoint for job templates, not unified jts of other types --- .../workflows/workflow-maker/workflow-maker.controller.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index efe7bf6642..5747cd75b7 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -613,7 +613,10 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', if (!_.isEmpty($scope.nodeBeingEdited.promptData)) { $scope.promptData = _.cloneDeep($scope.nodeBeingEdited.promptData); - } else if ($scope.nodeBeingEdited.unifiedJobTemplate){ + } else if ( + _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.unified_job_type') === 'job_template' || + _.get($scope, 'nodeBeingEdited.unifiedJobTemplate.type') === 'job_template' + ) { let promises = [jobTemplate.optionsLaunch($scope.nodeBeingEdited.unifiedJobTemplate.id), jobTemplate.getLaunch($scope.nodeBeingEdited.unifiedJobTemplate.id)]; if (_.has($scope, 'nodeBeingEdited.originalNodeObj.related.credentials')) { From 89e08460dd72ed719814dce4e7f8c7087d15dd17 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 22 May 2018 13:57:37 -0400 Subject: [PATCH 023/762] display client finish time for incomplete job statuses --- awx/ui/client/features/output/status.service.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index 704d87ed18..4c92f4d598 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -107,10 +107,8 @@ function JobStatusService (moment, message) { } }; - this.isExpectingStatsEvent = () => ( - (this.jobType === 'job') || - (this.jobType === 'project_update')) && - (!_.includes(INCOMPLETE, this.state.status)); + this.isExpectingStatsEvent = () => (this.jobType === 'job') || + (this.jobType === 'project_update'); this.updateStats = () => { this.updateHostCounts(); @@ -155,7 +153,10 @@ function JobStatusService (moment, message) { this.setJobStatus = status => { this.state.status = status; - if (!this.isExpectingStatsEvent() && _.includes(FINISHED, status)) { + const isIncomplete = _.includes(INCOMPLETE, status); + const isFinished = _.includes(FINISHED, status); + + if ((this.isExpectingStatsEvent() && isIncomplete) || isFinished) { if (this.latestTime) { this.setFinished(this.latestTime); if (!this.state.started && this.state.elapsed) { From 9bbed9f14e5a945cba34e0619861c7323b504f4e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 22 May 2018 13:58:24 -0400 Subject: [PATCH 024/762] use event end_line for discarding events below starting threshold --- awx/ui/client/features/output/engine.service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/features/output/engine.service.js b/awx/ui/client/features/output/engine.service.js index 2d08b4ed6e..cf77c276d3 100644 --- a/awx/ui/client/features/output/engine.service.js +++ b/awx/ui/client/features/output/engine.service.js @@ -116,7 +116,7 @@ function JobEventEngine ($q) { this.chain = this.chain .then(() => { if (!this.isActive()) { - if (data.start_line < (this.lines.min)) { + if (data.end_line < (this.lines.min)) { return $q.resolve(); } this.start(); From 87f4104304fcfc89d27220eb17af22918c69bf8d Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 22 May 2018 14:55:43 -0400 Subject: [PATCH 025/762] Get default wfjt extra vars so that we can show all extra vars on launch preview --- .../launchTemplateButton/launchTemplateButton.component.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js index f8f6734c0e..637555d57f 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js +++ b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js @@ -73,12 +73,13 @@ function atLaunchTemplateCtrl ( } else if (vm.template.type === 'workflow_job_template') { const selectedWorkflowJobTemplate = workflowTemplate.create(); const preLaunchPromises = [ + selectedWorkflowJobTemplate.request('get', vm.template.id), selectedWorkflowJobTemplate.getLaunch(vm.template.id), selectedWorkflowJobTemplate.optionsLaunch(vm.template.id), ]; Promise.all(preLaunchPromises) - .then(([launchData, launchOptions]) => { + .then(([wfjtData, launchData, launchOptions]) => { if (selectedWorkflowJobTemplate.canLaunchWithoutPrompt()) { selectedWorkflowJobTemplate .postLaunch({ id: vm.template.id }) @@ -86,6 +87,9 @@ function atLaunchTemplateCtrl ( $state.go('workflowResults', { id: data.workflow_job }, { reload: true }); }); } else { + launchData.data.defaults = { + extra_vars: wfjtData.data.extra_vars + }; const promptData = { launchConf: launchData.data, launchOptions: launchOptions.data, From 2bb59fde543285b294117a56e7ee9dcb3ce99483 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 22 May 2018 15:03:41 -0400 Subject: [PATCH 026/762] Fixed concurrent jobs checkbox on wfjt edit form --- .../workflows/edit-workflow/workflow-edit.controller.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js index 0ef57520d9..37053ffc9e 100644 --- a/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js +++ b/awx/ui/client/src/templates/workflows/edit-workflow/workflow-edit.controller.js @@ -295,6 +295,11 @@ export default [ $scope[form.fields[fld].sourceModel + '_' + form.fields[fld].sourceField] = workflowJobTemplateData.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField]; } + if (form.fields[fld].type === 'checkbox_group') { + for(var j=0; j Date: Tue, 22 May 2018 13:57:24 -0400 Subject: [PATCH 027/762] disallow launching with other users prompts --- awx/main/access.py | 10 ++++-- awx/main/tests/functional/api/test_job.py | 28 +++++++++++++++- awx/main/tests/functional/test_rbac_job.py | 32 +++++++++++++++---- .../tests/functional/test_rbac_job_start.py | 1 + 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 47534928bd..f2ba528f9b 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1488,7 +1488,7 @@ class JobAccess(BaseAccess): # Obtain prompts used to start original job JobLaunchConfig = obj._meta.get_field('launch_config').related_model try: - config = obj.launch_config + config = JobLaunchConfig.objects.prefetch_related('credentials').get(job=obj) except JobLaunchConfig.DoesNotExist: config = None @@ -1496,6 +1496,12 @@ class JobAccess(BaseAccess): if obj.job_template is not None: if config is None: prompts_access = False + elif config.prompts_dict() == {}: + prompts_access = True + elif obj.created_by_id != self.user.pk: + prompts_access = False + if self.save_messages: + self.messages['detail'] = _('Job was launched with prompts provided by another user.') else: prompts_access = ( JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}) and @@ -1513,7 +1519,7 @@ class JobAccess(BaseAccess): # job can be relaunched if user could make an equivalent JT ret = org_access and credential_access and project_access - if not ret and self.save_messages: + if not ret and self.save_messages and not self.messages: if not obj.job_template: pretext = _('Job has been orphaned from its job template.') elif config is None: diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index 0e939269b6..1d5739731d 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -11,6 +11,8 @@ from awx.api.views import RelatedJobsPreventDeleteMixin, UnifiedJobDeletionMixin from awx.main.models import JobTemplate, User, Job +from crum import impersonate + @pytest.mark.django_db def test_extra_credentials(get, organization_factory, job_template_factory, credential): @@ -33,7 +35,8 @@ def test_job_relaunch_permission_denied_response( jt.credentials.add(machine_credential) jt_user = User.objects.create(username='jobtemplateuser') jt.execute_role.members.add(jt_user) - job = jt.create_unified_job() + with impersonate(jt_user): + job = jt.create_unified_job() # User capability is shown for this r = get(job.get_absolute_url(), jt_user, expect=200) @@ -46,6 +49,29 @@ def test_job_relaunch_permission_denied_response( assert 'do not have permission' in r.data['detail'] +@pytest.mark.django_db +def test_job_relaunch_permission_denied_response_other_user(get, post, inventory, project, alice, bob): + ''' + Asserts custom permission denied message corresponding to + awx/main/tests/functional/test_rbac_job.py::TestJobRelaunchAccess::test_other_user_prompts + ''' + jt = JobTemplate.objects.create( + name='testjt', inventory=inventory, project=project, + ask_credential_on_launch=True, + ask_variables_on_launch=True) + jt.execute_role.members.add(alice, bob) + with impersonate(bob): + job = jt.create_unified_job(extra_vars={'job_var': 'foo2'}) + + # User capability is shown for this + r = get(job.get_absolute_url(), alice, expect=200) + assert r.data['summary_fields']['user_capabilities']['start'] + + # Job has prompted data, launch denied w/ message + r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, alice, expect=403) + assert 'Job was launched with prompts provided by another user' in r.data['detail'] + + @pytest.mark.django_db def test_job_relaunch_without_creds(post, inventory, project, admin_user): jt = JobTemplate.objects.create( diff --git a/awx/main/tests/functional/test_rbac_job.py b/awx/main/tests/functional/test_rbac_job.py index 9c4b2b100a..4f8b62a574 100644 --- a/awx/main/tests/functional/test_rbac_job.py +++ b/awx/main/tests/functional/test_rbac_job.py @@ -19,6 +19,8 @@ from awx.main.models import ( Credential ) +from crum import impersonate + @pytest.fixture def normal_job(deploy_jobtemplate): @@ -151,11 +153,14 @@ class TestJobRelaunchAccess: ask_inventory_on_launch=True, ask_credential_on_launch=True ) - job_with_links = Job.objects.create(name='existing-job', inventory=inventory, job_template=job_template) + u = user('user1', False) + job_with_links = Job.objects.create( + name='existing-job', inventory=inventory, job_template=job_template, + created_by=u + ) job_with_links.credentials.add(machine_credential) JobLaunchConfig.objects.create(job=job_with_links, inventory=inventory) job_with_links.launch_config.credentials.add(machine_credential) # credential was prompted - u = user('user1', False) job_template.execute_role.members.add(u) if inv_access: job_with_links.inventory.use_role.members.add(u) @@ -225,15 +230,30 @@ class TestJobRelaunchAccess: @pytest.mark.job_runtime_vars def test_callback_relaunchable_by_user(self, job_template, rando): - job = job_template.create_unified_job( - _eager_fields={'launch_type': 'callback'}, - limit='host2' - ) + with impersonate(rando): + job = job_template.create_unified_job( + _eager_fields={'launch_type': 'callback'}, + limit='host2' + ) assert 'limit' in job.launch_config.prompts_dict() # sanity assertion job_template.execute_role.members.add(rando) can_access, messages = rando.can_access_with_errors(Job, 'start', job, validate_license=False) assert can_access, messages + def test_other_user_prompts(self, inventory, project, alice, bob): + jt = JobTemplate.objects.create( + name='testjt', inventory=inventory, project=project, + ask_credential_on_launch=True, + ask_variables_on_launch=True) + jt.execute_role.members.add(alice, bob) + + with impersonate(bob): + job = jt.create_unified_job(extra_vars={'job_var': 'foo2'}) + + assert 'job_var' in job.launch_config.extra_data + assert bob.can_access(Job, 'start', job, validate_license=False) + assert not alice.can_access(Job, 'start', job, validate_license=False) + @pytest.mark.django_db class TestJobAndUpdateCancels: diff --git a/awx/main/tests/functional/test_rbac_job_start.py b/awx/main/tests/functional/test_rbac_job_start.py index e48e7f6a7c..60c35e0803 100644 --- a/awx/main/tests/functional/test_rbac_job_start.py +++ b/awx/main/tests/functional/test_rbac_job_start.py @@ -87,6 +87,7 @@ class TestJobRelaunchAccess: for cred in job_with_prompts.credentials.all(): cred.use_role.members.add(rando) job_with_prompts.inventory.use_role.members.add(rando) + job_with_prompts.created_by = rando assert rando.can_access(Job, 'start', job_with_prompts) def test_no_relaunch_after_limit_change(self, inventory, machine_credential, rando): From 34dc939782a6ce958ffce87e98887d12f5a4a1a2 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 22 May 2018 16:22:13 -0400 Subject: [PATCH 028/762] improve the check performed to determine if a job is in an active state --- awx/ui/client/features/output/status.service.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index 4c92f4d598..83cb359c9d 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -151,12 +151,11 @@ function JobStatusService (moment, message) { }; this.setJobStatus = status => { - this.state.status = status; - + const isExpectingStats = this.isExpectingStatsEvent(); const isIncomplete = _.includes(INCOMPLETE, status); const isFinished = _.includes(FINISHED, status); - if ((this.isExpectingStatsEvent() && isIncomplete) || isFinished) { + if ((isExpectingStats && isIncomplete) || (!isExpectingStats && isFinished)) { if (this.latestTime) { this.setFinished(this.latestTime); if (!this.state.started && this.state.elapsed) { @@ -166,6 +165,7 @@ function JobStatusService (moment, message) { } } + this.state.status = status; this.updateRunningState(); }; From d369ae763871b9c010d7e44fdb9e2907c646b204 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 22 May 2018 16:09:46 -0400 Subject: [PATCH 029/762] do not put deep copy items in activity stream --- awx/main/tasks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index ad22a7d163..cbe70a394f 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2360,6 +2360,7 @@ def deep_copy_model_obj( ): logger.info(six.text_type('Deep copy {} from {} to {}.').format(model_name, obj_pk, new_obj_pk)) from awx.api.generics import CopyAPIView + from awx.main.signals import disable_activity_stream model = getattr(importlib.import_module(model_module), model_name, None) if model is None: return @@ -2370,7 +2371,7 @@ def deep_copy_model_obj( except ObjectDoesNotExist: logger.warning("Object or user no longer exists.") return - with transaction.atomic(), ignore_inventory_computed_fields(): + with transaction.atomic(), ignore_inventory_computed_fields(), disable_activity_stream(): copy_mapping = {} for sub_obj_setup in sub_obj_list: sub_model = getattr(importlib.import_module(sub_obj_setup[0]), From a1ed0f47ab4ac1ef9eecdf06743a6c40e71b7067 Mon Sep 17 00:00:00 2001 From: Fedor Sumkin Date: Sat, 19 May 2018 18:42:50 +0300 Subject: [PATCH 030/762] adding pexpect support --- requirements/requirements_ansible.in | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/requirements_ansible.in b/requirements/requirements_ansible.in index 54ec18470b..2f8ef92cc4 100644 --- a/requirements/requirements_ansible.in +++ b/requirements/requirements_ansible.in @@ -21,6 +21,7 @@ backports.ssl-match-hostname==3.5.0.1 boto==2.47.0 # last which does not break ec2 scripts boto3==1.6.2 ovirt-engine-sdk-python==4.2.4 # minimum set inside Ansible facts module requirements +pexpect==4.5.0 python-memcached==1.59 # same as AWX requirement psphere==0.5.2 psutil==5.4.3 # same as AWX requirement From f42c9bb952e6d75f5e95a2ef87254897468df228 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 22 May 2018 17:45:51 -0400 Subject: [PATCH 031/762] sanitize dynamic breadcrumb label --- awx/ui/client/features/output/index.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index be7bf49b33..e26112915b 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -186,9 +186,10 @@ function getWebSocketResource (type) { return { name, key }; } -function JobsRun ($stateRegistry, strings) { +function JobsRun ($stateRegistry, $filter, strings) { const parent = 'jobs'; const ncyBreadcrumb = { parent, label: strings.get('state.BREADCRUMB_DEFAULT') }; + const sanitize = $filter('sanitize'); const state = { url: '/:type/:id?job_event_search', @@ -231,7 +232,7 @@ function JobsRun ($stateRegistry, strings) { breadcrumbLabel: [ 'resource', ({ model }) => { - ncyBreadcrumb.label = `${model.get('id')} - ${model.get('name')}`; + ncyBreadcrumb.label = `${model.get('id')} - ${sanitize(model.get('name'))}`; } ], }, @@ -240,7 +241,7 @@ function JobsRun ($stateRegistry, strings) { $stateRegistry.register(state); } -JobsRun.$inject = ['$stateRegistry', 'JobStrings']; +JobsRun.$inject = ['$stateRegistry', '$filter', 'JobStrings']; angular .module(MODULE_NAME, [ From 16b00e486797b4cb18d0f030ab481a7482e2b35b Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Tue, 22 May 2018 15:35:21 -0700 Subject: [PATCH 032/762] Validates extra vars before moving to next step in prompt workflow --- .../src/templates/prompt/prompt.controller.js | 17 +++++++++++++++++ .../src/templates/prompt/prompt.partial.html | 2 +- .../prompt-other-prompts.controller.js | 9 ++++++++- .../prompt-other-prompts.directive.js | 3 ++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/templates/prompt/prompt.controller.js b/awx/ui/client/src/templates/prompt/prompt.controller.js index 362e1223fe..2f7baae551 100644 --- a/awx/ui/client/src/templates/prompt/prompt.controller.js +++ b/awx/ui/client/src/templates/prompt/prompt.controller.js @@ -158,6 +158,13 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', order: order }; order++; + + let codemirror = () => { + return { + validate:{} + }; + }; + vm.codeMirror = new codemirror(); } if(vm.promptDataClone.launchConf.survey_enabled) { vm.steps.survey.includeStep = true; @@ -189,6 +196,16 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', }; vm.next = (currentTab) => { + if(_.has(vm.steps.other_prompts, 'tab._active') && vm.steps.other_prompts.tab._active === true){ + try { + if (vm.codeMirror.validate) { + vm.codeMirror.validate(); + } + } catch (err) { + event.preventDefault(); + return; + } + } Object.keys(vm.steps).forEach(step => { if(vm.steps[step].tab) { if(vm.steps[step].tab.order === currentTab.order) { diff --git a/awx/ui/client/src/templates/prompt/prompt.partial.html b/awx/ui/client/src/templates/prompt/prompt.partial.html index 820ea8553d..e336f35744 100644 --- a/awx/ui/client/src/templates/prompt/prompt.partial.html +++ b/awx/ui/client/src/templates/prompt/prompt.partial.html @@ -19,7 +19,7 @@
- +
diff --git a/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.controller.js b/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.controller.js index 96a087218d..71af177f87 100644 --- a/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.controller.js +++ b/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.controller.js @@ -5,7 +5,7 @@ *************************************************/ export default - ['ParseTypeChange', 'CreateSelect2', 'TemplatesStrings', '$timeout', function(ParseTypeChange, CreateSelect2, strings, $timeout) { + ['ParseTypeChange', 'CreateSelect2', 'TemplatesStrings', '$timeout', 'ToJSON', function(ParseTypeChange, CreateSelect2, strings, $timeout, ToJSON) { const vm = this; vm.strings = strings; @@ -79,8 +79,15 @@ export default codemirrorExtraVars(); } }); + + function validate () { + return ToJSON(scope.parseType, scope.extraVariables, true); + } + scope.validate = validate; }; + + vm.toggleDiff = () => { scope.promptData.prompts.diffMode.value = !scope.promptData.prompts.diffMode.value; }; diff --git a/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.directive.js b/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.directive.js index c551fc8bd7..3a4990ae10 100644 --- a/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.directive.js +++ b/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.directive.js @@ -12,7 +12,8 @@ export default [ 'templateUrl', scope: { promptData: '=', otherPromptsForm: '=', - isActiveStep: '=' + isActiveStep: '=', + validate: '=' }, templateUrl: templateUrl('templates/prompt/steps/other-prompts/prompt-other-prompts'), controller: promptOtherPrompts, From b37926792fc23ab05cf27eca8e44a7979ceb6edc Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Tue, 22 May 2018 17:11:04 -0700 Subject: [PATCH 033/762] Fixes organizations collections watcher it was watching 'organization' instead of 'organizations' --- .../list/organizations-list.partial.html | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/awx/ui/client/src/organizations/list/organizations-list.partial.html b/awx/ui/client/src/organizations/list/organizations-list.partial.html index 8a7a2feee6..6cecccd453 100644 --- a/awx/ui/client/src/organizations/list/organizations-list.partial.html +++ b/awx/ui/client/src/organizations/list/organizations-list.partial.html @@ -24,15 +24,6 @@
- - + collection="organizations"> From c913badafe4092e2dd01eee369c30cfa3752387e Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 22 May 2018 20:21:39 -0400 Subject: [PATCH 034/762] Fixed error changing edge type of a previous root node --- .../workflows/workflow-maker/workflow-maker.controller.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index efe7bf6642..a12f027db3 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -252,8 +252,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', } } - if ((params.node.originalParentId && params.parentId !== params.node.originalParentId) || params.node.originalEdge !== params.node.edgeType) {//beep - + if (params.node.originalParentId && (params.parentId !== params.node.originalParentId || params.node.originalEdge !== params.node.edgeType)) { let parentIsDeleted = false; _.forEach($scope.treeData.data.deletedNodes, function(deletedNode) { @@ -974,6 +973,10 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', $scope.templateManuallySelected = function(selectedTemplate) { + if (surveyQuestionWatcher) { + surveyQuestionWatcher(); + } + if (selectedTemplate.type === "job_template") { let jobTemplate = new JobTemplate(); From b7e9bda6cfaf44f2246a74312af2de51490fcce3 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Fri, 18 May 2018 07:52:19 -0400 Subject: [PATCH 035/762] track prior organization_id by base model prior values tore Also fix bug where unified pointers were counted in the prior values store --- awx/main/fields.py | 1 + awx/main/models/base.py | 4 ++++ awx/main/signals.py | 13 +------------ 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index d63eb54002..0747a32b4d 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -218,6 +218,7 @@ class ImplicitRoleField(models.ForeignKey): kwargs.setdefault('to', 'Role') kwargs.setdefault('related_name', '+') kwargs.setdefault('null', 'True') + kwargs.setdefault('editable', False) super(ImplicitRoleField, self).__init__(*args, **kwargs) def deconstruct(self): diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 709e603967..2d71432d1b 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -290,6 +290,10 @@ class PrimordialModel(CreatedModifiedModel): if hasattr(field, 'attname'): if field.attname == 'id': continue + elif field.attname.endswith('ptr_id'): + # polymorphic fields should always be non-editable, see: + # https://github.com/django-polymorphic/django-polymorphic/issues/349 + continue if getattr(field, 'editable', True): fds.add(field.attname) return fds diff --git a/awx/main/signals.py b/awx/main/signals.py index 86c798795d..11d192e6e9 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -10,7 +10,6 @@ import json # Django from django.conf import settings from django.db.models.signals import ( - post_init, post_save, pre_delete, post_delete, @@ -200,14 +199,6 @@ def cleanup_detached_labels_on_deleted_parent(sender, instance, **kwargs): l.delete() -def set_original_organization(sender, instance, **kwargs): - '''set_original_organization is used to set the original, or - pre-save organization, so we can later determine if the organization - field is dirty. - ''' - instance.__original_org_id = instance.organization_id - - def save_related_job_templates(sender, instance, **kwargs): '''save_related_job_templates loops through all of the job templates that use an Inventory or Project that have had their @@ -217,7 +208,7 @@ def save_related_job_templates(sender, instance, **kwargs): if sender not in (Project, Inventory): raise ValueError('This signal callback is only intended for use with Project or Inventory') - if instance.__original_org_id != instance.organization_id: + if instance._prior_values_store.get('organization_id') != instance.organization_id: jtq = JobTemplate.objects.filter(**{sender.__name__.lower(): instance}) for jt in jtq: update_role_parentage_for_instance(jt) @@ -240,8 +231,6 @@ def connect_computed_field_signals(): connect_computed_field_signals() -post_init.connect(set_original_organization, sender=Project) -post_init.connect(set_original_organization, sender=Inventory) post_save.connect(save_related_job_templates, sender=Project) post_save.connect(save_related_job_templates, sender=Inventory) post_save.connect(emit_job_event_detail, sender=JobEvent) From f434196bae525390c486c13e830e0c41d79249c2 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 21 May 2018 16:16:18 -0400 Subject: [PATCH 036/762] store denormalized metadata about ActivityStream.actor for accounting see: https://github.com/ansible/tower/issues/1782 --- awx/api/serializers.py | 3 +++ ...8_v330_add_deleted_activitystream_actor.py | 24 +++++++++++++++++ awx/main/models/activity_stream.py | 13 ++++++++++ .../functional/models/test_activity_stream.py | 26 +++++++++++++++++++ .../activitystream.controller.js | 8 ++++-- 5 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 awx/main/migrations/0038_v330_add_deleted_activitystream_actor.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 28432c9ab0..69326d265c 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4831,6 +4831,9 @@ class ActivityStreamSerializer(BaseSerializer): username = obj.actor.username, first_name = obj.actor.first_name, last_name = obj.actor.last_name) + elif obj.deleted_actor: + summary_fields['actor'] = obj.deleted_actor.copy() + summary_fields['actor']['id'] = None if obj.setting: summary_fields['setting'] = [obj.setting] return summary_fields diff --git a/awx/main/migrations/0038_v330_add_deleted_activitystream_actor.py b/awx/main/migrations/0038_v330_add_deleted_activitystream_actor.py new file mode 100644 index 0000000000..6f79485f3f --- /dev/null +++ b/awx/main/migrations/0038_v330_add_deleted_activitystream_actor.py @@ -0,0 +1,24 @@ +#d -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-05-21 19:51 +from __future__ import unicode_literals + +import awx.main.fields +import awx.main.models.activity_stream +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0037_v330_remove_legacy_fact_cleanup'), + ] + + operations = [ + migrations.AddField( + model_name='activitystream', + name='deleted_actor', + field=awx.main.fields.JSONField(null=True), + ), + ] diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index 45d8cbea07..5881008bf2 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -33,6 +33,7 @@ class ActivityStream(models.Model): operation = models.CharField(max_length=13, choices=OPERATION_CHOICES) timestamp = models.DateTimeField(auto_now_add=True) changes = models.TextField(blank=True) + deleted_actor = JSONField(null=True) object_relationship_type = models.TextField(blank=True) object1 = models.TextField() @@ -77,6 +78,18 @@ class ActivityStream(models.Model): return reverse('api:activity_stream_detail', kwargs={'pk': self.pk}, request=request) def save(self, *args, **kwargs): + # Store denormalized actor metadata so that we retain it for accounting + # purposes when the User row is deleted. + if self.actor: + self.deleted_actor = { + 'id': self.actor_id, + 'username': self.actor.username, + 'first_name': self.actor.first_name, + 'last_name': self.actor.last_name, + } + if 'update_fields' in kwargs and 'deleted_actor' not in kwargs['update_fields']: + kwargs['update_fields'].append('deleted_actor') + # For compatibility with Django 1.4.x, attempt to handle any calls to # save that pass update_fields. try: diff --git a/awx/main/tests/functional/models/test_activity_stream.py b/awx/main/tests/functional/models/test_activity_stream.py index f13ce48f20..eff901093e 100644 --- a/awx/main/tests/functional/models/test_activity_stream.py +++ b/awx/main/tests/functional/models/test_activity_stream.py @@ -184,6 +184,32 @@ def test_annon_user_action(): assert not entry.actor +@pytest.mark.django_db +def test_activity_stream_deleted_actor(alice, bob): + alice.first_name = 'Alice' + alice.last_name = 'Doe' + alice.save() + with impersonate(alice): + o = Organization.objects.create(name='test organization') + entry = o.activitystream_set.get(operation='create') + assert entry.actor == alice + + alice.delete() + entry = o.activitystream_set.get(operation='create') + assert entry.actor is None + deleted = entry.deleted_actor + assert deleted['username'] == 'alice' + assert deleted['first_name'] == 'Alice' + assert deleted['last_name'] == 'Doe' + + entry.actor = bob + entry.save(update_fields=['actor']) + deleted = entry.deleted_actor + + entry = ActivityStream.objects.get(id=entry.pk) + assert entry.deleted_actor['username'] == 'bob' + + @pytest.mark.django_db def test_modified_not_allowed_field(somecloud_type): ''' diff --git a/awx/ui/client/src/activity-stream/activitystream.controller.js b/awx/ui/client/src/activity-stream/activitystream.controller.js index 73c8f9c732..febc257786 100644 --- a/awx/ui/client/src/activity-stream/activitystream.controller.js +++ b/awx/ui/client/src/activity-stream/activitystream.controller.js @@ -53,8 +53,12 @@ export default ['$scope', '$state', 'subTitle', 'GetTargetTitle', $scope.activities.forEach(function(activity, i) { // build activity.user if ($scope.activities[i].summary_fields.actor) { - $scope.activities[i].user = "" + - $scope.activities[i].summary_fields.actor.username + ""; + if ($scope.activities[i].summary_fields.actor.id) { + $scope.activities[i].user = "" + + $scope.activities[i].summary_fields.actor.username + ""; + } else { + $scope.activities[i].user = $scope.activities[i].summary_fields.actor.username + ' (deleted)'; + } } else { $scope.activities[i].user = 'system'; } From 7bc28276f22041048b5e94d4ba0596235537bb25 Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Wed, 23 May 2018 10:31:54 -0400 Subject: [PATCH 037/762] add note --- requirements/requirements_ansible.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/requirements_ansible.in b/requirements/requirements_ansible.in index 2f8ef92cc4..e63f24d083 100644 --- a/requirements/requirements_ansible.in +++ b/requirements/requirements_ansible.in @@ -21,7 +21,7 @@ backports.ssl-match-hostname==3.5.0.1 boto==2.47.0 # last which does not break ec2 scripts boto3==1.6.2 ovirt-engine-sdk-python==4.2.4 # minimum set inside Ansible facts module requirements -pexpect==4.5.0 +pexpect==4.5.0 # same as AWX requirement python-memcached==1.59 # same as AWX requirement psphere==0.5.2 psutil==5.4.3 # same as AWX requirement From 50503f97cca892c1f246afe6e80c6c26da6b9558 Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Wed, 23 May 2018 14:11:09 -0400 Subject: [PATCH 038/762] Regen requirements_ansible.txt (minimal update). --- requirements/requirements_ansible.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements/requirements_ansible.txt b/requirements/requirements_ansible.txt index a986e48cef..63f43d36d6 100644 --- a/requirements/requirements_ansible.txt +++ b/requirements/requirements_ansible.txt @@ -68,9 +68,11 @@ os-service-types==1.2.0 # via openstacksdk ovirt-engine-sdk-python==4.2.4 packaging==17.1 # via deprecation paramiko==2.4.0 # via azure-cli-core +pexpect==4.5.0 pbr==3.1.1 # via keystoneauth1, openstacksdk, os-service-types, shade, stevedore psphere==0.5.2 psutil==5.4.3 +ptyprocess==0.5.2 # via pexpect pyasn1==0.4.2 # via paramiko pycparser==2.18 # via cffi pycurl==7.43.0.1 # via ovirt-engine-sdk-python From 28a42850a24626013aadbd463af7ad28e12499a0 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 23 May 2018 12:26:44 -0400 Subject: [PATCH 039/762] allow AUTH_LDAP_USER_FLAGS_BY_GROUP to specify an OR'd list for a flag see: https://github.com/ansible/tower/issues/968 --- .../tests/functional/api/test_settings.py | 36 +++++++++++++++++++ awx/sso/fields.py | 14 +++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/awx/main/tests/functional/api/test_settings.py b/awx/main/tests/functional/api/test_settings.py index 97effe0fa3..4e6852ce85 100644 --- a/awx/main/tests/functional/api/test_settings.py +++ b/awx/main/tests/functional/api/test_settings.py @@ -101,6 +101,42 @@ def test_ldap_settings(get, put, patch, delete, admin): patch(url, user=admin, data={'AUTH_LDAP_BIND_DN': u'cn=暴力膜,dc=大新闻,dc=真的粉丝'}, expect=200) +@pytest.mark.django_db +@pytest.mark.parametrize('value', [ + None, '', 'INVALID', 1, [1], ['INVALID'], +]) +def test_ldap_user_flags_by_group_invalid_dn(get, patch, admin, value): + url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) + patch(url, user=admin, + data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': value}}, + expect=400) + + +@pytest.mark.django_db +def test_ldap_user_flags_by_group_string(get, patch, admin): + expected = 'CN=Admins,OU=Groups,DC=example,DC=com' + url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) + patch(url, user=admin, + data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': expected}}, + expect=200) + resp = get(url, user=admin) + assert resp.data['AUTH_LDAP_USER_FLAGS_BY_GROUP']['is_superuser'] == [expected] + + +@pytest.mark.django_db +def test_ldap_user_flags_by_group_list(get, patch, admin): + expected = [ + 'CN=Admins,OU=Groups,DC=example,DC=com', + 'CN=Superadmins,OU=Groups,DC=example,DC=com' + ] + url = reverse('api:setting_singleton_detail', kwargs={'category_slug': 'ldap'}) + patch(url, user=admin, + data={'AUTH_LDAP_USER_FLAGS_BY_GROUP': {'is_superuser': expected}}, + expect=200) + resp = get(url, user=admin) + assert resp.data['AUTH_LDAP_USER_FLAGS_BY_GROUP']['is_superuser'] == expected + + @pytest.mark.parametrize('setting', [ 'AUTH_LDAP_USER_DN_TEMPLATE', 'AUTH_LDAP_REQUIRE_GROUP', diff --git a/awx/sso/fields.py b/awx/sso/fields.py index 0e7434f443..c31591beb7 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -220,6 +220,18 @@ class LDAPDNField(fields.CharField): return None if value == '' else value +class LDAPDNListField(fields.StringListField): + + def __init__(self, **kwargs): + super(LDAPDNListField, self).__init__(**kwargs) + self.validators.append(lambda dn: map(validate_ldap_dn, dn)) + + def run_validation(self, data=empty): + if not isinstance(data, (list, tuple)): + data = [data] + return super(LDAPDNListField, self).run_validation(data) + + class LDAPDNWithUserField(fields.CharField): def __init__(self, **kwargs): @@ -431,7 +443,7 @@ class LDAPUserFlagsField(fields.DictField): 'invalid_flag': _('Invalid user flag: "{invalid_flag}".'), } valid_user_flags = {'is_superuser', 'is_system_auditor'} - child = LDAPDNField() + child = LDAPDNListField() def to_internal_value(self, data): data = super(LDAPUserFlagsField, self).to_internal_value(data) From 06eb400f225d81840ff7ed35c52216a80cc4f29d Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Wed, 23 May 2018 15:56:39 -0400 Subject: [PATCH 040/762] add callback for checking if field is filterable --- awx/ui/client/features/output/search.component.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/awx/ui/client/features/output/search.component.js b/awx/ui/client/features/output/search.component.js index b1f40efd3e..4e9a2e8caf 100644 --- a/awx/ui/client/features/output/search.component.js +++ b/awx/ui/client/features/output/search.component.js @@ -1,8 +1,8 @@ const templateUrl = require('~features/output/search.partial.html'); const searchReloadOptions = { inherit: false, location: 'replace' }; -const searchKeyExamples = ['id:>1', 'task:set', 'created:>=2000-01-01']; -const searchKeyFields = ['changed', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play']; +const searchKeyExamples = ['host_name:localhost', 'task:set', 'created:>=2000-01-01']; +const searchKeyFields = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play']; const PLACEHOLDER_RUNNING = 'CANNOT SEARCH RUNNING JOB'; const PLACEHOLDER_DEFAULT = 'SEARCH'; @@ -52,11 +52,16 @@ function reloadQueryset (queryset, rejection = REJECT_DEFAULT) { }); } +const isFilterable = term => { + const field = term[0].split('.')[0].replace(/^-/, ''); + return (searchKeyFields.indexOf(field) > -1); +}; + function removeSearchTag (index) { const searchTerm = vm.tags[index]; const currentQueryset = getCurrentQueryset(); - const modifiedQueryset = qs.removeTermsFromQueryset(currentQueryset, searchTerm); + const modifiedQueryset = qs.removeTermsFromQueryset(currentQueryset, searchTerm, isFilterable); reloadQueryset(modifiedQueryset); } @@ -64,7 +69,7 @@ function removeSearchTag (index) { function submitSearch () { const currentQueryset = getCurrentQueryset(); - const searchInputQueryset = qs.getSearchInputQueryset(vm.value); + const searchInputQueryset = qs.getSearchInputQueryset(vm.value, isFilterable); const modifiedQueryset = qs.mergeQueryset(currentQueryset, searchInputQueryset); reloadQueryset(modifiedQueryset, REJECT_INVALID); From d8f86ecba0f66279565414f210803d94a63fd1d3 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 23 May 2018 16:19:59 -0400 Subject: [PATCH 041/762] add help text for the new custom_virtualenv field see: https://github.com/ansible/tower/issues/1866 --- .../0039_v330_custom_venv_help_text.py | 33 +++++++++++++++++++ awx/main/models/mixins.py | 3 +- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 awx/main/migrations/0039_v330_custom_venv_help_text.py diff --git a/awx/main/migrations/0039_v330_custom_venv_help_text.py b/awx/main/migrations/0039_v330_custom_venv_help_text.py new file mode 100644 index 0000000000..ba68aa158f --- /dev/null +++ b/awx/main/migrations/0039_v330_custom_venv_help_text.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-05-23 20:17 +from __future__ import unicode_literals + +import awx.main.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0038_v330_add_deleted_activitystream_actor'), + ] + + operations = [ + migrations.AlterField( + model_name='jobtemplate', + name='custom_virtualenv', + field=models.CharField(blank=True, default=None, help_text='Local absolute file path containing a custom Python virtualenv to use', max_length=100, null=True), + ), + migrations.AlterField( + model_name='organization', + name='custom_virtualenv', + field=models.CharField(blank=True, default=None, help_text='Local absolute file path containing a custom Python virtualenv to use', max_length=100, null=True), + ), + migrations.AlterField( + model_name='project', + name='custom_virtualenv', + field=models.CharField(blank=True, default=None, help_text='Local absolute file path containing a custom Python virtualenv to use', max_length=100, null=True), + ), + ] diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 7d563f1e36..2314b295ae 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -436,7 +436,8 @@ class CustomVirtualEnvMixin(models.Model): blank=True, null=True, default=None, - max_length=100 + max_length=100, + help_text=_('Local absolute file path containing a custom Python virtualenv to use') ) def clean_custom_virtualenv(self): From ef7ed76178ac7d7907d303c5a18f940c98221b00 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Wed, 23 May 2018 16:31:08 -0400 Subject: [PATCH 042/762] fix auditor issues --- .../features/applications/list-applications.view.html | 10 +++------- awx/ui/client/src/scheduler/schedulerForm.block.less | 4 ++++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/awx/ui/client/features/applications/list-applications.view.html b/awx/ui/client/features/applications/list-applications.view.html index c1ab24bd75..46a144d487 100644 --- a/awx/ui/client/features/applications/list-applications.view.html +++ b/awx/ui/client/features/applications/list-applications.view.html @@ -18,7 +18,7 @@ collection="collection" search-tags="searchTags"> -
+
- - - - +
diff --git a/awx/ui/client/src/scheduler/schedulerForm.block.less b/awx/ui/client/src/scheduler/schedulerForm.block.less index d40eb0e15e..ea1244e120 100644 --- a/awx/ui/client/src/scheduler/schedulerForm.block.less +++ b/awx/ui/client/src/scheduler/schedulerForm.block.less @@ -16,6 +16,10 @@ padding-top: 4px; } +input.DatePicker-input[disabled] { + background: @ebgrey; +} + @media (min-width: 901px) { .SchedulerForm-formGroup { flex: 0 0 auto; From dbc02ae9a19b4959e99ef50ac12ab14e04d5663f Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 23 May 2018 16:44:44 -0400 Subject: [PATCH 043/762] Fixed race condition where selecte2-ifying the edge type was happening before the digest cycle finished --- .../workflows/workflow-maker/workflow-maker.controller.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 5747cd75b7..e6c706b8c8 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -6,10 +6,10 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', '$state', 'ProcessErrors', 'CreateSelect2', '$q', 'JobTemplateModel', - 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', + 'Empty', 'PromptService', 'Rest', 'TemplatesStrings', '$timeout', function($scope, WorkflowService, GetBasePath, TemplatesService, $state, ProcessErrors, CreateSelect2, $q, JobTemplate, - Empty, PromptService, Rest, TemplatesStrings) { + Empty, PromptService, Rest, TemplatesStrings, $timeout) { let promptWatcher, surveyQuestionWatcher; @@ -808,7 +808,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', break; } - updateEdgeDropdownOptions(edgeDropdownOptions); + $timeout(updateEdgeDropdownOptions(edgeDropdownOptions)); $scope.$broadcast("refreshWorkflowChart"); }; From 63f089c7124cccde8a6e7cec8106a793bad7bc09 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 23 May 2018 16:51:58 -0400 Subject: [PATCH 044/762] allow any authenticated user to access the schedule preview API endpoint see: https://github.com/ansible/tower/issues/1939 --- awx/api/views.py | 1 + awx/main/tests/functional/api/test_schedules.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/awx/api/views.py b/awx/api/views.py index 5f1d8b22af..d64899df39 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -720,6 +720,7 @@ class SchedulePreview(GenericAPIView): model = Schedule view_name = _('Schedule Recurrence Rule Preview') serializer_class = SchedulePreviewSerializer + permission_classes = (IsAuthenticated,) def post(self, request): serializer = self.get_serializer(data=request.data) diff --git a/awx/main/tests/functional/api/test_schedules.py b/awx/main/tests/functional/api/test_schedules.py index 56c35d5f94..c03ecead1a 100644 --- a/awx/main/tests/functional/api/test_schedules.py +++ b/awx/main/tests/functional/api/test_schedules.py @@ -87,6 +87,12 @@ def test_invalid_rrules(post, admin_user, project, inventory, rrule, error): assert error in resp.content +@pytest.mark.django_db +def test_normal_users_can_preview_schedules(post, alice): + url = reverse('api:schedule_rrule') + post(url, {'rrule': get_rrule()}, alice, expect=200) + + @pytest.mark.django_db def test_utc_preview(post, admin_user): url = reverse('api:schedule_rrule') From 3abdf6679493081c21f1f4bf5ad3e90fa9207cf2 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 24 May 2018 09:07:53 -0400 Subject: [PATCH 045/762] run network ui tests in shippable and Jenkins --- tools/docker-compose/unit-tests/docker-compose-shippable.yml | 2 +- tools/docker-compose/unit-tests/docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/docker-compose/unit-tests/docker-compose-shippable.yml b/tools/docker-compose/unit-tests/docker-compose-shippable.yml index b314d6acdd..5485b2e415 100644 --- a/tools/docker-compose/unit-tests/docker-compose-shippable.yml +++ b/tools/docker-compose/unit-tests/docker-compose-shippable.yml @@ -8,7 +8,7 @@ services: image: gcr.io/ansible-tower-engineering/unit-test-runner:${GIT_BRANCH:-latest} environment: SWIG_FEATURES: "-cpperraswarn -includeall -I/usr/include/openssl" - TEST_DIRS: awx/main/tests/functional awx/main/tests/unit awx/conf/tests awx/sso/tests + TEST_DIRS: awx/main/tests/functional awx/main/tests/unit awx/conf/tests awx/sso/tests awx/network_ui/tests command: ["make test"] volumes: - /awx_devel:/awx_devel diff --git a/tools/docker-compose/unit-tests/docker-compose.yml b/tools/docker-compose/unit-tests/docker-compose.yml index 9c94cc3564..0f652e40d5 100644 --- a/tools/docker-compose/unit-tests/docker-compose.yml +++ b/tools/docker-compose/unit-tests/docker-compose.yml @@ -8,7 +8,7 @@ services: image: gcr.io/ansible-tower-engineering/unit-test-runner:${GIT_BRANCH:-latest} environment: SWIG_FEATURES: "-cpperraswarn -includeall -I/usr/include/openssl" - TEST_DIRS: awx/main/tests/functional awx/main/tests/unit awx/conf/tests awx/sso/tests + TEST_DIRS: awx/main/tests/functional awx/main/tests/unit awx/conf/tests awx/sso/tests awx/network_ui/tests command: ["make test_combined"] volumes: - ../../../:/awx_devel From 37264d9d212797eae020937e889c75a0f210a7fd Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 24 May 2018 09:27:34 -0400 Subject: [PATCH 046/762] rename some tests from unit -> functional --- awx/network_ui/tests/{unit => functional}/__init__.py | 0 awx/network_ui/tests/{unit => functional}/test_consumers.py | 0 awx/network_ui/tests/{unit => functional}/test_models.py | 0 awx/network_ui/tests/{unit => functional}/test_network_events.py | 0 awx/network_ui/tests/{unit => functional}/test_routing.py | 0 awx/network_ui/tests/{unit => functional}/test_views.py | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename awx/network_ui/tests/{unit => functional}/__init__.py (100%) rename awx/network_ui/tests/{unit => functional}/test_consumers.py (100%) rename awx/network_ui/tests/{unit => functional}/test_models.py (100%) rename awx/network_ui/tests/{unit => functional}/test_network_events.py (100%) rename awx/network_ui/tests/{unit => functional}/test_routing.py (100%) rename awx/network_ui/tests/{unit => functional}/test_views.py (100%) diff --git a/awx/network_ui/tests/unit/__init__.py b/awx/network_ui/tests/functional/__init__.py similarity index 100% rename from awx/network_ui/tests/unit/__init__.py rename to awx/network_ui/tests/functional/__init__.py diff --git a/awx/network_ui/tests/unit/test_consumers.py b/awx/network_ui/tests/functional/test_consumers.py similarity index 100% rename from awx/network_ui/tests/unit/test_consumers.py rename to awx/network_ui/tests/functional/test_consumers.py diff --git a/awx/network_ui/tests/unit/test_models.py b/awx/network_ui/tests/functional/test_models.py similarity index 100% rename from awx/network_ui/tests/unit/test_models.py rename to awx/network_ui/tests/functional/test_models.py diff --git a/awx/network_ui/tests/unit/test_network_events.py b/awx/network_ui/tests/functional/test_network_events.py similarity index 100% rename from awx/network_ui/tests/unit/test_network_events.py rename to awx/network_ui/tests/functional/test_network_events.py diff --git a/awx/network_ui/tests/unit/test_routing.py b/awx/network_ui/tests/functional/test_routing.py similarity index 100% rename from awx/network_ui/tests/unit/test_routing.py rename to awx/network_ui/tests/functional/test_routing.py diff --git a/awx/network_ui/tests/unit/test_views.py b/awx/network_ui/tests/functional/test_views.py similarity index 100% rename from awx/network_ui/tests/unit/test_views.py rename to awx/network_ui/tests/functional/test_views.py From c9c7a4b8f4be36e7a4ae976fcc89049d116b486f Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 24 May 2018 09:28:34 -0400 Subject: [PATCH 047/762] fix a few broken network UI tests --- awx/network_ui/consumers.py | 2 ++ .../tests/functional/test_consumers.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/awx/network_ui/consumers.py b/awx/network_ui/consumers.py index bd5dd90994..d07b68b729 100644 --- a/awx/network_ui/consumers.py +++ b/awx/network_ui/consumers.py @@ -225,11 +225,13 @@ def ws_connect(message): message.user.id, inventory_id) ) message.reply_channel.send({"close": True}) + return if message.user not in inventory.admin_role: logger.warn("User {} attempted connecting to inventory_id {} without permission.".format( message.user.id, inventory_id )) message.reply_channel.send({"close": True}) + return topology_ids = list(TopologyInventory.objects.filter(inventory_id=inventory_id).values_list('pk', flat=True)) topology_id = None if len(topology_ids) > 0: diff --git a/awx/network_ui/tests/functional/test_consumers.py b/awx/network_ui/tests/functional/test_consumers.py index de5c79e105..f5d3d36173 100644 --- a/awx/network_ui/tests/functional/test_consumers.py +++ b/awx/network_ui/tests/functional/test_consumers.py @@ -1,4 +1,3 @@ - import mock import logging import json @@ -7,6 +6,7 @@ from mock import patch patch('channels.auth.channel_session_user', lambda x: x).start() patch('channels.auth.channel_session_user_from_http', lambda x: x).start() +from awx.main.models import Inventory # noqa from awx.network_ui.consumers import parse_inventory_id, networking_events_dispatcher, send_snapshot # noqa from awx.network_ui.models import Topology, Device, Link, Interface, TopologyInventory, Client # noqa import awx # noqa @@ -178,7 +178,8 @@ def test_ws_connect_unauthenticated(): def test_ws_connect_new_topology(): - message = mock.MagicMock() + mock_user = mock.Mock() + message = mock.MagicMock(user=mock_user) logger = logging.getLogger('awx.network_ui.consumers') with mock.patch('awx.network_ui.consumers.Client') as client_mock,\ mock.patch('awx.network_ui.consumers.Topology') as topology_mock,\ @@ -191,10 +192,12 @@ def test_ws_connect_new_topology(): mock.patch.object(Topology, 'objects'),\ mock.patch.object(Device, 'objects'),\ mock.patch.object(Link, 'objects'),\ - mock.patch.object(Interface, 'objects'): + mock.patch.object(Interface, 'objects'),\ + mock.patch.object(Inventory, 'objects') as inventory_objects: client_mock.return_value.pk = 777 topology_mock.return_value = Topology( name="topology", scale=1.0, panX=0, panY=0, pk=999) + inventory_objects.get.return_value = mock.Mock(admin_role=[mock_user]) awx.network_ui.consumers.ws_connect(message) message.reply_channel.send.assert_has_calls([ mock.call({'text': '["id", 777]'}), @@ -206,7 +209,8 @@ def test_ws_connect_new_topology(): def test_ws_connect_existing_topology(): - message = mock.MagicMock() + mock_user = mock.Mock() + message = mock.MagicMock(user=mock_user) logger = logging.getLogger('awx.network_ui.consumers') with mock.patch('awx.network_ui.consumers.Client') as client_mock,\ mock.patch('awx.network_ui.consumers.send_snapshot') as send_snapshot_mock,\ @@ -218,7 +222,8 @@ def test_ws_connect_existing_topology(): mock.patch.object(Topology, 'objects') as topology_objects_mock,\ mock.patch.object(Device, 'objects'),\ mock.patch.object(Link, 'objects'),\ - mock.patch.object(Interface, 'objects'): + mock.patch.object(Interface, 'objects'),\ + mock.patch.object(Inventory, 'objects') as inventory_objects: topology_inventory_objects_mock.filter.return_value.values_list.return_value = [ 1] client_mock.return_value.pk = 888 @@ -230,6 +235,7 @@ def test_ws_connect_existing_topology(): scale=1.0, link_id_seq=1, device_id_seq=1) + inventory_objects.get.return_value = mock.Mock(admin_role=[mock_user]) awx.network_ui.consumers.ws_connect(message) message.reply_channel.send.assert_has_calls([ mock.call({'text': '["id", 888]'}), From 5d220e82229bd66272cbe3b2338ab929726fe2b1 Mon Sep 17 00:00:00 2001 From: adamscmRH Date: Wed, 23 May 2018 14:04:36 -0400 Subject: [PATCH 048/762] add scope validator to token endpoints --- awx/api/serializers.py | 416 +++++++++----------- awx/main/tests/functional/api/test_oauth.py | 15 +- 2 files changed, 201 insertions(+), 230 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 69326d265c..77b7316334 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -985,19 +985,12 @@ class UserSerializer(BaseSerializer): return self._validate_ldap_managed_field(value, 'is_superuser') -class UserAuthorizedTokenSerializer(BaseSerializer): - +class BaseOAuth2TokenSerializer(BaseSerializer): + refresh_token = serializers.SerializerMethodField() token = serializers.SerializerMethodField() + ALLOWED_SCOPES = ['read', 'write'] - class Meta: - model = OAuth2AccessToken - fields = ( - '*', '-name', 'description', 'user', 'token', 'refresh_token', - 'expires', 'scope', 'application' - ) - read_only_fields = ('user', 'token', 'expires') - def get_token(self, obj): request = self.context.get('request', None) try: @@ -1006,7 +999,36 @@ class UserAuthorizedTokenSerializer(BaseSerializer): else: return TOKEN_CENSOR except ObjectDoesNotExist: - return '' + return '' + + def _is_valid_scope(self, value): + if not value or (not isinstance(value, six.string_types)): + return False + words = value.split() + for word in words: + if words.count(word) > 1: + return False # do not allow duplicates + if word not in self.ALLOWED_SCOPES: + return False + return True + + def validate_scope(self, value): + if not self._is_valid_scope(value): + raise serializers.ValidationError(_( + 'Must be a simple space-separated string with allowed scopes {}.' + ).format(self.ALLOWED_SCOPES)) + return value + + +class UserAuthorizedTokenSerializer(BaseOAuth2TokenSerializer): + + class Meta: + model = OAuth2AccessToken + fields = ( + '*', '-name', 'description', 'user', 'token', 'refresh_token', + 'expires', 'scope', 'application' + ) + read_only_fields = ('user', 'token', 'expires') def get_refresh_token(self, obj): request = self.context.get('request', None) @@ -1035,7 +1057,162 @@ class UserAuthorizedTokenSerializer(BaseSerializer): access_token=obj ) return obj + + +class OAuth2TokenSerializer(BaseOAuth2TokenSerializer): + + class Meta: + model = OAuth2AccessToken + fields = ( + '*', '-name', 'description', 'user', 'token', 'refresh_token', + 'application', 'expires', 'scope', + ) + read_only_fields = ('user', 'token', 'expires') + extra_kwargs = { + 'scope': {'allow_null': False, 'required': True}, + 'user': {'allow_null': False, 'required': True} + } + + def get_modified(self, obj): + if obj is None: + return None + return obj.updated + + def get_related(self, obj): + ret = super(OAuth2TokenSerializer, self).get_related(obj) + if obj.user: + ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) + if obj.application: + ret['application'] = self.reverse( + 'api:o_auth2_application_detail', kwargs={'pk': obj.application.pk} + ) + ret['activity_stream'] = self.reverse( + 'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk} + ) + return ret + + def get_refresh_token(self, obj): + request = self.context.get('request', None) + try: + if request.method == 'POST': + return getattr(obj.refresh_token, 'token', '') + else: + return TOKEN_CENSOR + except ObjectDoesNotExist: + return '' + + def create(self, validated_data): + current_user = self.context['request'].user + validated_data['user'] = current_user + validated_data['token'] = generate_token() + validated_data['expires'] = now() + timedelta( + seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] + ) + obj = super(OAuth2TokenSerializer, self).create(validated_data) + if obj.application and obj.application.user: + obj.user = obj.application.user + obj.save() + if obj.application is not None: + RefreshToken.objects.create( + user=current_user, + token=generate_token(), + application=obj.application, + access_token=obj + ) + return obj + +class OAuth2TokenDetailSerializer(OAuth2TokenSerializer): + + class Meta: + read_only_fields = ('*', 'user', 'application') + + +class OAuth2AuthorizedTokenSerializer(BaseOAuth2TokenSerializer): + + class Meta: + model = OAuth2AccessToken + fields = ( + '*', '-name', 'description', '-user', 'token', 'refresh_token', + 'expires', 'scope', 'application', + ) + read_only_fields = ('user', 'token', 'expires') + extra_kwargs = { + 'scope': {'allow_null': False, 'required': True} + } + + def get_refresh_token(self, obj): + request = self.context.get('request', None) + try: + if request.method == 'POST': + return getattr(obj.refresh_token, 'token', '') + else: + return TOKEN_CENSOR + except ObjectDoesNotExist: + return '' + + def create(self, validated_data): + current_user = self.context['request'].user + validated_data['user'] = current_user + validated_data['token'] = generate_token() + validated_data['expires'] = now() + timedelta( + seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] + ) + obj = super(OAuth2AuthorizedTokenSerializer, self).create(validated_data) + if obj.application and obj.application.user: + obj.user = obj.application.user + obj.save() + if obj.application is not None: + RefreshToken.objects.create( + user=current_user, + token=generate_token(), + application=obj.application, + access_token=obj + ) + return obj + + +class OAuth2PersonalTokenSerializer(BaseOAuth2TokenSerializer): + + class Meta: + model = OAuth2AccessToken + fields = ( + '*', '-name', 'description', 'user', 'token', 'refresh_token', + 'application', 'expires', 'scope', + ) + read_only_fields = ('user', 'token', 'expires', 'application') + extra_kwargs = { + 'scope': {'allow_null': False, 'required': True} + } + + def get_modified(self, obj): + if obj is None: + return None + return obj.updated + + def get_related(self, obj): + ret = super(OAuth2PersonalTokenSerializer, self).get_related(obj) + if obj.user: + ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) + ret['activity_stream'] = self.reverse( + 'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk} + ) + return ret + + def get_refresh_token(self, obj): + return None + + def create(self, validated_data): + validated_data['user'] = self.context['request'].user + validated_data['token'] = generate_token() + validated_data['expires'] = now() + timedelta( + seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] + ) + validated_data['application'] = None + obj = super(OAuth2PersonalTokenSerializer, self).create(validated_data) + obj.save() + return obj + class OAuth2ApplicationSerializer(BaseSerializer): @@ -1096,223 +1273,6 @@ class OAuth2ApplicationSerializer(BaseSerializer): return ret -class OAuth2TokenSerializer(BaseSerializer): - - refresh_token = serializers.SerializerMethodField() - token = serializers.SerializerMethodField() - ALLOWED_SCOPES = ['read', 'write'] - - class Meta: - model = OAuth2AccessToken - fields = ( - '*', '-name', 'description', 'user', 'token', 'refresh_token', - 'application', 'expires', 'scope', - ) - read_only_fields = ('user', 'token', 'expires') - extra_kwargs = { - 'scope': {'allow_null': False, 'required': True}, - 'user': {'allow_null': False, 'required': True} - } - - def get_modified(self, obj): - if obj is None: - return None - return obj.updated - - def get_related(self, obj): - ret = super(OAuth2TokenSerializer, self).get_related(obj) - if obj.user: - ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) - if obj.application: - ret['application'] = self.reverse( - 'api:o_auth2_application_detail', kwargs={'pk': obj.application.pk} - ) - ret['activity_stream'] = self.reverse( - 'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk} - ) - return ret - - def get_token(self, obj): - request = self.context.get('request', None) - try: - if request.method == 'POST': - return obj.token - else: - return TOKEN_CENSOR - except ObjectDoesNotExist: - return '' - - def get_refresh_token(self, obj): - request = self.context.get('request', None) - try: - if request.method == 'POST': - return getattr(obj.refresh_token, 'token', '') - else: - return TOKEN_CENSOR - except ObjectDoesNotExist: - return '' - - def _is_valid_scope(self, value): - if not value or (not isinstance(value, six.string_types)): - return False - words = value.split() - for word in words: - if words.count(word) > 1: - return False # do not allow duplicates - if word not in self.ALLOWED_SCOPES: - return False - return True - - def validate_scope(self, value): - if not self._is_valid_scope(value): - raise serializers.ValidationError(_( - 'Must be a simple space-separated string with allowed scopes {}.' - ).format(self.ALLOWED_SCOPES)) - return value - - def create(self, validated_data): - current_user = self.context['request'].user - validated_data['user'] = current_user - validated_data['token'] = generate_token() - validated_data['expires'] = now() + timedelta( - seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] - ) - obj = super(OAuth2TokenSerializer, self).create(validated_data) - if obj.application and obj.application.user: - obj.user = obj.application.user - obj.save() - if obj.application is not None: - RefreshToken.objects.create( - user=current_user, - token=generate_token(), - application=obj.application, - access_token=obj - ) - return obj - - -class OAuth2TokenDetailSerializer(OAuth2TokenSerializer): - - class Meta: - read_only_fields = ('*', 'user', 'application') - - -class OAuth2AuthorizedTokenSerializer(BaseSerializer): - - refresh_token = serializers.SerializerMethodField() - token = serializers.SerializerMethodField() - - class Meta: - model = OAuth2AccessToken - fields = ( - '*', '-name', 'description', '-user', 'token', 'refresh_token', - 'expires', 'scope', 'application', - ) - read_only_fields = ('user', 'token', 'expires') - extra_kwargs = { - 'scope': {'allow_null': False, 'required': True} - } - - def get_token(self, obj): - request = self.context.get('request', None) - try: - if request.method == 'POST': - return obj.token - else: - return TOKEN_CENSOR - except ObjectDoesNotExist: - return '' - - def get_refresh_token(self, obj): - request = self.context.get('request', None) - try: - if request.method == 'POST': - return getattr(obj.refresh_token, 'token', '') - else: - return TOKEN_CENSOR - except ObjectDoesNotExist: - return '' - - def create(self, validated_data): - current_user = self.context['request'].user - validated_data['user'] = current_user - validated_data['token'] = generate_token() - validated_data['expires'] = now() + timedelta( - seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] - ) - obj = super(OAuth2AuthorizedTokenSerializer, self).create(validated_data) - if obj.application and obj.application.user: - obj.user = obj.application.user - obj.save() - if obj.application is not None: - RefreshToken.objects.create( - user=current_user, - token=generate_token(), - application=obj.application, - access_token=obj - ) - return obj - - -class OAuth2PersonalTokenSerializer(BaseSerializer): - - refresh_token = serializers.SerializerMethodField() - token = serializers.SerializerMethodField() - - class Meta: - model = OAuth2AccessToken - fields = ( - '*', '-name', 'description', 'user', 'token', 'refresh_token', - 'application', 'expires', 'scope', - ) - read_only_fields = ('user', 'token', 'expires', 'application') - extra_kwargs = { - 'scope': {'allow_null': False, 'required': True} - } - - def get_modified(self, obj): - if obj is None: - return None - return obj.updated - - def get_related(self, obj): - ret = super(OAuth2PersonalTokenSerializer, self).get_related(obj) - if obj.user: - ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) - if obj.application: - ret['application'] = self.reverse( - 'api:o_auth2_application_detail', kwargs={'pk': obj.application.pk} - ) - ret['activity_stream'] = self.reverse( - 'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk} - ) - return ret - - def get_token(self, obj): - request = self.context.get('request', None) - try: - if request.method == 'POST': - return obj.token - else: - return TOKEN_CENSOR - except ObjectDoesNotExist: - return '' - - def get_refresh_token(self, obj): - return None - - def create(self, validated_data): - validated_data['user'] = self.context['request'].user - validated_data['token'] = generate_token() - validated_data['expires'] = now() + timedelta( - seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] - ) - validated_data['application'] = None - obj = super(OAuth2PersonalTokenSerializer, self).create(validated_data) - obj.save() - return obj - - class OrganizationSerializer(BaseSerializer): show_capabilities = ['edit', 'delete'] diff --git a/awx/main/tests/functional/api/test_oauth.py b/awx/main/tests/functional/api/test_oauth.py index 7e745213c8..7e8b63eb08 100644 --- a/awx/main/tests/functional/api/test_oauth.py +++ b/awx/main/tests/functional/api/test_oauth.py @@ -29,7 +29,7 @@ def test_personal_access_token_creation(oauth_application, post, alice): @pytest.mark.django_db -def test_oauth_application_create(admin, organization, post): +def test_oauth2_application_create(admin, organization, post): response = post( reverse('api:o_auth2_application_list'), { 'name': 'test app', @@ -47,7 +47,18 @@ def test_oauth_application_create(admin, organization, post): assert created_app.client_type == 'confidential' assert created_app.authorization_grant_type == 'password' assert created_app.organization == organization - + + +@pytest.mark.django_db +def test_oauth2_validator(admin, oauth_application, post): + post( + reverse('api:o_auth2_application_list'), { + 'name': 'Write App Token', + 'application': oauth_application.pk, + 'scope': 'Write', + }, admin, expect=400 + ) + @pytest.mark.django_db def test_oauth_application_update(oauth_application, organization, patch, admin, alice): From be9598af539cfc1b368eebfc1d35b4580fe2e165 Mon Sep 17 00:00:00 2001 From: adamscmRH Date: Thu, 24 May 2018 12:25:27 -0400 Subject: [PATCH 049/762] fix refresh token & refactor --- awx/api/serializers.py | 68 +++++++++++++----------------------------- 1 file changed, 21 insertions(+), 47 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 77b7316334..c518a4514c 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -991,6 +991,18 @@ class BaseOAuth2TokenSerializer(BaseSerializer): token = serializers.SerializerMethodField() ALLOWED_SCOPES = ['read', 'write'] + class Meta: + model = OAuth2AccessToken + fields = ( + '*', '-name', 'description', 'user', 'token', 'refresh_token', + 'application', 'expires', 'scope', + ) + read_only_fields = ('user', 'token', 'expires', 'refresh_token') + extra_kwargs = { + 'scope': {'allow_null': False, 'required': True}, + 'user': {'allow_null': False, 'required': True} + } + def get_token(self, obj): request = self.context.get('request', None) try: @@ -1000,6 +1012,11 @@ class BaseOAuth2TokenSerializer(BaseSerializer): return TOKEN_CENSOR except ObjectDoesNotExist: return '' + + def get_modified(self, obj): + if obj is None: + return None + return obj.updated def _is_valid_scope(self, value): if not value or (not isinstance(value, six.string_types)): @@ -1020,15 +1037,7 @@ class BaseOAuth2TokenSerializer(BaseSerializer): return value -class UserAuthorizedTokenSerializer(BaseOAuth2TokenSerializer): - - class Meta: - model = OAuth2AccessToken - fields = ( - '*', '-name', 'description', 'user', 'token', 'refresh_token', - 'expires', 'scope', 'application' - ) - read_only_fields = ('user', 'token', 'expires') +class UserAuthorizedTokenSerializer(BaseOAuth2TokenSerializer): def get_refresh_token(self, obj): request = self.context.get('request', None) @@ -1061,18 +1070,6 @@ class UserAuthorizedTokenSerializer(BaseOAuth2TokenSerializer): class OAuth2TokenSerializer(BaseOAuth2TokenSerializer): - class Meta: - model = OAuth2AccessToken - fields = ( - '*', '-name', 'description', 'user', 'token', 'refresh_token', - 'application', 'expires', 'scope', - ) - read_only_fields = ('user', 'token', 'expires') - extra_kwargs = { - 'scope': {'allow_null': False, 'required': True}, - 'user': {'allow_null': False, 'required': True} - } - def get_modified(self, obj): if obj is None: return None @@ -1096,10 +1093,12 @@ class OAuth2TokenSerializer(BaseOAuth2TokenSerializer): try: if request.method == 'POST': return getattr(obj.refresh_token, 'token', '') + elif not obj.refresh_token: + return None else: return TOKEN_CENSOR except ObjectDoesNotExist: - return '' + return None def create(self, validated_data): current_user = self.context['request'].user @@ -1129,17 +1128,6 @@ class OAuth2TokenDetailSerializer(OAuth2TokenSerializer): class OAuth2AuthorizedTokenSerializer(BaseOAuth2TokenSerializer): - - class Meta: - model = OAuth2AccessToken - fields = ( - '*', '-name', 'description', '-user', 'token', 'refresh_token', - 'expires', 'scope', 'application', - ) - read_only_fields = ('user', 'token', 'expires') - extra_kwargs = { - 'scope': {'allow_null': False, 'required': True} - } def get_refresh_token(self, obj): request = self.context.get('request', None) @@ -1175,20 +1163,7 @@ class OAuth2AuthorizedTokenSerializer(BaseOAuth2TokenSerializer): class OAuth2PersonalTokenSerializer(BaseOAuth2TokenSerializer): class Meta: - model = OAuth2AccessToken - fields = ( - '*', '-name', 'description', 'user', 'token', 'refresh_token', - 'application', 'expires', 'scope', - ) read_only_fields = ('user', 'token', 'expires', 'application') - extra_kwargs = { - 'scope': {'allow_null': False, 'required': True} - } - - def get_modified(self, obj): - if obj is None: - return None - return obj.updated def get_related(self, obj): ret = super(OAuth2PersonalTokenSerializer, self).get_related(obj) @@ -1238,7 +1213,6 @@ class OAuth2ApplicationSerializer(BaseSerializer): ret.pop('client_secret', None) return ret - def get_modified(self, obj): if obj is None: return None From 870adc14f99d6cd237c73b703ad3b3b5f5416896 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 24 May 2018 12:28:46 -0400 Subject: [PATCH 050/762] fix a bug in the instance policy algorithm when both min and % are used see: https://github.com/ansible/tower/issues/897 --- awx/main/tasks.py | 4 ++++ awx/main/tests/functional/test_instances.py | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index cbe70a394f..dc3c8df28b 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -175,6 +175,10 @@ def apply_cluster_membership_policies(self): # Finally process instance policy percentages for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): + if i.obj.id in g.instances: + # If the instance is already _in_ the group, it was + # probably applied earlier via a minimum policy + continue if 100 * float(len(g.instances)) / len(actual_instances) >= g.obj.policy_instance_percentage: break logger.info(six.text_type("Policy percentage, adding Instance {} to Group {}").format(i.obj.hostname, g.obj.name)) diff --git a/awx/main/tests/functional/test_instances.py b/awx/main/tests/functional/test_instances.py index 91dee86b9e..40216ae123 100644 --- a/awx/main/tests/functional/test_instances.py +++ b/awx/main/tests/functional/test_instances.py @@ -193,6 +193,19 @@ def test_inherited_instance_group_membership(instance_group_factory, default_ins assert default_instance_group not in j.preferred_instance_groups +@pytest.mark.django_db +@mock.patch('awx.main.tasks.handle_ha_toplogy_changes', return_value=None) +def test_mixed_group_membership(mock, instance_factory, instance_group_factory): + for i in range(5): + instance_factory("i{}".format(i)) + ig_1 = instance_group_factory("ig1", percentage=60) + ig_2 = instance_group_factory("ig2", minimum=3) + ig_3 = instance_group_factory("ig3", minimum=1, percentage=60) + apply_cluster_membership_policies() + for group in (ig_1, ig_2, ig_3): + assert len(group.instances.all()) == 3 + + @pytest.mark.django_db def test_instance_group_capacity(instance_factory, instance_group_factory): i1 = instance_factory("i1") From 41e432abf0d7077fbbf10f85c9d17137b7d5b3a0 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 23 May 2018 15:08:34 -0400 Subject: [PATCH 051/762] Add error handling to stateDefinitions resolve block --- .../src/shared/stateDefinitions.factory.js | 18 ++++++++++--- .../src/teams/edit/teams-edit.controller.js | 26 +++++++------------ 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js index 99dc4f6f99..a7a437ef11 100644 --- a/awx/ui/client/src/shared/stateDefinitions.factory.js +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -209,9 +209,10 @@ function($injector, $stateExtender, $log, i18n) { FormDefinition: [params.form, function(definition) { return definition; }], - resourceData: ['FormDefinition', 'Rest', '$stateParams', 'GetBasePath', - function(FormDefinition, Rest, $stateParams, GetBasePath) { + resourceData: ['FormDefinition', 'Rest', '$stateParams', 'GetBasePath', '$q', 'ProcessErrors', + function(FormDefinition, Rest, $stateParams, GetBasePath, $q, ProcessErrors) { let form, path; + let deferred = $q.defer(); form = typeof(FormDefinition) === 'function' ? FormDefinition() : FormDefinition; if (GetBasePath(form.basePath) === undefined && GetBasePath(form.stateTree) === undefined ){ @@ -221,7 +222,18 @@ function($injector, $stateExtender, $log, i18n) { path = (GetBasePath(form.basePath) || GetBasePath(form.stateTree) || form.basePath) + $stateParams[`${form.name}_id`]; } Rest.setUrl(path); - return Rest.get(); + Rest.get() + .then((response) => deferred.resolve(response)) + .catch(({ data, status }) => { + ProcessErrors(null, data, status, null, + { + hdr: i18n._('Error!'), + msg: i18n._('Unable to get resource: ') + status + } + ); + deferred.reject(); + }); + return deferred.promise; } ] }, diff --git a/awx/ui/client/src/teams/edit/teams-edit.controller.js b/awx/ui/client/src/teams/edit/teams-edit.controller.js index 41a37b19cb..dc9beea14d 100644 --- a/awx/ui/client/src/teams/edit/teams-edit.controller.js +++ b/awx/ui/client/src/teams/edit/teams-edit.controller.js @@ -5,14 +5,15 @@ *************************************************/ export default ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest', - 'ProcessErrors', 'GetBasePath', 'Wait', '$state', 'OrgAdminLookup', 'resolvedModels', + 'ProcessErrors', 'GetBasePath', 'Wait', '$state', 'OrgAdminLookup', 'resolvedModels', 'resourceData', function($scope, $rootScope, $stateParams, TeamForm, Rest, ProcessErrors, - GetBasePath, Wait, $state, OrgAdminLookup, models) { + GetBasePath, Wait, $state, OrgAdminLookup, models, Dataset) { const { me } = models; - var form = TeamForm, - id = $stateParams.team_id, - defaultUrl = GetBasePath('teams') + id; + const { data } = Dataset; + const id = $stateParams.team_id; + const defaultUrl = GetBasePath('teams') + id; + let form = TeamForm; init(); @@ -20,26 +21,19 @@ export default ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest', $scope.canEdit = me.get('summary_fields.user_capabilities.edit'); $scope.isOrgAdmin = me.get('related.admin_of_organizations.count') > 0; $scope.team_id = id; - Rest.setUrl(defaultUrl); - Wait('start'); - Rest.get(defaultUrl).then(({data}) => { - setScopeFields(data); - $scope.organization_name = data.summary_fields.organization.name; + setScopeFields(data); + $scope.organization_name = data.summary_fields.organization.name; - OrgAdminLookup.checkForAdminAccess({organization: data.organization}) + OrgAdminLookup.checkForAdminAccess({organization: data.organization}) .then(function(canEditOrg){ $scope.canEditOrg = canEditOrg; }); - $scope.team_obj = data; - Wait('stop'); - }); + $scope.team_obj = data; $scope.$watch('team_obj.summary_fields.user_capabilities.edit', function(val) { $scope.canAdd = (val === false) ? false : true; }); - - } // @issue I think all this really want to do is _.forEach(form.fields, (field) =>{ $scope[field] = data[field]}) From e04a07f56c788242740433550c2b4e757821c918 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 24 May 2018 13:56:32 -0400 Subject: [PATCH 052/762] cover testing of new 3.3 org roles for user security fix --- awx/main/access.py | 10 +++------- awx/main/tests/functional/test_rbac_role.py | 1 + 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 47534928bd..be77c90a38 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -519,12 +519,7 @@ class UserAccess(BaseAccess): def user_membership_roles(self, u): return Role.objects.filter( content_type=ContentType.objects.get_for_model(Organization), - role_field__in=[ - 'admin_role', 'member_role', - 'execute_role', 'project_admin_role', 'inventory_admin_role', - 'credential_admin_role', 'workflow_admin_role', - 'notification_admin_role' - ], + role_field__in=Organization.member_role.field.parent_role + ['member_role'], members=u ) @@ -2531,7 +2526,8 @@ class RoleAccess(BaseAccess): # administrators of that Organization the ability to edit that user. To prevent # unwanted escalations lets ensure that the Organization administartor has the abilty # to admin the user being added to the role. - if isinstance(obj.content_object, Organization) and obj.role_field in ['member_role', 'admin_role']: + if (isinstance(obj.content_object, Organization) and + obj.role_field in (Organization.member_role.field.parent_role + ['member_role'])): if not UserAccess(self.user).can_admin(sub_obj, None, allow_orphans=True): return False diff --git a/awx/main/tests/functional/test_rbac_role.py b/awx/main/tests/functional/test_rbac_role.py index abaa8a4410..7cbea31f8a 100644 --- a/awx/main/tests/functional/test_rbac_role.py +++ b/awx/main/tests/functional/test_rbac_role.py @@ -67,6 +67,7 @@ def test_org_user_role_attach(user, organization, inventory): role_access = RoleAccess(admin) assert not role_access.can_attach(organization.member_role, nonmember, 'members', None) + assert not role_access.can_attach(organization.notification_admin_role, nonmember, 'members', None) assert not role_access.can_attach(organization.admin_role, nonmember, 'members', None) From 3d5605f4b520714144e0ac2ffb91d4be27efafe0 Mon Sep 17 00:00:00 2001 From: adamscmRH Date: Thu, 24 May 2018 14:33:27 -0400 Subject: [PATCH 053/762] refactor & purge cruft --- awx/api/serializers.py | 136 +++++-------------- awx/api/urls/user.py | 2 +- awx/api/urls/user_oauth.py | 2 +- awx/api/views.py | 19 +-- awx/main/tests/functional/test_rbac_oauth.py | 4 +- 5 files changed, 39 insertions(+), 124 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c518a4514c..9348a6c37e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -948,7 +948,7 @@ class UserSerializer(BaseSerializer): access_list = self.reverse('api:user_access_list', kwargs={'pk': obj.pk}), tokens = self.reverse('api:o_auth2_token_list', kwargs={'pk': obj.pk}), authorized_tokens = self.reverse('api:user_authorized_token_list', kwargs={'pk': obj.pk}), - personal_tokens = self.reverse('api:o_auth2_personal_token_list', kwargs={'pk': obj.pk}), + personal_tokens = self.reverse('api:user_personal_token_list', kwargs={'pk': obj.pk}), )) return res @@ -1013,10 +1013,30 @@ class BaseOAuth2TokenSerializer(BaseSerializer): except ObjectDoesNotExist: return '' - def get_modified(self, obj): - if obj is None: + def get_refresh_token(self, obj): + request = self.context.get('request', None) + try: + if not obj.refresh_token: + return None + elif request.method == 'POST': + return getattr(obj.refresh_token, 'token', '') + else: + return TOKEN_CENSOR + except ObjectDoesNotExist: return None - return obj.updated + + def get_related(self, obj): + ret = super(BaseOAuth2TokenSerializer, self).get_related(obj) + if obj.user: + ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) + if obj.application: + ret['application'] = self.reverse( + 'api:o_auth2_application_detail', kwargs={'pk': obj.application.pk} + ) + ret['activity_stream'] = self.reverse( + 'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk} + ) + return ret def _is_valid_scope(self, value): if not value or (not isinstance(value, six.string_types)): @@ -1038,16 +1058,13 @@ class BaseOAuth2TokenSerializer(BaseSerializer): class UserAuthorizedTokenSerializer(BaseOAuth2TokenSerializer): - - def get_refresh_token(self, obj): - request = self.context.get('request', None) - try: - if request.method == 'POST': - return getattr(obj.refresh_token, 'token', '') - else: - return TOKEN_CENSOR - except ObjectDoesNotExist: - return '' + + class Meta: + extra_kwargs = { + 'scope': {'allow_null': False, 'required': True}, + 'user': {'allow_null': False, 'required': True}, + 'application': {'allow_null': False, 'required': True} + } def create(self, validated_data): current_user = self.context['request'].user @@ -1070,36 +1087,6 @@ class UserAuthorizedTokenSerializer(BaseOAuth2TokenSerializer): class OAuth2TokenSerializer(BaseOAuth2TokenSerializer): - def get_modified(self, obj): - if obj is None: - return None - return obj.updated - - def get_related(self, obj): - ret = super(OAuth2TokenSerializer, self).get_related(obj) - if obj.user: - ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) - if obj.application: - ret['application'] = self.reverse( - 'api:o_auth2_application_detail', kwargs={'pk': obj.application.pk} - ) - ret['activity_stream'] = self.reverse( - 'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk} - ) - return ret - - def get_refresh_token(self, obj): - request = self.context.get('request', None) - try: - if request.method == 'POST': - return getattr(obj.refresh_token, 'token', '') - elif not obj.refresh_token: - return None - else: - return TOKEN_CENSOR - except ObjectDoesNotExist: - return None - def create(self, validated_data): current_user = self.context['request'].user validated_data['user'] = current_user @@ -1127,56 +1114,11 @@ class OAuth2TokenDetailSerializer(OAuth2TokenSerializer): read_only_fields = ('*', 'user', 'application') -class OAuth2AuthorizedTokenSerializer(BaseOAuth2TokenSerializer): - - def get_refresh_token(self, obj): - request = self.context.get('request', None) - try: - if request.method == 'POST': - return getattr(obj.refresh_token, 'token', '') - else: - return TOKEN_CENSOR - except ObjectDoesNotExist: - return '' - - def create(self, validated_data): - current_user = self.context['request'].user - validated_data['user'] = current_user - validated_data['token'] = generate_token() - validated_data['expires'] = now() + timedelta( - seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] - ) - obj = super(OAuth2AuthorizedTokenSerializer, self).create(validated_data) - if obj.application and obj.application.user: - obj.user = obj.application.user - obj.save() - if obj.application is not None: - RefreshToken.objects.create( - user=current_user, - token=generate_token(), - application=obj.application, - access_token=obj - ) - return obj - - -class OAuth2PersonalTokenSerializer(BaseOAuth2TokenSerializer): +class UserPersonalTokenSerializer(BaseOAuth2TokenSerializer): class Meta: read_only_fields = ('user', 'token', 'expires', 'application') - def get_related(self, obj): - ret = super(OAuth2PersonalTokenSerializer, self).get_related(obj) - if obj.user: - ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) - ret['activity_stream'] = self.reverse( - 'api:o_auth2_token_activity_stream_list', kwargs={'pk': obj.pk} - ) - return ret - - def get_refresh_token(self, obj): - return None - def create(self, validated_data): validated_data['user'] = self.context['request'].user validated_data['token'] = generate_token() @@ -1184,7 +1126,7 @@ class OAuth2PersonalTokenSerializer(BaseOAuth2TokenSerializer): seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] ) validated_data['application'] = None - obj = super(OAuth2PersonalTokenSerializer, self).create(validated_data) + obj = super(UserPersonalTokenSerializer, self).create(validated_data) obj.save() return obj @@ -1218,18 +1160,6 @@ class OAuth2ApplicationSerializer(BaseSerializer): return None return obj.updated - def get_related(self, obj): - ret = super(OAuth2ApplicationSerializer, self).get_related(obj) - if obj.user: - ret['user'] = self.reverse('api:user_detail', kwargs={'pk': obj.user.pk}) - ret['tokens'] = self.reverse( - 'api:o_auth2_application_token_list', kwargs={'pk': obj.pk} - ) - ret['activity_stream'] = self.reverse( - 'api:o_auth2_application_activity_stream_list', kwargs={'pk': obj.pk} - ) - return ret - def _summary_field_tokens(self, obj): token_list = [{'id': x.pk, 'token': TOKEN_CENSOR, 'scope': x.scope} for x in obj.oauth2accesstoken_set.all()[:10]] if has_model_field_prefetched(obj, 'oauth2accesstoken_set'): diff --git a/awx/api/urls/user.py b/awx/api/urls/user.py index 9ecebbb044..c3c896af24 100644 --- a/awx/api/urls/user.py +++ b/awx/api/urls/user.py @@ -34,7 +34,7 @@ urls = [ url(r'^(?P[0-9]+)/applications/$', OAuth2ApplicationList.as_view(), name='o_auth2_application_list'), url(r'^(?P[0-9]+)/tokens/$', OAuth2UserTokenList.as_view(), name='o_auth2_token_list'), url(r'^(?P[0-9]+)/authorized_tokens/$', UserAuthorizedTokenList.as_view(), name='user_authorized_token_list'), - url(r'^(?P[0-9]+)/personal_tokens/$', OAuth2PersonalTokenList.as_view(), name='o_auth2_personal_token_list'), + url(r'^(?P[0-9]+)/personal_tokens/$', OAuth2PersonalTokenList.as_view(), name='user_personal_token_list'), ] diff --git a/awx/api/urls/user_oauth.py b/awx/api/urls/user_oauth.py index bec5c4332b..3b290dbf01 100644 --- a/awx/api/urls/user_oauth.py +++ b/awx/api/urls/user_oauth.py @@ -43,7 +43,7 @@ urls = [ OAuth2TokenActivityStreamList.as_view(), name='o_auth2_token_activity_stream_list' ), - url(r'^personal_tokens/$', OAuth2PersonalTokenList.as_view(), name='o_auth2_personal_token_list'), + url(r'^personal_tokens/$', OAuth2PersonalTokenList.as_view(), name='user_personal_token_list'), ] __all__ = ['urls'] diff --git a/awx/api/views.py b/awx/api/views.py index 5f1d8b22af..bd789ef9b7 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1610,21 +1610,6 @@ class OAuth2UserTokenList(SubListCreateAPIView): relationship = 'main_oauth2accesstoken' parent_key = 'user' swagger_topic = 'Authentication' - - -class OAuth2AuthorizedTokenList(SubListCreateAPIView): - - view_name = _("OAuth2 Authorized Access Tokens") - - model = OAuth2AccessToken - serializer_class = OAuth2AuthorizedTokenSerializer - parent_model = OAuth2Application - relationship = 'oauth2accesstoken_set' - parent_key = 'application' - swagger_topic = 'Authentication' - - def get_queryset(self): - return get_access_token_model().objects.filter(application__isnull=False, user=self.request.user) class UserAuthorizedTokenList(SubListCreateAPIView): @@ -1632,7 +1617,7 @@ class UserAuthorizedTokenList(SubListCreateAPIView): view_name = _("OAuth2 User Authorized Access Tokens") model = OAuth2AccessToken - serializer_class = OAuth2AuthorizedTokenSerializer + serializer_class = UserAuthorizedTokenSerializer parent_model = User relationship = 'oauth2accesstoken_set' parent_key = 'user' @@ -1659,7 +1644,7 @@ class OAuth2PersonalTokenList(SubListCreateAPIView): view_name = _("OAuth2 Personal Access Tokens") model = OAuth2AccessToken - serializer_class = OAuth2PersonalTokenSerializer + serializer_class = UserPersonalTokenSerializer parent_model = User relationship = 'main_oauth2accesstoken' parent_key = 'user' diff --git a/awx/main/tests/functional/test_rbac_oauth.py b/awx/main/tests/functional/test_rbac_oauth.py index 757c55e12b..f076db3689 100644 --- a/awx/main/tests/functional/test_rbac_oauth.py +++ b/awx/main/tests/functional/test_rbac_oauth.py @@ -200,7 +200,7 @@ class TestOAuth2Token: user_list = [admin, org_admin, org_member, alice] can_access_list = [True, False, True, False] response = post( - reverse('api:o_auth2_personal_token_list', kwargs={'pk': org_member.pk}), + reverse('api:user_personal_token_list', kwargs={'pk': org_member.pk}), {'scope': 'read'}, org_member, expect=201 ) token = AccessToken.objects.get(token=response.data['token']) @@ -220,7 +220,7 @@ class TestOAuth2Token: for user, can_access in zip(user_list, can_access_list): response = post( - reverse('api:o_auth2_personal_token_list', kwargs={'pk': user.pk}), + reverse('api:user_personal_token_list', kwargs={'pk': user.pk}), {'scope': 'read', 'application':None}, user, expect=201 ) token = AccessToken.objects.get(token=response.data['token']) From c1593935ca8e469f9a7aa2206e540009620ed8ee Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 21 May 2018 13:37:02 -0400 Subject: [PATCH 054/762] Update active row indicator when state param id changes --- .../features/templates/templatesList.controller.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/features/templates/templatesList.controller.js b/awx/ui/client/features/templates/templatesList.controller.js index 5ad7388fd4..4f0ec55116 100644 --- a/awx/ui/client/features/templates/templatesList.controller.js +++ b/awx/ui/client/features/templates/templatesList.controller.js @@ -35,7 +35,7 @@ function ListTemplatesController( vm.strings = strings; vm.templateTypes = mapChoices(choices); - vm.activeId = parseInt($state.params.job_template_id || $state.params.workflow_template_id); + vm.activeId = parseInt($state.params.job_template_id || $state.params.workflow_job_template_id); vm.invalidTooltip = { popover: { text: strings.get('error.INVALID'), @@ -61,6 +61,16 @@ function ListTemplatesController( }; $scope.template_dataset = Dataset.data; $scope.templates = Dataset.data.results; + + $scope.$watch('$state.params', function(newValue, oldValue) { + const job_template_id = _.get($state.params, 'job_template_id'); + const workflow_job_template_id = _.get($state.params, 'workflow_job_template_id'); + + if((job_template_id || workflow_job_template_id) && (newValue !== oldValue)) { + vm.activeId = parseInt($state.params.job_template_id || $state.params.workflow_job_template_id); + } + }, true); + $scope.$on('updateDataset', (e, dataset) => { $scope.template_dataset = dataset; $scope.templates = dataset.results; From bb6a4f696462b5f50e78ea8b0df20496778f6a91 Mon Sep 17 00:00:00 2001 From: adamscmRH Date: Thu, 24 May 2018 15:28:17 -0400 Subject: [PATCH 055/762] fix oauth urls & rename for clarity --- awx/api/urls/{user_oauth.py => oauth2.py} | 4 +--- awx/api/urls/{oauth.py => oauth2_root.py} | 0 awx/api/urls/urls.py | 8 ++++---- awx/api/urls/user.py | 4 ++-- awx/api/views.py | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) rename awx/api/urls/{user_oauth.py => oauth2.py} (90%) rename awx/api/urls/{oauth.py => oauth2_root.py} (100%) diff --git a/awx/api/urls/user_oauth.py b/awx/api/urls/oauth2.py similarity index 90% rename from awx/api/urls/user_oauth.py rename to awx/api/urls/oauth2.py index 3b290dbf01..6e9eea3d9f 100644 --- a/awx/api/urls/user_oauth.py +++ b/awx/api/urls/oauth2.py @@ -11,7 +11,6 @@ from awx.api.views import ( OAuth2TokenList, OAuth2TokenDetail, OAuth2TokenActivityStreamList, - OAuth2PersonalTokenList ) @@ -42,8 +41,7 @@ urls = [ r'^tokens/(?P[0-9]+)/activity_stream/$', OAuth2TokenActivityStreamList.as_view(), name='o_auth2_token_activity_stream_list' - ), - url(r'^personal_tokens/$', OAuth2PersonalTokenList.as_view(), name='user_personal_token_list'), + ), ] __all__ = ['urls'] diff --git a/awx/api/urls/oauth.py b/awx/api/urls/oauth2_root.py similarity index 100% rename from awx/api/urls/oauth.py rename to awx/api/urls/oauth2_root.py diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index e282a73e5f..52e9ef1cf0 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -67,8 +67,8 @@ from .schedule import urls as schedule_urls from .activity_stream import urls as activity_stream_urls from .instance import urls as instance_urls from .instance_group import urls as instance_group_urls -from .user_oauth import urls as user_oauth_urls -from .oauth import urls as oauth_urls +from .oauth2 import urls as oauth2_urls +from .oauth2_root import urls as oauth2_root_urls v1_urls = [ @@ -130,7 +130,7 @@ v2_urls = [ url(r'^applications/(?P[0-9]+)/$', OAuth2ApplicationDetail.as_view(), name='o_auth2_application_detail'), url(r'^applications/(?P[0-9]+)/tokens/$', ApplicationOAuth2TokenList.as_view(), name='application_o_auth2_token_list'), url(r'^tokens/$', OAuth2TokenList.as_view(), name='o_auth2_token_list'), - url(r'^', include(user_oauth_urls)), + url(r'^', include(oauth2_urls)), ] app_name = 'api' @@ -145,7 +145,7 @@ urlpatterns = [ url(r'^logout/$', LoggedLogoutView.as_view( next_page='/api/', redirect_field_name='next' ), name='logout'), - url(r'^o/', include(oauth_urls)), + url(r'^o/', include(oauth2_root_urls)), ] if settings.SETTINGS_MODULE == 'awx.settings.development': from awx.api.swagger import SwaggerSchemaView diff --git a/awx/api/urls/user.py b/awx/api/urls/user.py index c3c896af24..ca8d531f46 100644 --- a/awx/api/urls/user.py +++ b/awx/api/urls/user.py @@ -16,7 +16,7 @@ from awx.api.views import ( UserAccessList, OAuth2ApplicationList, OAuth2UserTokenList, - OAuth2PersonalTokenList, + UserPersonalTokenList, UserAuthorizedTokenList, ) @@ -34,7 +34,7 @@ urls = [ url(r'^(?P[0-9]+)/applications/$', OAuth2ApplicationList.as_view(), name='o_auth2_application_list'), url(r'^(?P[0-9]+)/tokens/$', OAuth2UserTokenList.as_view(), name='o_auth2_token_list'), url(r'^(?P[0-9]+)/authorized_tokens/$', UserAuthorizedTokenList.as_view(), name='user_authorized_token_list'), - url(r'^(?P[0-9]+)/personal_tokens/$', OAuth2PersonalTokenList.as_view(), name='user_personal_token_list'), + url(r'^(?P[0-9]+)/personal_tokens/$', UserPersonalTokenList.as_view(), name='user_personal_token_list'), ] diff --git a/awx/api/views.py b/awx/api/views.py index bd789ef9b7..d319cb88df 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1639,7 +1639,7 @@ class OrganizationApplicationList(SubListCreateAPIView): swagger_topic = 'Authentication' -class OAuth2PersonalTokenList(SubListCreateAPIView): +class UserPersonalTokenList(SubListCreateAPIView): view_name = _("OAuth2 Personal Access Tokens") From 1790b1703f1985be8668ae1da303d27dcd4b1119 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 24 May 2018 15:48:13 -0400 Subject: [PATCH 056/762] ignore new test artifact being produced --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b1674af811..137c233c19 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ pep8.txt scratch testem.log awx/awx_test.sqlite3-journal +.pytest_cache/ # Mac OS X *.DS_Store From 930ecaec3e207c516d04a16293859d6d45507e22 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 24 May 2018 14:08:18 -0400 Subject: [PATCH 057/762] organize and translate job output strings --- .../features/output/details.component.js | 90 +++++++++-------- .../features/output/details.partial.html | 26 ++--- .../features/output/index.controller.js | 27 +++-- awx/ui/client/features/output/index.js | 6 +- awx/ui/client/features/output/index.view.html | 2 +- awx/ui/client/features/output/jobs.strings.js | 35 ------- .../client/features/output/output.strings.js | 99 +++++++++++++++++++ .../features/output/search.component.js | 21 ++-- .../features/output/search.partial.html | 29 ++++-- .../client/features/output/stats.component.js | 13 ++- .../client/features/output/stats.partial.html | 9 +- .../client/features/output/status.service.js | 5 + 12 files changed, 225 insertions(+), 137 deletions(-) delete mode 100644 awx/ui/client/features/output/jobs.strings.js create mode 100644 awx/ui/client/features/output/output.strings.js diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 84a97ed050..512e68985a 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -27,7 +27,7 @@ function getStatusDetails (jobStatus) { const choices = mapChoices(resource.model.options('actions.GET.status.choices')); - const label = 'Status'; + const label = strings.get('labels.STATUS'); const icon = `fa icon-job-${unmapped}`; const value = choices[unmapped]; @@ -36,15 +36,14 @@ function getStatusDetails (jobStatus) { function getStartDetails (started) { const unfiltered = started || resource.model.get('started'); - - const label = 'Started'; + const label = strings.get('labels.STARTED'); let value; if (unfiltered) { value = $filter('longDate')(unfiltered); } else { - value = 'Not Started'; + value = strings.get('details.NOT_STARTED'); } return { label, value }; @@ -52,15 +51,14 @@ function getStartDetails (started) { function getFinishDetails (finished) { const unfiltered = finished || resource.model.get('finished'); - - const label = 'Finished'; + const label = strings.get('labels.FINISHED'); let value; if (unfiltered) { value = $filter('longDate')(unfiltered); } else { - value = 'Not Finished'; + value = strings.get('details.NOT_FINISHED'); } return { label, value }; @@ -68,7 +66,7 @@ function getFinishDetails (finished) { function getModuleArgDetails () { const value = resource.model.get('module_args'); - const label = 'Module Args'; + const label = strings.get('labels.MODULE_ARGS'); if (!value) { return null; @@ -86,7 +84,7 @@ function getJobTypeDetails () { const choices = mapChoices(resource.model.options('actions.GET.job_type.choices')); - const label = 'Job Type'; + const label = strings.get('labels.JOB_TYPE'); const value = choices[unmapped]; return { label, value }; @@ -101,7 +99,7 @@ function getVerbosityDetails () { const choices = mapChoices(resource.model.options('actions.GET.verbosity.choices')); - const label = 'Verbosity'; + const label = strings.get('labels.VERBOSITY'); const value = choices[verbosity]; return { label, value }; @@ -115,7 +113,7 @@ function getSourceWorkflowJobDetails () { } const link = `/#/workflows/${sourceWorkflowJob.id}`; - const tooltip = strings.get('resourceTooltips.SOURCE_WORKFLOW_JOB'); + const tooltip = strings.get('tooltips.SOURCE_WORKFLOW_JOB'); return { link, tooltip }; } @@ -127,10 +125,10 @@ function getJobTemplateDetails () { return null; } - const label = 'Job Template'; + const label = strings.get('labels.JOB_TEMPLATE'); const link = `/#/templates/job_template/${jobTemplate.id}`; const value = $filter('sanitize')(jobTemplate.name); - const tooltip = strings.get('resourceTooltips.JOB_TEMPLATE'); + const tooltip = strings.get('tooltips.JOB_TEMPLATE'); return { label, link, value, tooltip }; } @@ -172,8 +170,8 @@ function getInventoryJobNameDetails () { const name = resource.model.get('name'); const id = resource.model.get('id'); - const label = 'Name'; - const tooltip = strings.get('resourceTooltips.INVENTORY'); + const label = strings.get('labels.NAME'); + const tooltip = strings.get('tooltips.INVENTORY'); const value = `${id} - ${$filter('sanitize')(name)}`; const link = `/#/inventories/inventory/${inventoryId}`; @@ -188,7 +186,7 @@ function getInventorySourceDetails () { const { source } = resource.model.get('summary_fields.inventory_source'); const choices = mapChoices(resource.model.options('actions.GET.source.choices')); - const label = 'Source'; + const label = strings.get('labels.SOURCE'); const value = choices[source]; return { label, value }; @@ -199,7 +197,7 @@ function getOverwriteDetails () { return null; } - const label = 'Overwrite'; + const label = strings.get('labels.OVERWRITE'); const value = resource.model.get('overwrite'); return { label, value }; @@ -210,7 +208,7 @@ function getOverwriteVarsDetails () { return null; } - const label = 'Overwrite Vars'; + const label = strings.get('labels.OVERWRITE_VARS'); const value = resource.model.get('overwrite_vars'); return { label, value }; @@ -221,7 +219,7 @@ function getLicenseErrorDetails () { return null; } - const label = 'License Error'; + const label = strings.get('labels.LICENSE_ERROR'); const value = resource.model.get('license_error'); return { label, value }; @@ -230,7 +228,6 @@ function getLicenseErrorDetails () { function getLaunchedByDetails () { const createdBy = resource.model.get('summary_fields.created_by'); const jobTemplate = resource.model.get('summary_fields.job_template'); - const relatedSchedule = resource.model.get('related.schedule'); const schedule = resource.model.get('summary_fields.schedule'); @@ -238,18 +235,18 @@ function getLaunchedByDetails () { return null; } - const label = 'Launched By'; + const label = strings.get('labels.LAUNCHED_BY'); let link; let tooltip; let value; if (createdBy) { - tooltip = strings.get('resourceTooltips.USER'); + tooltip = strings.get('tooltips.USER'); link = `/#/users/${createdBy.id}`; value = $filter('sanitize')(createdBy.username); } else if (relatedSchedule && jobTemplate) { - tooltip = strings.get('resourceTooltips.SCHEDULE'); + tooltip = strings.get('tooltips.SCHEDULE'); link = `/#/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`; value = $filter('sanitize')(schedule.name); } else { @@ -268,8 +265,8 @@ function getInventoryDetails () { return null; } - const label = 'Inventory'; - const tooltip = strings.get('resourceTooltips.INVENTORY'); + const label = strings.get('labels.INVENTORY'); + const tooltip = strings.get('tooltips.INVENTORY'); const value = $filter('sanitize')(inventory.name); let link; @@ -290,10 +287,10 @@ function getProjectDetails () { return null; } - const label = 'Project'; + const label = strings.get('labels.PROJECT'); const link = `/#/projects/${project.id}`; const value = $filter('sanitize')(project.name); - const tooltip = strings.get('resourceTooltips.PROJECT'); + const tooltip = strings.get('tooltips.PROJECT'); return { label, link, value, tooltip }; } @@ -318,13 +315,13 @@ function getProjectUpdateDetails (updateId) { } const link = `/#/jobs/project/${jobId}`; - const tooltip = strings.get('resourceTooltips.PROJECT_UPDATE'); + const tooltip = strings.get('tooltips.PROJECT_UPDATE'); return { link, tooltip }; } function getSCMRevisionDetails () { - const label = 'Revision'; + const label = strings.get('labels.SCM_REVISION'); const value = resource.model.get('scm_revision'); if (!value) { @@ -335,7 +332,7 @@ function getSCMRevisionDetails () { } function getPlaybookDetails () { - const label = 'Playbook'; + const label = strings.get('labels.PLAYBOOK'); const value = resource.model.get('playbook'); if (!value) { @@ -353,7 +350,7 @@ function getJobExplanationDetails () { } const limit = 150; - const label = 'Explanation'; + const label = strings.get('labels.JOB_EXPLANATION'); let more = explanation; @@ -380,7 +377,7 @@ function getResultTracebackDetails () { } const limit = 150; - const label = 'Error Details'; + const label = strings.get('labels.RESULT_TRACEBACK'); const more = traceback; const less = $filter('limitTo')(more, limit); @@ -398,25 +395,25 @@ function getCredentialDetails () { return null; } - let label = 'Credential'; + let label = strings.get('labels.CREDENTIAL'); if (resource.type === 'playbook') { - label = 'Machine Credential'; + label = strings.get('labels.MACHINE_CREDENTIAL'); } if (resource.type === 'inventory') { - label = 'Source Credential'; + label = strings.get('labels.SOURCE_CREDENTIAL'); } const link = `/#/credentials/${credential.id}`; - const tooltip = strings.get('resourceTooltips.CREDENTIAL'); + const tooltip = strings.get('tooltips.CREDENTIAL'); const value = $filter('sanitize')(credential.name); return { label, link, tooltip, value }; } function getForkDetails () { - const label = 'Forks'; + const label = strings.get('labels.FORKS'); const value = resource.model.get('forks'); if (!value) { @@ -427,7 +424,7 @@ function getForkDetails () { } function getLimitDetails () { - const label = 'Limit'; + const label = strings.get('labels.LIMIT'); const value = resource.model.get('limit'); if (!value) { @@ -444,13 +441,13 @@ function getInstanceGroupDetails () { return null; } - const label = 'Instance Group'; + const label = strings.get('labels.INSTANCE_GROUP'); const value = $filter('sanitize')(instanceGroup.name); let isolated = null; if (instanceGroup.controller_id) { - isolated = 'Isolated'; + isolated = strings.get('details.ISOLATED'); } return { label, value, isolated }; @@ -471,7 +468,7 @@ function getJobTagDetails () { return null; } - const label = 'Job Tags'; + const label = strings.get('labels.JOB_TAGS'); const more = false; const value = jobTags.map($filter('sanitize')); @@ -494,8 +491,8 @@ function getSkipTagDetails () { return null; } - const label = 'Skip Tags'; const more = false; + const label = strings.get('labels.SKIP_TAGS'); const value = skipTags.map($filter('sanitize')); return { label, more, value }; @@ -508,8 +505,8 @@ function getExtraVarsDetails () { return null; } - const label = 'Extra Variables'; - const tooltip = 'Read-only view of extra variables added to the job template.'; + const label = strings.get('labels.EXTRA_VARS'); + const tooltip = strings.get('tooltips.EXTRA_VARS'); const value = parse(extraVars); const disabled = true; @@ -523,7 +520,7 @@ function getLabelDetails () { return null; } - const label = 'Labels'; + const label = strings.get('labels.LABELS'); const more = false; const value = jobLabels.map(({ name }) => name).map($filter('sanitize')); @@ -663,6 +660,7 @@ function JobDetailsController ( vm.$onInit = () => { resource = this.resource; // eslint-disable-line prefer-destructuring + vm.strings = strings; vm.status = getStatusDetails(); vm.started = getStartDetails(); @@ -726,7 +724,7 @@ JobDetailsController.$inject = [ '$state', 'ProcessErrors', 'Prompt', - 'JobStrings', + 'OutputStrings', 'Wait', 'ParseVariableString', 'JobStatusService', diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 1bc8acc7ee..1913c4c074 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -1,6 +1,6 @@
-
DETAILS
+
{{:: vm.strings.get('details.HEADER')}}
@@ -14,7 +14,7 @@ ng-show="vm.status.value === 'Pending' || vm.status.value === 'Waiting' || vm.status.value === 'Running'" - aw-tool-tip="{{'Cancel' | translate }}" + aw-tool-tip="{{:: vm.strings.get('tooltips.CANCEL') }}" data-original-title="" title=""> @@ -31,7 +31,7 @@ vm.status.value === 'Failed' || vm.status.value === 'Error' || vm.status.value === 'Canceled')" - aw-tool-tip="{{ 'Delete' | translate }}" + aw-tool-tip="{{:: vm.strings.get('tooltips.DELETE') }}" data-original-title="" title=""> @@ -71,7 +71,7 @@ - Show More + {{:: vm.strings.get('details.SHOW_MORE') }}
- Show Less + {{:: vm.strings.get('details.SHOW_LESS') }}
@@ -124,7 +124,7 @@ - Show More + {{:: vm.strings.get('details.SHOW_MORE') }}
- Show Less + {{:: vm.strings.get('details.SHOW_LESS') }}
@@ -298,14 +298,14 @@ ng-show="vm.labels.more" href="" ng-click="vm.toggleLabels()"> - {{ vm.labels.label }} + {{ vm.labels.label }} - {{ vm.labels.label }} + {{ vm.labels.label }} @@ -323,14 +323,14 @@ ng-show="vm.jobTags.more" href="" ng-click="vm.toggleJobTags()"> - {{ vm.jobTags.label }} + {{ vm.jobTags.label }} - {{ vm.jobTags.label }} + {{ vm.jobTags.label }} @@ -348,14 +348,14 @@ ng-show="vm.skipTags.more" href="" ng-click="vm.toggleSkipTags()"> - {{ vm.skipTags.label }} + {{ vm.skipTags.label }} - {{ vm.skipTags.label }} + {{ vm.skipTags.label }} diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 9663749557..6c50a10abc 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -1,6 +1,8 @@ let $compile; +let $filter; let $q; let $scope; + let page; let render; let resource; @@ -13,29 +15,34 @@ let streaming; let listeners = []; function JobsIndexController ( + _$compile_, + _$filter_, + _$q_, + _$scope_, _resource_, _page_, _scroll_, _render_, _engine_, - _$scope_, - _$compile_, - _$q_, _status_, + _strings_, ) { vm = this || {}; $compile = _$compile_; - $scope = _$scope_; + $filter = _$filter_; $q = _$q_; - resource = _resource_; + $scope = _$scope_; + resource = _resource_; page = _page_; scroll = _scroll_; render = _render_; engine = _engine_; status = _status_; + vm.strings = _strings_; + // Development helper(s) vm.clear = devClear; @@ -45,7 +52,7 @@ function JobsIndexController ( // Panel vm.resource = resource; - vm.title = resource.model.get('name'); + vm.title = $filter('sanitize')(resource.model.get('name')); // Stdout Navigation vm.scroll = { @@ -386,15 +393,17 @@ function devClear () { // } JobsIndexController.$inject = [ + '$compile', + '$filter', + '$q', + '$scope', 'resource', 'JobPageService', 'JobScrollService', 'JobRenderService', 'JobEventEngine', - '$scope', - '$compile', - '$q', 'JobStatusService', + 'OutputStrings', ]; module.exports = JobsIndexController; diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index e26112915b..de29d1b4c6 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -1,7 +1,7 @@ import atLibModels from '~models'; import atLibComponents from '~components'; -import Strings from '~features/output/jobs.strings'; +import Strings from '~features/output/output.strings'; import Controller from '~features/output/index.controller'; import PageService from '~features/output/page.service'; import RenderService from '~features/output/render.service'; @@ -241,7 +241,7 @@ function JobsRun ($stateRegistry, $filter, strings) { $stateRegistry.register(state); } -JobsRun.$inject = ['$stateRegistry', '$filter', 'JobStrings']; +JobsRun.$inject = ['$stateRegistry', '$filter', 'OutputStrings']; angular .module(MODULE_NAME, [ @@ -249,7 +249,7 @@ angular atLibComponents, HostEvent ]) - .service('JobStrings', Strings) + .service('OutputStrings', Strings) .service('JobPageService', PageService) .service('JobScrollService', ScrollService) .service('JobRenderService', RenderService) diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html index cb932fafd3..0b31859876 100644 --- a/awx/ui/client/features/output/index.view.html +++ b/awx/ui/client/features/output/index.view.html @@ -55,7 +55,7 @@

-

Back to Top

+

{{ vm.strings.get('stdout.BACK_TO_TOP') }}

diff --git a/awx/ui/client/features/output/jobs.strings.js b/awx/ui/client/features/output/jobs.strings.js deleted file mode 100644 index c581039172..0000000000 --- a/awx/ui/client/features/output/jobs.strings.js +++ /dev/null @@ -1,35 +0,0 @@ -function JobsStrings (BaseString) { - BaseString.call(this, 'jobs'); - - const { t } = this; - const ns = this.jobs; - - ns.state = { - BREADCRUMB_DEFAULT: t.s('RESULTS'), - }; - - ns.status = { - RUNNING: t.s('The host status bar will update when the job is complete.'), - UNAVAILABLE: t.s('Host status information for this job is unavailable.'), - }; - - ns.resourceTooltips = { - USER: t.s('View the User'), - SCHEDULE: t.s('View the Schedule'), - INVENTORY: t.s('View the Inventory'), - CREDENTIAL: t.s('View the Credential'), - JOB_TEMPLATE: t.s('View the Job Template'), - SOURCE_WORKFLOW_JOB: t.s('View the source Workflow Job'), - PROJECT: t.s('View the Project'), - PROJECT_UPDATE: t.s('View Project checkout results') - }; - - ns.expandCollapse = { - EXPAND: t.s('Expand Output'), - COLLAPSE: t.s('Collapse Output') - }; -} - -JobsStrings.$inject = ['BaseStringService']; - -export default JobsStrings; diff --git a/awx/ui/client/features/output/output.strings.js b/awx/ui/client/features/output/output.strings.js new file mode 100644 index 0000000000..f7b0a20565 --- /dev/null +++ b/awx/ui/client/features/output/output.strings.js @@ -0,0 +1,99 @@ +function OutputStrings (BaseString) { + BaseString.call(this, 'output'); + + const { t } = this; + const ns = this.output; + + ns.state = { + BREADCRUMB_DEFAULT: t.s('RESULTS'), + }; + + ns.status = { + RUNNING: t.s('The host status bar will update when the job is complete.'), + UNAVAILABLE: t.s('Host status information for this job is unavailable.'), + }; + + ns.tooltips = { + CANCEL: t.s('Cancel'), + COLLAPSE_OUTPUT: t.s('Collapse Output'), + DELETE: t.s('Delete'), + DOWNLOAD_OUTPUT: t.s('Download Output'), + CREDENTIAL: t.s('View the Credential'), + EXPAND_OUTPUT: t.s('Expand Output'), + EXTRA_VARS: t.s('Read-only view of extra variables added to the job template.'), + INVENTORY: t.s('View the Inventory'), + JOB_TEMPLATE: t.s('View the Job Template'), + PROJECT: t.s('View the Project'), + PROJECT_UPDATE: t.s('View Project checkout results'), + SCHEDULE: t.s('View the Schedule'), + SOURCE_WORKFLOW_JOB: t.s('View the source Workflow Job'), + USER: t.s('View the User'), + }; + + ns.details = { + HEADER: t.s('Details'), + ISOLATED: t.s('Isolated'), + NOT_FINISHED: t.s('Not Finished'), + NOT_STARTED: t.s('Not Started'), + SHOW_LESS: t.s('Show Less'), + SHOW_MORE: t.s('Show More'), + }; + + ns.labels = { + CREDENTIAL: t.s('Credential'), + EXTRA_VARS: t.s('Extra Variables'), + FINISHED: t.s('Finished'), + FORKS: t.s('Forks'), + INSTANCE_GROUP: t.s('Instance Group'), + INVENTORY: t.s('Inventory'), + JOB_EXPLANATION: t.s('Explanation'), + JOB_TAGS: t.s('Job Tags'), + JOB_TEMPLATE: t.s('Job Template'), + JOB_TYPE: t.s('Job Type'), + LABELS: t.s('Labels'), + LAUNCHED_BY: t.s('Launched By'), + LICENSE_ERROR: t.s('License Error'), + LIMIT: t.s('Limit'), + MACHINE_CREDENTIAL: t.s('Machine Credential'), + MODULE_ARGS: t.s('Module Args'), + NAME: t.s('Name'), + OVERWRITE: t.s('Overwrite'), + OVERWRITE_VARS: t.s('Overwrite Vars'), + PLAYBOOK: t.s('Playbook'), + PROJECT: t.s('Project'), + RESULT_TRACEBACK: t.s('Error Details'), + SCM_REVISION: t.s('Revision'), + SKIP_TAGS: t.s('Skip Tags'), + SOURCE: t.s('Source'), + SOURCE_CREDENTIAL: t.s('Source Credential'), + STARTED: t.s('Started'), + STATUS: t.s('Status'), + VERBOSITY: t.s('Verbosity'), + }; + + ns.search = { + ADDITIONAL_INFORMATION_HEADER: t.s('ADDITIONAL_INFORMATION'), + ADDITIONAL_INFORMATION: t.s('For additional information on advanced search search syntax please see the Ansible Tower'), + CLEAR_ALL: t.s('CLEAR ALL'), + DOCUMENTATION: t.s('documentation'), + EXAMPLES: t.s('EXAMPLES'), + FIELDS: t.s('FIELDS'), + KEY: t.s('KEY'), + PLACEHOLDER_DEFAULT: t.s('SEARCH'), + PLACEHOLDER_RUNNING: t.s('JOB IS STILL RUNNING'), + REJECT_DEFAULT: t.s('Failed to update search results.'), + REJECT_INVALID: t.s('Invalid search filter provided.'), + }; + + ns.stats = { + ELAPSED: t.s('Elapsed'), + }; + + ns.stdout = { + BACK_TO_TOP: t.s('Back to Top'), + }; +} + +OutputStrings.$inject = ['BaseStringService']; + +export default OutputStrings; diff --git a/awx/ui/client/features/output/search.component.js b/awx/ui/client/features/output/search.component.js index 4e9a2e8caf..bc446316f3 100644 --- a/awx/ui/client/features/output/search.component.js +++ b/awx/ui/client/features/output/search.component.js @@ -3,14 +3,11 @@ const templateUrl = require('~features/output/search.partial.html'); const searchReloadOptions = { inherit: false, location: 'replace' }; const searchKeyExamples = ['host_name:localhost', 'task:set', 'created:>=2000-01-01']; const searchKeyFields = ['changed', 'created', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play']; - -const PLACEHOLDER_RUNNING = 'CANNOT SEARCH RUNNING JOB'; -const PLACEHOLDER_DEFAULT = 'SEARCH'; -const REJECT_DEFAULT = 'Failed to update search results.'; -const REJECT_INVALID = 'Invalid search filter provided.'; +const searchKeyDocLink = 'https://docs.ansible.com/ansible-tower/3.3.0/html/userguide/search_sort.html'; let $state; let qs; +let strings; let vm; @@ -32,7 +29,7 @@ function getSearchTags (queryset) { .filter(tag => !tag.startsWith('order_by')); } -function reloadQueryset (queryset, rejection = REJECT_DEFAULT) { +function reloadQueryset (queryset, rejection = strings.get('search.REJECT_DEFAULT')) { const params = angular.copy($state.params); const currentTags = vm.tags; @@ -72,23 +69,25 @@ function submitSearch () { const searchInputQueryset = qs.getSearchInputQueryset(vm.value, isFilterable); const modifiedQueryset = qs.mergeQueryset(currentQueryset, searchInputQueryset); - reloadQueryset(modifiedQueryset, REJECT_INVALID); + reloadQueryset(modifiedQueryset, strings.get('search.REJECT_INVALID')); } function clearSearch () { reloadQueryset(); } -function JobSearchController (_$state_, _qs_, { subscribe }) { +function JobSearchController (_$state_, _qs_, _strings_, { subscribe }) { $state = _$state_; qs = _qs_; + strings = _strings_; vm = this || {}; + vm.strings = strings; vm.examples = searchKeyExamples; vm.fields = searchKeyFields; + vm.docLink = searchKeyDocLink; vm.relatedFields = []; - vm.placeholder = PLACEHOLDER_DEFAULT; vm.clearSearch = clearSearch; vm.toggleSearchKey = toggleSearchKey; @@ -103,11 +102,12 @@ function JobSearchController (_$state_, _qs_, { subscribe }) { vm.key = false; vm.rejected = false; vm.disabled = true; + vm.running = false; vm.tags = getSearchTags(getCurrentQueryset()); unsubscribe = subscribe(({ running }) => { vm.disabled = running; - vm.placeholder = running ? PLACEHOLDER_RUNNING : PLACEHOLDER_DEFAULT; + vm.running = running; }); }; @@ -119,6 +119,7 @@ function JobSearchController (_$state_, _qs_, { subscribe }) { JobSearchController.$inject = [ '$state', 'QuerySet', + 'OutputStrings', 'JobStatusService', ]; diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html index c209394815..b3937b17ee 100644 --- a/awx/ui/client/features/output/search.partial.html +++ b/awx/ui/client/features/output/search.partial.html @@ -5,7 +5,8 @@ class="form-control at-Input" ng-class="{ 'at-Input--rejected': vm.rejected }" ng-model="vm.value" - ng-attr-placeholder="{{ vm.placeholder }}" + ng-attr-placeholder="{{ vm.running ? vm.strings.get('search.PLACEHOLDER_RUNNING') : + vm.strings.get('search.PLACEHOLDER_DEFAULT') }}" ng-disabled="vm.disabled">
@@ -40,25 +43,31 @@ - +
-
EXAMPLES:
- +
+ {{:: vm.strings.get('search.EXAMPLES') }}: +
+
- FIELDS: + {{:: vm.strings.get('search.FIELDS') }}: {{ field }},
- ADDITIONAL INFORMATION: - For additional information on advanced search search syntax please see the Ansible Tower - documentation. + {{:: vm.strings.get('search.ADDITIONAL_INFORMATION_HEADER') }}: + {{:: vm.strings.get('search.ADDITIONAL_INFORMATION') }} + + {{:: vm.strings.get('search.DOCUMENTATION') }}. +
diff --git a/awx/ui/client/features/output/stats.component.js b/awx/ui/client/features/output/stats.component.js index a7a0ec61f3..d9051727de 100644 --- a/awx/ui/client/features/output/stats.component.js +++ b/awx/ui/client/features/output/stats.component.js @@ -11,6 +11,7 @@ function createStatsBarTooltip (key, count) { function JobStatsController (strings, { subscribe }) { vm = this || {}; + vm.strings = strings; let unsubscribe; @@ -21,7 +22,9 @@ function JobStatsController (strings, { subscribe }) { vm.$onInit = () => { vm.download = vm.resource.model.get('related.stdout'); - vm.toggleStdoutFullscreenTooltip = strings.get('expandCollapse.EXPAND'); + vm.tooltips.toggleExpand = vm.expanded ? + strings.get('tooltips.COLLAPSE_OUTPUT') : + strings.get('tooltips.EXPAND_OUTPUT'); unsubscribe = subscribe(({ running, elapsed, counts, stats, hosts }) => { vm.plays = counts.plays; @@ -52,14 +55,14 @@ function JobStatsController (strings, { subscribe }) { vm.toggleExpanded = () => { vm.expanded = !vm.expanded; - vm.toggleStdoutFullscreenTooltip = vm.expanded ? - strings.get('expandCollapse.COLLAPSE') : - strings.get('expandCollapse.EXPAND'); + vm.tooltips.toggleExpand = vm.expanded ? + strings.get('tooltips.COLLAPSE_OUTPUT') : + strings.get('tooltips.EXPAND_OUTPUT'); }; } JobStatsController.$inject = [ - 'JobStrings', + 'OutputStrings', 'JobStatusService', ]; diff --git a/awx/ui/client/features/output/stats.partial.html b/awx/ui/client/features/output/stats.partial.html index c55ba4bc8d..bbff1bb5a2 100644 --- a/awx/ui/client/features/output/stats.partial.html +++ b/awx/ui/client/features/output/stats.partial.html @@ -12,7 +12,7 @@ ... {{ vm.hosts }} - elapsed + {{ vm.strings.get('stats.ELAPSED') }} ... {{ vm.elapsed * 1000 | duration: "hh:mm:ss" }} @@ -20,16 +20,15 @@ @@ -51,7 +51,7 @@ ng-click="deleteJob()" ng-hide="workflow.status == 'running' || workflow.status == 'pending' " - aw-tool-tip="{{'Delete'|translate}}" + aw-tool-tip="{{ strings.tooltips.DELETE }}" data-original-title="" title=""> @@ -71,7 +71,7 @@ - {{ status_label }} + {{ strings.labels.STATUS }} @@ -79,10 +79,10 @@
- {{ workflow.started | longDate }} + {{ (workflow.started | longDate) || strings.details.NOT_STARTED }}
@@ -90,11 +90,11 @@
{{ (workflow.finished | - longDate) || "Not Finished" }} + longDate) || strings.details.NOT_FINISHED }}
@@ -102,11 +102,11 @@
{{ workflow.summary_fields.workflow_job_template.name }} @@ -117,11 +117,11 @@
{{ workflow.summary_fields.created_by.username }} @@ -133,11 +133,11 @@ ng-show="workflow.summary_fields.schedule.name">
{{ workflow.summary_fields.schedule.name }} @@ -163,7 +163,7 @@ ng-show="lessLabels" href="" ng-click="toggleLessLabels()"> - Labels + {{ strings.labels.LABELS }} @@ -172,7 +172,7 @@ ng-show="!lessLabels" href="" ng-click="toggleLessLabels()"> - Labels + {{ strings.labels.LABELS }} @@ -207,7 +207,7 @@ @@ -218,7 +218,7 @@
- Total Jobs + {{ strings.results.TOTAL_JOBS }}
{{ workflow_nodes.length || 0}} @@ -226,7 +226,7 @@
- Elapsed + {{ strings.results.ELAPSED }}
{{ workflow.elapsed * 1000 | duration: "hh:mm:ss"}} @@ -238,8 +238,8 @@ From e085c4ae714ba4eb927717eeed63ce83ccf72c2a Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 24 May 2018 20:10:00 -0400 Subject: [PATCH 060/762] use a one-way binding for back-to-top text --- awx/ui/client/features/output/index.view.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html index 0b31859876..bfe204958e 100644 --- a/awx/ui/client/features/output/index.view.html +++ b/awx/ui/client/features/output/index.view.html @@ -55,7 +55,7 @@

-

{{ vm.strings.get('stdout.BACK_TO_TOP') }}

+

{{:: vm.strings.get('stdout.BACK_TO_TOP') }}

From 46758f1e7e0cbcd86418336097d41b3bdcdc8415 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 24 May 2018 22:48:09 -0400 Subject: [PATCH 061/762] always discard events beneath line threshold --- awx/ui/client/features/output/engine.service.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/features/output/engine.service.js b/awx/ui/client/features/output/engine.service.js index cf77c276d3..bb884e52af 100644 --- a/awx/ui/client/features/output/engine.service.js +++ b/awx/ui/client/features/output/engine.service.js @@ -115,10 +115,11 @@ function JobEventEngine ($q) { this.chain = this.chain .then(() => { + if (data.end_line < this.lines.min) { + return $q.resolve(); + } + if (!this.isActive()) { - if (data.end_line < (this.lines.min)) { - return $q.resolve(); - } this.start(); } else if (data.event === JOB_END) { if (this.isPaused()) { From 3d8d27064f72f55e8b0e7569ae98713d7f46f6e4 Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Fri, 25 May 2018 00:18:50 -0700 Subject: [PATCH 062/762] Makes rows inactive on permissions list if assigning user doesn't have permission to edit the target user --- .../rbac-multiselect/permissionsUsers.list.js | 6 ++++++ .../rbac-multiselect-list.directive.js | 19 +++++++++++++++++-- .../inventories/inventory.list.js | 1 + .../list-generator/list-generator.factory.js | 10 +++++++--- .../select-list-item.directive.js | 5 +++-- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/awx/ui/client/src/access/rbac-multiselect/permissionsUsers.list.js b/awx/ui/client/src/access/rbac-multiselect/permissionsUsers.list.js index 39b083f06c..4a9b053cdd 100644 --- a/awx/ui/client/src/access/rbac-multiselect/permissionsUsers.list.js +++ b/awx/ui/client/src/access/rbac-multiselect/permissionsUsers.list.js @@ -16,6 +16,12 @@ index: false, hover: true, emptyListText : i18n._('No Users exist'), + disableRow: "{{ user.summary_fields.user_capabilities.edit === false }}", + disableRowValue: 'summary_fields.user_capabilities.edit === false', + disableTooltip: { + placement: 'top', + tipWatch: 'user.tooltip' + }, fields: { first_name: { label: i18n._('First Name'), diff --git a/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js b/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js index 8de176322c..2c3a7b9c8c 100644 --- a/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js +++ b/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js @@ -7,10 +7,10 @@ /* jshint unused: vars */ export default ['addPermissionsTeamsList', 'addPermissionsUsersList', 'TemplateList', 'ProjectList', 'InventoryList', 'CredentialList', '$compile', 'generateList', - 'OrganizationList', '$window', + 'OrganizationList', '$window', 'i18n', function(addPermissionsTeamsList, addPermissionsUsersList, TemplateList, ProjectList, InventoryList, CredentialList, $compile, generateList, - OrganizationList, $window) { + OrganizationList, $window, i18n) { return { restrict: 'E', scope: { @@ -159,6 +159,21 @@ export default ['addPermissionsTeamsList', 'addPermissionsUsersList', 'TemplateL // iterate over the list and add fields like type label, after the // OPTIONS request returns, or the list is sorted/paginated/searched function optionsRequestDataProcessing(){ + if(scope.list.name === 'users'){ + if (scope[list.name] !== undefined) { + scope[list.name].forEach(function(item, item_idx) { + var itm = scope[list.name][item_idx]; + if(itm.summary_fields.user_capabilities.edit){ + // undefined doesn't render the tooltip, + // which is intended here. + itm.tooltip = undefined; + } + else if(!itm.summary_fields.user_capabilities.edit){ + itm.tooltip = i18n._('You do not have permission to manage this user'); + } + }); + } + } if(scope.list.name === 'projects'){ if (scope[list.name] !== undefined) { scope[list.name].forEach(function(item, item_idx) { diff --git a/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js b/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js index abcb526d58..c821221d28 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js +++ b/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js @@ -19,6 +19,7 @@ export default ['i18n', function(i18n) { basePath: 'inventory', title: false, disableRow: "{{ inventory.pending_deletion }}", + disableRowValue: 'pending_deletion', fields: { status: { diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js index a1f7088769..8e6b240c87 100644 --- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js +++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js @@ -290,6 +290,7 @@ export default ['$compile', 'Attr', 'Icon', // gotcha: transcluded elements require custom scope linking - binding to $parent models assumes a very rigid DOM hierarchy // see: lookup-modal.directive.js for example innerTable += options.mode === 'lookup' ? `` : `"\n"`; + innerTable += "\n"; - if (list.index) { innerTable += "{{ $index + ((" + list.iterator + "_page - 1) * " + list.iterator + "_page_size) + 1 }}.\n"; } if (list.multiSelect) { - innerTable += ''; + innerTable += ''; } // Change layout if a lookup list, place radio buttons before labels diff --git a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js index aa8397e904..20b9c4e0a0 100644 --- a/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js +++ b/awx/ui/client/src/shared/multi-select-list/select-list-item.directive.js @@ -27,10 +27,11 @@ export default return { restrict: 'E', scope: { - item: '=item' + item: '=item', + disabled: '=' }, require: '^multiSelectList', - template: '', + template: '', link: function(scope, element, attrs, multiSelectList) { scope.decoratedItem = multiSelectList.registerItem(scope.item); From e64e25fcc108df888b0f5a1e6d8e74bf2e45c0ab Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 24 May 2018 10:17:51 -0400 Subject: [PATCH 063/762] flake8 errors in access.py due to an upgrade --- awx/main/access.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 47534928bd..29664faa71 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1622,7 +1622,7 @@ class JobLaunchConfigAccess(BaseAccess): if isinstance(sub_obj, Credential) and relationship == 'credentials': return self.user in sub_obj.use_role else: - raise NotImplemented('Only credentials can be attached to launch configurations.') + raise NotImplementedError('Only credentials can be attached to launch configurations.') def can_unattach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): if isinstance(sub_obj, Credential) and relationship == 'credentials': @@ -1631,7 +1631,7 @@ class JobLaunchConfigAccess(BaseAccess): else: return self.user in sub_obj.read_role else: - raise NotImplemented('Only credentials can be attached to launch configurations.') + raise NotImplementedError('Only credentials can be attached to launch configurations.') class WorkflowJobTemplateNodeAccess(BaseAccess): @@ -1720,7 +1720,7 @@ class WorkflowJobTemplateNodeAccess(BaseAccess): elif relationship in ('success_nodes', 'failure_nodes', 'always_nodes'): return self.check_same_WFJT(obj, sub_obj) else: - raise NotImplemented('Relationship {} not understood for WFJT nodes.'.format(relationship)) + raise NotImplementedError('Relationship {} not understood for WFJT nodes.'.format(relationship)) def can_unattach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): if not self.wfjt_admin(obj): @@ -1735,7 +1735,7 @@ class WorkflowJobTemplateNodeAccess(BaseAccess): elif relationship in ('success_nodes', 'failure_nodes', 'always_nodes'): return self.check_same_WFJT(obj, sub_obj) else: - raise NotImplemented('Relationship {} not understood for WFJT nodes.'.format(relationship)) + raise NotImplementedError('Relationship {} not understood for WFJT nodes.'.format(relationship)) class WorkflowJobNodeAccess(BaseAccess): From 7292e0015896b2a0aa027217d2fa6b3f17a7c79f Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 24 May 2018 17:27:03 -0400 Subject: [PATCH 064/762] create componentized tags --- .../features/output/search.partial.html | 7 +-- awx/ui/client/lib/components/_index.less | 1 + awx/ui/client/lib/components/index.js | 2 + awx/ui/client/lib/components/tag/_index.less | 53 +++++++++++++++++++ .../lib/components/tag/tag.directive.js | 16 ++++++ .../lib/components/tag/tag.partial.html | 6 +++ 6 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 awx/ui/client/lib/components/tag/_index.less create mode 100644 awx/ui/client/lib/components/tag/tag.directive.js create mode 100644 awx/ui/client/lib/components/tag/tag.partial.html diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html index b3937b17ee..4ef70a4ffb 100644 --- a/awx/ui/client/features/output/search.partial.html +++ b/awx/ui/client/features/output/search.partial.html @@ -37,11 +37,8 @@
-
-
{{ tag }}
-
- -
+
+
{{:: vm.strings.get('search.CLEAR_ALL') }} diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index 72c14fe4b5..7d661e8122 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -8,6 +8,7 @@ @import 'popover/_index'; @import 'relaunchButton/_index'; @import 'tabs/_index'; +@import 'tag/_index'; @import 'truncate/_index'; @import 'utility/_index'; @import 'code-mirror/_index'; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 35b0e0193d..bea2f38ce6 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -32,6 +32,7 @@ import sideNav from '~components/layout/side-nav.directive'; import sideNavItem from '~components/layout/side-nav-item.directive'; import tab from '~components/tabs/tab.directive'; import tabGroup from '~components/tabs/group.directive'; +import tag from '~components/tag/tag.directive'; import topNavItem from '~components/layout/top-nav-item.directive'; import truncate from '~components/truncate/truncate.directive'; import atCodeMirror from '~components/code-mirror'; @@ -78,6 +79,7 @@ angular .directive('atSideNavItem', sideNavItem) .directive('atTab', tab) .directive('atTabGroup', tabGroup) + .directive('atTag', tag) .directive('atTopNavItem', topNavItem) .directive('atTruncate', truncate) .service('BaseInputController', BaseInputController) diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less new file mode 100644 index 0000000000..28ff8ecc82 --- /dev/null +++ b/awx/ui/client/lib/components/tag/_index.less @@ -0,0 +1,53 @@ +.TagComponentWrapper { + padding: 5px; +} +.TagComponent { + color: white; + background: #337AB7; + border-radius: @at-space; + font-size: 12px; + display: flex; + flex-direction: row; + align-content: center; + min-height: @at-space-4x; + overflow: hidden; + max-width: 200px; +} + +.TagComponent-name { + margin: @at-space @at-space-2x; + align-self: center; + word-break: break-word; + text-transform: lowercase; +} + +.TagComponent--cloud { + &:before { + content: '\f0c2'; + color: white; + height: 20px; + width: @at-space-4x; + } +} + +.TagComponent--key { + &:before { + content: '\f084'; + color: white; + height: @at-space-4x; + width: @at-space-4x; + } +} + +.TagComponent-button { + padding: 0 @at-space; + display: flex; + flex-direction: column; + justify-content: center; +} + +.TagComponent-button:hover { + cursor: pointer; + border-color: @default-err; + background-color: @default-err; +} diff --git a/awx/ui/client/lib/components/tag/tag.directive.js b/awx/ui/client/lib/components/tag/tag.directive.js new file mode 100644 index 0000000000..0616bb5e0d --- /dev/null +++ b/awx/ui/client/lib/components/tag/tag.directive.js @@ -0,0 +1,16 @@ +const templateUrl = require('~components/tag/tag.partial.html'); + +function atTag () { + return { + restrict: 'E', + replace: true, + transclude: true, + templateUrl, + scope: { + tag: '=', + removeTag: '&?', + }, + }; +} + +export default atTag; diff --git a/awx/ui/client/lib/components/tag/tag.partial.html b/awx/ui/client/lib/components/tag/tag.partial.html new file mode 100644 index 0000000000..ba7fbada4f --- /dev/null +++ b/awx/ui/client/lib/components/tag/tag.partial.html @@ -0,0 +1,6 @@ +
+
{{ tag }}
+
+ +
+
\ No newline at end of file From d83fa5a03103415c2a0787899ccb3093d2a3772e Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 24 May 2018 17:35:11 -0400 Subject: [PATCH 065/762] change 5px down to 3px per design feedback. --- awx/ui/client/lib/components/tag/_index.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index 28ff8ecc82..996a6f681b 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -15,7 +15,7 @@ } .TagComponent-name { - margin: @at-space @at-space-2x; + margin: 3px @at-space-2x; align-self: center; word-break: break-word; text-transform: lowercase; From edada760504ed8e301dfad909adc273b4ac4be49 Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 24 May 2018 17:40:24 -0400 Subject: [PATCH 066/762] Pixel pushing --- awx/ui/client/lib/components/tag/_index.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index 996a6f681b..11309a17fd 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -15,7 +15,7 @@ } .TagComponent-name { - margin: 3px @at-space-2x; + margin: 2px @at-space-2x; align-self: center; word-break: break-word; text-transform: lowercase; From d8dcac9158f52baab55acc204661f540ddc80bb4 Mon Sep 17 00:00:00 2001 From: kialam Date: Fri, 25 May 2018 10:00:42 -0400 Subject: [PATCH 067/762] small cleanup --- awx/ui/client/lib/components/tag/_index.less | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index 11309a17fd..f4bc366012 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -1,5 +1,5 @@ .TagComponentWrapper { - padding: 5px; + padding: @at-space; } .TagComponent { color: white; @@ -25,7 +25,7 @@ &:before { content: '\f0c2'; color: white; - height: 20px; + height: @at-space-4x; width: @at-space-4x; } } From a9bdac0d53a9209cd4c6fc3fc89a95004b95f050 Mon Sep 17 00:00:00 2001 From: kialam Date: Fri, 25 May 2018 10:25:16 -0400 Subject: [PATCH 068/762] use newer variables --- awx/ui/client/lib/components/tag/_index.less | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index f4bc366012..98381154d8 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -3,7 +3,7 @@ } .TagComponent { color: white; - background: #337AB7; + background: @at-blue; border-radius: @at-space; font-size: 12px; display: flex; @@ -48,6 +48,6 @@ .TagComponent-button:hover { cursor: pointer; - border-color: @default-err; - background-color: @default-err; + border-color: @at-color-error; + background-color: @at-color-error; } From 376a763cc09ea084bf8542a879e1e7a39d5cfb28 Mon Sep 17 00:00:00 2001 From: kialam Date: Fri, 25 May 2018 10:34:03 -0400 Subject: [PATCH 069/762] vertically center `CLEAR ALL` "button" using newly created mixin and resolve merge conflict --- awx/ui/client/features/output/_index.less | 4 ++++ awx/ui/client/features/output/search.partial.html | 6 +++--- awx/ui/client/lib/components/tag/_index.less | 4 +--- awx/ui/client/lib/theme/_mixins.less | 6 ++++++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less index cb8e2940fd..a41721dd37 100644 --- a/awx/ui/client/features/output/_index.less +++ b/awx/ui/client/features/output/_index.less @@ -178,6 +178,10 @@ padding: 6px @at-padding-input 6px @at-padding-input; } +.jobz-searchClearAllContainer { + .at-mixin-VerticallyCenter(); +} + .jobz-searchClearAll { font-size: 10px; padding-bottom: @at-space; diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html index 4ef70a4ffb..ff2a49e73a 100644 --- a/awx/ui/client/features/output/search.partial.html +++ b/awx/ui/client/features/output/search.partial.html @@ -40,9 +40,9 @@
-
+
diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index 98381154d8..1fa4f5ace5 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -41,9 +41,7 @@ .TagComponent-button { padding: 0 @at-space; - display: flex; - flex-direction: column; - justify-content: center; + .at-mixin-VerticallyCenter(); } .TagComponent-button:hover { diff --git a/awx/ui/client/lib/theme/_mixins.less b/awx/ui/client/lib/theme/_mixins.less index 701613a2ec..a3aa4ef1df 100644 --- a/awx/ui/client/lib/theme/_mixins.less +++ b/awx/ui/client/lib/theme/_mixins.less @@ -102,4 +102,10 @@ .at-mixin-FontFixedWidth () { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} + +.at-mixin-VerticallyCenter () { + display: flex; + flex-direction: column; + justify-content: center; } \ No newline at end of file From 0b0fd21734690b52b0a0a792787a42571255b555 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 25 May 2018 10:26:17 -0400 Subject: [PATCH 070/762] Fix project permissions user link --- awx/ui/client/src/projects/projects.form.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/projects/projects.form.js b/awx/ui/client/src/projects/projects.form.js index bd82dabbc2..ac97e7269f 100644 --- a/awx/ui/client/src/projects/projects.form.js +++ b/awx/ui/client/src/projects/projects.form.js @@ -261,8 +261,9 @@ export default ['i18n', 'NotificationsList', 'TemplateList', fields: { username: { + key: true, label: i18n._('User'), - uiSref: 'users({user_id: field.id})', + linkBase: 'users', class: 'col-lg-3 col-md-3 col-sm-3 col-xs-4' }, role: { From a0433773d8ee109bd97eb01eebc4568998a5c371 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 25 May 2018 14:13:04 -0400 Subject: [PATCH 071/762] don't allow Accept:application/json on /api/login/ see: https://github.com/ansible/tower/issues/1672 --- awx/api/generics.py | 24 +++++++++++++++++++++-- awx/main/tests/functional/test_session.py | 14 +++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index c62f3cc6dd..4f13e8585c 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -24,13 +24,14 @@ from django.contrib.auth import views as auth_views # Django REST Framework from rest_framework.authentication import get_authorization_header -from rest_framework.exceptions import PermissionDenied, AuthenticationFailed, ParseError +from rest_framework.exceptions import PermissionDenied, AuthenticationFailed, ParseError, NotAcceptable from rest_framework import generics from rest_framework.response import Response from rest_framework import status from rest_framework import views from rest_framework.permissions import AllowAny -from rest_framework.renderers import JSONRenderer +from rest_framework.renderers import StaticHTMLRenderer, JSONRenderer +from rest_framework.negotiation import DefaultContentNegotiation # cryptography from cryptography.fernet import InvalidToken @@ -64,6 +65,25 @@ analytics_logger = logging.getLogger('awx.analytics.performance') class LoggedLoginView(auth_views.LoginView): + def get(self, request, *args, **kwargs): + # The django.auth.contrib login form doesn't perform the content + # negotiation we've come to expect from DRF; add in code to catch + # situations where Accept != text/html (or */*) and reply with + # an HTTP 406 + try: + DefaultContentNegotiation().select_renderer( + request, + [StaticHTMLRenderer], + 'html' + ) + except NotAcceptable: + resp = Response(status=status.HTTP_406_NOT_ACCEPTABLE) + resp.accepted_renderer = StaticHTMLRenderer() + resp.accepted_media_type = 'text/plain' + resp.renderer_context = {} + return resp + return super(LoggedLoginView, self).get(request, *args, **kwargs) + def post(self, request, *args, **kwargs): original_user = getattr(request, 'user', None) ret = super(LoggedLoginView, self).post(request, *args, **kwargs) diff --git a/awx/main/tests/functional/test_session.py b/awx/main/tests/functional/test_session.py index 90f33626ea..352581ae1e 100644 --- a/awx/main/tests/functional/test_session.py +++ b/awx/main/tests/functional/test_session.py @@ -24,6 +24,20 @@ class AlwaysPassBackend(object): return '{}.{}'.format(cls.__module__, cls.__name__) +@pytest.mark.django_db +@pytest.mark.parametrize('accept, status', [ + ['*/*', 200], + ['text/html', 200], + ['application/json', 406] +]) +def test_login_json_not_allowed(get, accept, status): + get( + '/api/login/', + HTTP_ACCEPT=accept, + expect=status + ) + + @pytest.mark.skip(reason="Needs Update - CA") @pytest.mark.django_db def test_session_create_delete(admin, post, get): From e03c584b805b143dbc02bfe69ee2cb53f8413cf3 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 25 May 2018 14:50:51 -0400 Subject: [PATCH 072/762] mark dynamic Credential Type labels and help_text for i18n see: https://github.com/ansible/tower/issues/1960 related: https://github.com/ansible/ansible-tower/pull/6844 --- awx/api/serializers.py | 1 + awx/main/models/credential/__init__.py | 220 ++++++++++++------------- 2 files changed, 111 insertions(+), 110 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 9348a6c37e..ebe019ddb9 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2363,6 +2363,7 @@ class CredentialTypeSerializer(BaseSerializer): # translate labels and help_text for credential fields "managed by Tower" if value.get('managed_by_tower'): + value['name'] = _(value['name']) for field in value.get('inputs', {}).get('fields', []): field['label'] = _(field['label']) if 'help_text' in field: diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 48205dea1f..2a7f7f9ede 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -14,7 +14,7 @@ from jinja2 import Template # Django from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext_lazy as _, ugettext_noop from django.core.exceptions import ValidationError from django.utils.encoding import force_text @@ -673,46 +673,46 @@ class CredentialType(CommonModelNameNotUnique): def ssh(cls): return cls( kind='ssh', - name='Machine', + name=ugettext_noop('Machine'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True, 'ask_at_runtime': True }, { 'id': 'ssh_key_data', - 'label': 'SSH Private Key', + 'label': ugettext_noop('SSH Private Key'), 'type': 'string', 'format': 'ssh_private_key', 'secret': True, 'multiline': True }, { 'id': 'ssh_key_unlock', - 'label': 'Private Key Passphrase', + 'label': ugettext_noop('Private Key Passphrase'), 'type': 'string', 'secret': True, 'ask_at_runtime': True }, { 'id': 'become_method', - 'label': 'Privilege Escalation Method', + 'label': ugettext_noop('Privilege Escalation Method'), 'type': 'become_method', - 'help_text': ('Specify a method for "become" operations. This is ' - 'equivalent to specifying the --become-method ' - 'Ansible parameter.') + 'help_text': ugettext_noop('Specify a method for "become" operations. This is ' + 'equivalent to specifying the --become-method ' + 'Ansible parameter.') }, { 'id': 'become_username', - 'label': 'Privilege Escalation Username', + 'label': ugettext_noop('Privilege Escalation Username'), 'type': 'string', }, { 'id': 'become_password', - 'label': 'Privilege Escalation Password', + 'label': ugettext_noop('Privilege Escalation Password'), 'type': 'string', 'secret': True, 'ask_at_runtime': True @@ -728,28 +728,28 @@ def ssh(cls): def scm(cls): return cls( kind='scm', - name='Source Control', + name=ugettext_noop('Source Control'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True }, { 'id': 'ssh_key_data', - 'label': 'SCM Private Key', + 'label': ugettext_noop('SCM Private Key'), 'type': 'string', 'format': 'ssh_private_key', 'secret': True, 'multiline': True }, { 'id': 'ssh_key_unlock', - 'label': 'Private Key Passphrase', + 'label': ugettext_noop('Private Key Passphrase'), 'type': 'string', 'secret': True }], @@ -764,25 +764,25 @@ def scm(cls): def vault(cls): return cls( kind='vault', - name='Vault', + name=ugettext_noop('Vault'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'vault_password', - 'label': 'Vault Password', + 'label': ugettext_noop('Vault Password'), 'type': 'string', 'secret': True, 'ask_at_runtime': True }, { 'id': 'vault_id', - 'label': 'Vault Identifier', + 'label': ugettext_noop('Vault Identifier'), 'type': 'string', 'format': 'vault_id', - 'help_text': ('Specify an (optional) Vault ID. This is ' - 'equivalent to specifying the --vault-id ' - 'Ansible parameter for providing multiple Vault ' - 'passwords. Note: this feature only works in ' - 'Ansible 2.4+.') + 'help_text': ugettext_noop('Specify an (optional) Vault ID. This is ' + 'equivalent to specifying the --vault-id ' + 'Ansible parameter for providing multiple Vault ' + 'passwords. Note: this feature only works in ' + 'Ansible 2.4+.') }], 'required': ['vault_password'], } @@ -793,37 +793,37 @@ def vault(cls): def net(cls): return cls( kind='net', - name='Network', + name=ugettext_noop('Network'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True, }, { 'id': 'ssh_key_data', - 'label': 'SSH Private Key', + 'label': ugettext_noop('SSH Private Key'), 'type': 'string', 'format': 'ssh_private_key', 'secret': True, 'multiline': True }, { 'id': 'ssh_key_unlock', - 'label': 'Private Key Passphrase', + 'label': ugettext_noop('Private Key Passphrase'), 'type': 'string', 'secret': True, }, { 'id': 'authorize', - 'label': 'Authorize', + 'label': ugettext_noop('Authorize'), 'type': 'boolean', }, { 'id': 'authorize_password', - 'label': 'Authorize Password', + 'label': ugettext_noop('Authorize Password'), 'type': 'string', 'secret': True, }], @@ -840,27 +840,27 @@ def net(cls): def aws(cls): return cls( kind='cloud', - name='Amazon Web Services', + name=ugettext_noop('Amazon Web Services'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'username', - 'label': 'Access Key', + 'label': ugettext_noop('Access Key'), 'type': 'string' }, { 'id': 'password', - 'label': 'Secret Key', + 'label': ugettext_noop('Secret Key'), 'type': 'string', 'secret': True, }, { 'id': 'security_token', - 'label': 'STS Token', + 'label': ugettext_noop('STS Token'), 'type': 'string', 'secret': True, - 'help_text': ('Security Token Service (STS) is a web service ' - 'that enables you to request temporary, ' - 'limited-privilege credentials for AWS Identity ' - 'and Access Management (IAM) users.'), + 'help_text': ugettext_noop('Security Token Service (STS) is a web service ' + 'that enables you to request temporary, ' + 'limited-privilege credentials for AWS Identity ' + 'and Access Management (IAM) users.'), }], 'required': ['username', 'password'] } @@ -871,36 +871,36 @@ def aws(cls): def openstack(cls): return cls( kind='cloud', - name='OpenStack', + name=ugettext_noop('OpenStack'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password (API Key)', + 'label': ugettext_noop('Password (API Key)'), 'type': 'string', 'secret': True, }, { 'id': 'host', - 'label': 'Host (Authentication URL)', + 'label': ugettext_noop('Host (Authentication URL)'), 'type': 'string', - 'help_text': ('The host to authenticate with. For example, ' - 'https://openstack.business.com/v2.0/') + 'help_text': ugettext_noop('The host to authenticate with. For example, ' + 'https://openstack.business.com/v2.0/') }, { 'id': 'project', - 'label': 'Project (Tenant Name)', + 'label': ugettext_noop('Project (Tenant Name)'), 'type': 'string', }, { 'id': 'domain', - 'label': 'Domain Name', + 'label': ugettext_noop('Domain Name'), 'type': 'string', - 'help_text': ('OpenStack domains define administrative boundaries. ' - 'It is only needed for Keystone v3 authentication ' - 'URLs. Refer to Ansible Tower documentation for ' - 'common scenarios.') + 'help_text': ugettext_noop('OpenStack domains define administrative boundaries. ' + 'It is only needed for Keystone v3 authentication ' + 'URLs. Refer to Ansible Tower documentation for ' + 'common scenarios.') }], 'required': ['username', 'password', 'host', 'project'] } @@ -911,22 +911,22 @@ def openstack(cls): def vmware(cls): return cls( kind='cloud', - name='VMware vCenter', + name=ugettext_noop('VMware vCenter'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'host', - 'label': 'VCenter Host', + 'label': ugettext_noop('VCenter Host'), 'type': 'string', - 'help_text': ('Enter the hostname or IP address that corresponds ' - 'to your VMware vCenter.') + 'help_text': ugettext_noop('Enter the hostname or IP address that corresponds ' + 'to your VMware vCenter.') }, { 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True, }], @@ -939,22 +939,22 @@ def vmware(cls): def satellite6(cls): return cls( kind='cloud', - name='Red Hat Satellite 6', + name=ugettext_noop('Red Hat Satellite 6'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'host', - 'label': 'Satellite 6 URL', + 'label': ugettext_noop('Satellite 6 URL'), 'type': 'string', - 'help_text': ('Enter the URL that corresponds to your Red Hat ' - 'Satellite 6 server. For example, https://satellite.example.org') + 'help_text': ugettext_noop('Enter the URL that corresponds to your Red Hat ' + 'Satellite 6 server. For example, https://satellite.example.org') }, { 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True, }], @@ -967,23 +967,23 @@ def satellite6(cls): def cloudforms(cls): return cls( kind='cloud', - name='Red Hat CloudForms', + name=ugettext_noop('Red Hat CloudForms'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'host', - 'label': 'CloudForms URL', + 'label': ugettext_noop('CloudForms URL'), 'type': 'string', - 'help_text': ('Enter the URL for the virtual machine that ' - 'corresponds to your CloudForm instance. ' - 'For example, https://cloudforms.example.org') + 'help_text': ugettext_noop('Enter the URL for the virtual machine that ' + 'corresponds to your CloudForm instance. ' + 'For example, https://cloudforms.example.org') }, { 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True, }], @@ -996,32 +996,32 @@ def cloudforms(cls): def gce(cls): return cls( kind='cloud', - name='Google Compute Engine', + name=ugettext_noop('Google Compute Engine'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'username', - 'label': 'Service Account Email Address', + 'label': ugettext_noop('Service Account Email Address'), 'type': 'string', - 'help_text': ('The email address assigned to the Google Compute ' - 'Engine service account.') + 'help_text': ugettext_noop('The email address assigned to the Google Compute ' + 'Engine service account.') }, { 'id': 'project', 'label': 'Project', 'type': 'string', - 'help_text': ('The Project ID is the GCE assigned identification. ' - 'It is often constructed as three words or two words ' - 'followed by a three-digit number. Examples: project-id-000 ' - 'and another-project-id') + 'help_text': ugettext_noop('The Project ID is the GCE assigned identification. ' + 'It is often constructed as three words or two words ' + 'followed by a three-digit number. Examples: project-id-000 ' + 'and another-project-id') }, { 'id': 'ssh_key_data', - 'label': 'RSA Private Key', + 'label': ugettext_noop('RSA Private Key'), 'type': 'string', 'format': 'ssh_private_key', 'secret': True, 'multiline': True, - 'help_text': ('Paste the contents of the PEM file associated ' - 'with the service account email.') + 'help_text': ugettext_noop('Paste the contents of the PEM file associated ' + 'with the service account email.') }], 'required': ['username', 'ssh_key_data'], } @@ -1032,43 +1032,43 @@ def gce(cls): def azure_rm(cls): return cls( kind='cloud', - name='Microsoft Azure Resource Manager', + name=ugettext_noop('Microsoft Azure Resource Manager'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'subscription', - 'label': 'Subscription ID', + 'label': ugettext_noop('Subscription ID'), 'type': 'string', - 'help_text': ('Subscription ID is an Azure construct, which is ' - 'mapped to a username.') + 'help_text': ugettext_noop('Subscription ID is an Azure construct, which is ' + 'mapped to a username.') }, { 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True, }, { 'id': 'client', - 'label': 'Client ID', + 'label': ugettext_noop('Client ID'), 'type': 'string' }, { 'id': 'secret', - 'label': 'Client Secret', + 'label': ugettext_noop('Client Secret'), 'type': 'string', 'secret': True, }, { 'id': 'tenant', - 'label': 'Tenant ID', + 'label': ugettext_noop('Tenant ID'), 'type': 'string' }, { 'id': 'cloud_environment', - 'label': 'Azure Cloud Environment', + 'label': ugettext_noop('Azure Cloud Environment'), 'type': 'string', - 'help_text': ('Environment variable AZURE_CLOUD_ENVIRONMENT when' - ' using Azure GovCloud or Azure stack.') + 'help_text': ugettext_noop('Environment variable AZURE_CLOUD_ENVIRONMENT when' + ' using Azure GovCloud or Azure stack.') }], 'required': ['subscription'], } @@ -1079,16 +1079,16 @@ def azure_rm(cls): def insights(cls): return cls( kind='insights', - name='Insights', + name=ugettext_noop('Insights'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True }], @@ -1107,28 +1107,28 @@ def insights(cls): def rhv(cls): return cls( kind='cloud', - name='Red Hat Virtualization', + name=ugettext_noop('Red Hat Virtualization'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'host', - 'label': 'Host (Authentication URL)', + 'label': ugettext_noop('Host (Authentication URL)'), 'type': 'string', - 'help_text': ('The host to authenticate with.') + 'help_text': ugettext_noop('The host to authenticate with.') }, { 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True, }, { 'id': 'ca_file', - 'label': 'CA File', + 'label': ugettext_noop('CA File'), 'type': 'string', - 'help_text': ('Absolute file path to the CA file to use (optional)') + 'help_text': ugettext_noop('Absolute file path to the CA file to use (optional)') }], 'required': ['host', 'username', 'password'], }, @@ -1159,26 +1159,26 @@ def rhv(cls): def tower(cls): return cls( kind='cloud', - name='Ansible Tower', + name=ugettext_noop('Ansible Tower'), managed_by_tower=True, inputs={ 'fields': [{ 'id': 'host', - 'label': 'Ansible Tower Hostname', + 'label': ugettext_noop('Ansible Tower Hostname'), 'type': 'string', - 'help_text': ('The Ansible Tower base URL to authenticate with.') + 'help_text': ugettext_noop('The Ansible Tower base URL to authenticate with.') }, { 'id': 'username', - 'label': 'Username', + 'label': ugettext_noop('Username'), 'type': 'string' }, { 'id': 'password', - 'label': 'Password', + 'label': ugettext_noop('Password'), 'type': 'string', 'secret': True, }, { 'id': 'verify_ssl', - 'label': 'Verify SSL', + 'label': ugettext_noop('Verify SSL'), 'type': 'boolean', 'secret': False }], From af7ec17ccddf840908fa90c18cdfa8395b9064d6 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 25 May 2018 17:21:43 -0400 Subject: [PATCH 073/762] fix a few minor issues that have broken the User Token creation see: https://github.com/ansible/tower/issues/1928 --- awx/api/serializers.py | 2 +- .../features/users/tokens/users-tokens-add.controller.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index ebe019ddb9..9a27eadf0c 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1073,7 +1073,7 @@ class UserAuthorizedTokenSerializer(BaseOAuth2TokenSerializer): validated_data['expires'] = now() + timedelta( seconds=settings.OAUTH2_PROVIDER['ACCESS_TOKEN_EXPIRE_SECONDS'] ) - obj = super(OAuth2TokenSerializer, self).create(validated_data) + obj = super(UserAuthorizedTokenSerializer, self).create(validated_data) obj.save() if obj.application is not None: RefreshToken.objects.create( diff --git a/awx/ui/client/features/users/tokens/users-tokens-add.controller.js b/awx/ui/client/features/users/tokens/users-tokens-add.controller.js index 1421077b1c..8e01fe5a3f 100644 --- a/awx/ui/client/features/users/tokens/users-tokens-add.controller.js +++ b/awx/ui/client/features/users/tokens/users-tokens-add.controller.js @@ -42,11 +42,11 @@ function AddTokensController ( required: true, _component: 'at-input-select', _data: [ - strings.get('add.SCOPE_PLACEHOLDER'), - strings.get('add.SCOPE_READ_LABEL'), - strings.get('add.SCOPE_WRITE_LABEL') + { label: strings.get('add.SCOPE_PLACEHOLDER'), value: '' }, + { label: strings.get('add.SCOPE_READ_LABEL'), value: 'read' }, + { label: strings.get('add.SCOPE_WRITE_LABEL'), value: 'write' } ], - _exp: 'choice for (index, choice) in state._data', + _exp: 'choice.value as choice.label for (index, choice) in state._data', _format: 'array' }; From 7f7f635f7b6f57f1fbd5030cbf4ed46e3df26c74 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 29 May 2018 09:43:45 -0400 Subject: [PATCH 074/762] upgrade to the latest pexpect see: https://github.com/pexpect/pexpect/pull/492 see: https://github.com/ansible/tower/issues/1797 --- awx/main/expect/run.py | 2 +- requirements/requirements.in | 2 +- requirements/requirements.txt | 2 +- requirements/requirements_ansible.in | 2 +- requirements/requirements_ansible.txt | 2 +- requirements/requirements_isolated.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/awx/main/expect/run.py b/awx/main/expect/run.py index ed26464c8e..c443fcc165 100755 --- a/awx/main/expect/run.py +++ b/awx/main/expect/run.py @@ -101,7 +101,7 @@ def run_pexpect(args, cwd, env, logfile, child = pexpect.spawn( args[0], args[1:], cwd=cwd, env=env, ignore_sighup=True, - encoding='utf-8', echo=False + encoding='utf-8', echo=False, poll=True ) child.logfile_read = logfile canceled = False diff --git a/requirements/requirements.in b/requirements/requirements.in index f1ff3cd0bb..17b7b95e17 100644 --- a/requirements/requirements.in +++ b/requirements/requirements.in @@ -30,7 +30,7 @@ jsonschema==2.6.0 M2Crypto==0.29.0 Markdown==2.6.11 ordereddict==1.1 -pexpect==4.5.0 +pexpect==4.6.0 psphere==0.5.2 psutil==5.4.3 psycopg2==2.7.3.2 # problems with Segmentation faults / wheels on upgrade diff --git a/requirements/requirements.txt b/requirements/requirements.txt index d81700b47d..a45f3453de 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -170,7 +170,7 @@ os-service-types==1.2.0 # via openstacksdk packaging==17.1 # via deprecation pathlib2==2.3.0 # via azure-datalake-store pbr==3.1.1 # via keystoneauth1, openstacksdk, os-service-types, shade, stevedore -pexpect==4.5.0 +pexpect==4.6.0 psphere==0.5.2 psutil==5.4.3 psycopg2==2.7.3.2 diff --git a/requirements/requirements_ansible.in b/requirements/requirements_ansible.in index e63f24d083..26ecc32c44 100644 --- a/requirements/requirements_ansible.in +++ b/requirements/requirements_ansible.in @@ -21,7 +21,7 @@ backports.ssl-match-hostname==3.5.0.1 boto==2.47.0 # last which does not break ec2 scripts boto3==1.6.2 ovirt-engine-sdk-python==4.2.4 # minimum set inside Ansible facts module requirements -pexpect==4.5.0 # same as AWX requirement +pexpect==4.6.0 # same as AWX requirement python-memcached==1.59 # same as AWX requirement psphere==0.5.2 psutil==5.4.3 # same as AWX requirement diff --git a/requirements/requirements_ansible.txt b/requirements/requirements_ansible.txt index 63f43d36d6..b1bde836ec 100644 --- a/requirements/requirements_ansible.txt +++ b/requirements/requirements_ansible.txt @@ -68,7 +68,7 @@ os-service-types==1.2.0 # via openstacksdk ovirt-engine-sdk-python==4.2.4 packaging==17.1 # via deprecation paramiko==2.4.0 # via azure-cli-core -pexpect==4.5.0 +pexpect==4.6.0 pbr==3.1.1 # via keystoneauth1, openstacksdk, os-service-types, shade, stevedore psphere==0.5.2 psutil==5.4.3 diff --git a/requirements/requirements_isolated.txt b/requirements/requirements_isolated.txt index a7df0a2fa6..58113b56d4 100644 --- a/requirements/requirements_isolated.txt +++ b/requirements/requirements_isolated.txt @@ -1,3 +1,3 @@ psutil==5.4.3 # same as AWX requirement -pexpect==4.5.0 # same as AWX requirement +pexpect==4.6.0 # same as AWX requirement python-daemon==2.1.2 From 4da68564efccb87720e5c28384edbaee821f0118 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 29 May 2018 10:27:51 -0400 Subject: [PATCH 075/762] do not cache dependency_list * This is probably causing some bug. Calls to start_task within the same run of the task manager could result in previous dependency lists being used. --- awx/main/scheduler/task_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 943d4960e6..810fbafdac 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -234,9 +234,11 @@ class TaskManager(): def get_dependent_jobs_for_inv_and_proj_update(self, job_obj): return [{'type': j.model_to_str(), 'id': j.id} for j in job_obj.dependent_jobs.all()] - def start_task(self, task, rampart_group, dependent_tasks=[]): + def start_task(self, task, rampart_group, dependent_tasks=None): from awx.main.tasks import handle_work_error, handle_work_success + dependent_tasks = dependent_tasks or [] + task_actual = { 'type': get_type_for_model(type(task)), 'id': task.id, From c369d44518e30a91177fd72ac2ec6e1883690375 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 29 May 2018 11:25:08 -0400 Subject: [PATCH 076/762] use the _correct_ argument to specify poll vs select --- awx/main/expect/run.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/expect/run.py b/awx/main/expect/run.py index c443fcc165..0c8881a85c 100755 --- a/awx/main/expect/run.py +++ b/awx/main/expect/run.py @@ -101,7 +101,7 @@ def run_pexpect(args, cwd, env, logfile, child = pexpect.spawn( args[0], args[1:], cwd=cwd, env=env, ignore_sighup=True, - encoding='utf-8', echo=False, poll=True + encoding='utf-8', echo=False, use_poll=True ) child.logfile_read = logfile canceled = False From 9d87f8527509a24c9accddaf0c3d26f4c39e4353 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 25 May 2018 20:06:58 -0400 Subject: [PATCH 077/762] fix wording of search keys --- awx/ui/client/features/output/output.strings.js | 2 +- awx/ui/client/src/shared/smart-search/smart-search.partial.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/features/output/output.strings.js b/awx/ui/client/features/output/output.strings.js index f7b0a20565..a3fb2458e3 100644 --- a/awx/ui/client/features/output/output.strings.js +++ b/awx/ui/client/features/output/output.strings.js @@ -73,7 +73,7 @@ function OutputStrings (BaseString) { ns.search = { ADDITIONAL_INFORMATION_HEADER: t.s('ADDITIONAL_INFORMATION'), - ADDITIONAL_INFORMATION: t.s('For additional information on advanced search search syntax please see the Ansible Tower'), + ADDITIONAL_INFORMATION: t.s('For additional information on advanced search syntax please see the Ansible Tower'), CLEAR_ALL: t.s('CLEAR ALL'), DOCUMENTATION: t.s('documentation'), EXAMPLES: t.s('EXAMPLES'), diff --git a/awx/ui/client/src/shared/smart-search/smart-search.partial.html b/awx/ui/client/src/shared/smart-search/smart-search.partial.html index a44a90f649..04615f9460 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.partial.html +++ b/awx/ui/client/src/shared/smart-search/smart-search.partial.html @@ -49,7 +49,7 @@
{{ 'ADDITIONAL INFORMATION' | translate }}: - {{ 'For additional information on advanced search search syntax please see the Ansible Tower' | translate }} + {{ 'For additional information on advanced search syntax please see the Ansible Tower' | translate }} {{ 'documentation' | translate }}.
From 0f6fe210b7e92f8900c2d45b4064e0783deb909f Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 29 May 2018 15:04:25 -0400 Subject: [PATCH 078/762] fix when token scope is passing when the ui saves the form --- .../users/tokens/users-tokens-add.controller.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/awx/ui/client/features/users/tokens/users-tokens-add.controller.js b/awx/ui/client/features/users/tokens/users-tokens-add.controller.js index 1421077b1c..67e15fa0ad 100644 --- a/awx/ui/client/features/users/tokens/users-tokens-add.controller.js +++ b/awx/ui/client/features/users/tokens/users-tokens-add.controller.js @@ -32,9 +32,9 @@ function AddTokensController ( vm.form.scope = { choices: [ - '', - 'read', - 'write' + [null, ''], + ['read', strings.get('add.SCOPE_READ_LABEL')], + ['write', strings.get('add.SCOPE_WRITE_LABEL')] ], help_text: strings.get('add.SCOPE_HELP_TEXT'), id: 'scope', @@ -42,12 +42,12 @@ function AddTokensController ( required: true, _component: 'at-input-select', _data: [ - strings.get('add.SCOPE_PLACEHOLDER'), - strings.get('add.SCOPE_READ_LABEL'), - strings.get('add.SCOPE_WRITE_LABEL') + [null, ''], + ['read', strings.get('add.SCOPE_READ_LABEL')], + ['write', strings.get('add.SCOPE_WRITE_LABEL')] ], - _exp: 'choice for (index, choice) in state._data', - _format: 'array' + _exp: 'choice[1] for (index, choice) in state._data', + _format: 'selectFromOptions' }; vm.form.save = payload => { From 5d0a131530b0adc5bc89316641116aa82f3cff5e Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 29 May 2018 16:29:14 -0400 Subject: [PATCH 079/762] Add credential tags to job details --- awx/ui/client/features/output/_index.less | 2 + .../features/output/details.component.js | 24 +++++----- .../features/output/details.partial.html | 17 ++++--- .../features/output/search.partial.html | 2 +- awx/ui/client/lib/components/list/_index.less | 1 - awx/ui/client/lib/components/tag/_index.less | 48 ++++++++++++------- .../lib/components/tag/tag.directive.js | 2 + .../lib/components/tag/tag.partial.html | 4 +- 8 files changed, 63 insertions(+), 37 deletions(-) diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less index a41721dd37..8ca2c8a5d0 100644 --- a/awx/ui/client/features/output/_index.less +++ b/awx/ui/client/features/output/_index.less @@ -366,6 +366,8 @@ } .JobResults-resultRowText { + display: flex; + flex-flow: row wrap; width: ~"calc(70% - 20px)"; flex: 1 0 auto; text-transform: none; diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 512e68985a..03fd2f9d02 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -389,27 +389,29 @@ function getResultTracebackDetails () { } function getCredentialDetails () { - const credential = resource.model.get('summary_fields.credential'); + const credentials = resource.model.get('summary_fields.credentials'); - if (!credential) { + let credentialTags = []; + + if (!credentials || credentials.length < 1) { return null; } - let label = strings.get('labels.CREDENTIAL'); + credentialTags = credentials.map((cred) => buildCredentialDetails(cred)); - if (resource.type === 'playbook') { - label = strings.get('labels.MACHINE_CREDENTIAL'); - } + const label = strings.get('labels.CREDENTIAL'); + const value = credentialTags; - if (resource.type === 'inventory') { - label = strings.get('labels.SOURCE_CREDENTIAL'); - } + return { label, value }; +} +function buildCredentialDetails (credential) { + const icon = `${credential.kind}`; const link = `/#/credentials/${credential.id}`; const tooltip = strings.get('tooltips.CREDENTIAL'); const value = $filter('sanitize')(credential.name); - return { label, link, tooltip, value }; + return { icon, link, tooltip, value }; } function getForkDetails () { @@ -679,7 +681,7 @@ function JobDetailsController ( vm.launchedBy = getLaunchedByDetails(); vm.jobExplanation = getJobExplanationDetails(); vm.verbosity = getVerbosityDetails(); - vm.credential = getCredentialDetails(); + vm.credentials = getCredentialDetails(); vm.forks = getForkDetails(); vm.limit = getLimitDetails(); vm.instanceGroup = getInstanceGroupDetails(); diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 1913c4c074..47f538a44c 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -216,15 +216,18 @@
-
- +
+
- - {{ vm.credential.value }} - + data-tip-watch="credential.tooltip"> +
diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html index ff2a49e73a..702b4749b3 100644 --- a/awx/ui/client/features/output/search.partial.html +++ b/awx/ui/client/features/output/search.partial.html @@ -37,7 +37,7 @@
-
+
diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index 08b39d8f05..321631559a 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -170,7 +170,6 @@ } .at-RowItem-tag { - text-transform: uppercase; font-weight: 100; background-color: @at-color-list-row-item-tag-background; border-radius: @at-border-radius; diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index 1fa4f5ace5..5fa309c106 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -1,8 +1,6 @@ -.TagComponentWrapper { - padding: @at-space; -} .TagComponent { - color: white; + color: @at-white; + cursor: default; background: @at-blue; border-radius: @at-space; font-size: 12px; @@ -12,30 +10,48 @@ min-height: @at-space-4x; overflow: hidden; max-width: 200px; + margin: @at-space; } .TagComponent-name { + color: @at-white; margin: 2px @at-space-2x; align-self: center; word-break: break-word; text-transform: lowercase; -} -.TagComponent--cloud { - &:before { - content: '\f0c2'; - color: white; - height: @at-space-4x; - width: @at-space-4x; + &:hover, + &:focus { + color: @at-white; } } -.TagComponent--key { - &:before { +.TagComponent-icon { + line-height: 20px; + margin-left: @at-space-2x; + + &--cloud:before { + content: '\f0c2'; + } + + &--insights:before { + content: '\f129'; + } + + &--net:before { + content: '\f0e8'; + } + + &--scm:before { + content: '\f126'; + } + + &--ssh:before { content: '\f084'; - color: white; - height: @at-space-4x; - width: @at-space-4x; + } + + &--vault:before { + content: '\f187'; } } diff --git a/awx/ui/client/lib/components/tag/tag.directive.js b/awx/ui/client/lib/components/tag/tag.directive.js index 0616bb5e0d..f18ae9dce9 100644 --- a/awx/ui/client/lib/components/tag/tag.directive.js +++ b/awx/ui/client/lib/components/tag/tag.directive.js @@ -8,6 +8,8 @@ function atTag () { templateUrl, scope: { tag: '=', + icon: '@?', + link: '@?', removeTag: '&?', }, }; diff --git a/awx/ui/client/lib/components/tag/tag.partial.html b/awx/ui/client/lib/components/tag/tag.partial.html index ba7fbada4f..f16da4da42 100644 --- a/awx/ui/client/lib/components/tag/tag.partial.html +++ b/awx/ui/client/lib/components/tag/tag.partial.html @@ -1,5 +1,7 @@
-
{{ tag }}
+
+ {{ tag }} +
{{ tag }}
From 4f90391f54a06d3daf5d8f384f7f54b26baea4a7 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 29 May 2018 17:25:09 -0400 Subject: [PATCH 080/762] Fix dashboard graph status dropdown value --- .../dashboard/graphs/job-status/job-status-graph.directive.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js b/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js index b97ea561ae..22d5fd95d0 100644 --- a/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js +++ b/awx/ui/client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js @@ -30,6 +30,7 @@ function JobStatusGraph($window, adjustGraphSize, templateUrl, i18n, moment, gra scope.period="month"; scope.jobType="all"; + scope.status="both"; scope.$watch('data', function(value) { if (value) { @@ -43,7 +44,7 @@ function JobStatusGraph($window, adjustGraphSize, templateUrl, i18n, moment, gra scope.data = data; scope.period = period; scope.jobType = jobType; - scope.status = status; + scope.status = Object.is(status, undefined) ? scope.status : status; }); } From 4e6fd591809fbde77117eb4226ae462bf082d87a Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 24 May 2018 13:27:20 -0400 Subject: [PATCH 081/762] Handle broken transactions in DB settings getattr This expands the role of the log database error context manager and will actually make itself an exception to the standard ORM behavior of raising an error when any queries are executed inside of a broken transaction. In this particular case it is less risky to continue on with a database query and push the data to memcache than it would be to use default settings values in violation of user's intent. (hopefully) --- awx/conf/settings.py | 76 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/awx/conf/settings.py b/awx/conf/settings.py index 2f7970ec2b..986e09037d 100644 --- a/awx/conf/settings.py +++ b/awx/conf/settings.py @@ -15,7 +15,7 @@ from django.conf import LazySettings from django.conf import settings, UserSettingsHolder from django.core.cache import cache as django_cache from django.core.exceptions import ImproperlyConfigured -from django.db import ProgrammingError, OperationalError +from django.db import ProgrammingError, OperationalError, transaction, connection from django.utils.functional import cached_property # Django REST Framework @@ -61,24 +61,66 @@ __all__ = ['SettingsWrapper', 'get_settings_to_cache', 'SETTING_CACHE_NOTSET'] @contextlib.contextmanager -def _log_database_error(): +def _ctit_db_wrapper(trans_safe=False): + ''' + Wrapper to avoid undesired actions by Django ORM when managing settings + if only getting a setting, can use trans_safe=True, which will avoid + throwing errors if the prior context was a broken transaction. + Any database errors will be logged, but exception will be suppressed. + ''' + rollback_set = None + is_atomic = None try: + if trans_safe: + is_atomic = connection.in_atomic_block + if is_atomic: + rollback_set = transaction.get_rollback() + if rollback_set: + logger.debug('Obtaining database settings in spite of broken transaction.') + transaction.set_rollback(False) yield except (ProgrammingError, OperationalError): if 'migrate' in sys.argv and get_tower_migration_version() < '310': logger.info('Using default settings until version 3.1 migration.') else: - # Somewhat ugly - craming the full stack trace into the log message - # the available exc_info does not give information about the real caller - # TODO: replace in favor of stack_info kwarg in python 3 - sio = StringIO.StringIO() - traceback.print_stack(file=sio) - sinfo = sio.getvalue() - sio.close() - sinfo = sinfo.strip('\n') - logger.warning('Database settings are not available, using defaults, logged from:\n{}'.format(sinfo)) + # We want the _full_ traceback with the context + # First we get the current call stack, which constitutes the "top", + # it has the context up to the point where the context manager is used + top_stack = StringIO.StringIO() + traceback.print_stack(file=top_stack) + top_lines = top_stack.getvalue().strip('\n').split('\n') + top_stack.close() + # Get "bottom" stack from the local error that happened + # inside of the "with" block this wraps + exc_type, exc_value, exc_traceback = sys.exc_info() + bottom_stack = StringIO.StringIO() + traceback.print_tb(exc_traceback, file=bottom_stack) + bottom_lines = bottom_stack.getvalue().strip('\n').split('\n') + # Glue together top and bottom where overlap is found + bottom_cutoff = 0 + for i, line in enumerate(bottom_lines): + if line in top_lines: + # start of overlapping section, take overlap from bottom + top_lines = top_lines[:top_lines.index(line)] + bottom_cutoff = i + break + bottom_lines = bottom_lines[bottom_cutoff:] + tb_lines = top_lines + bottom_lines + + tb_string = '\n'.join( + ['Traceback (most recent call last):'] + + tb_lines + + ['{}: {}'.format(exc_type.__name__, str(exc_value))] + ) + bottom_stack.close() + # Log the combined stack + if trans_safe: + logger.warning('Database settings are not available, using defaults, error:\n{}'.format(tb_string)) + else: + logger.error('Error modifying something related to database settings.\n{}'.format(tb_string)) finally: - pass + if trans_safe and is_atomic and rollback_set: + transaction.set_rollback(rollback_set) def filter_sensitive(registry, key, value): @@ -398,7 +440,7 @@ class SettingsWrapper(UserSettingsHolder): def __getattr__(self, name): value = empty if name in self.all_supported_settings: - with _log_database_error(): + with _ctit_db_wrapper(trans_safe=True): value = self._get_local(name) if value is not empty: return value @@ -430,7 +472,7 @@ class SettingsWrapper(UserSettingsHolder): def __setattr__(self, name, value): if name in self.all_supported_settings: - with _log_database_error(): + with _ctit_db_wrapper(): self._set_local(name, value) else: setattr(self.default_settings, name, value) @@ -446,14 +488,14 @@ class SettingsWrapper(UserSettingsHolder): def __delattr__(self, name): if name in self.all_supported_settings: - with _log_database_error(): + with _ctit_db_wrapper(): self._del_local(name) else: delattr(self.default_settings, name) def __dir__(self): keys = [] - with _log_database_error(): + with _ctit_db_wrapper(trans_safe=True): for setting in Setting.objects.filter( key__in=self.all_supported_settings, user__isnull=True): # Skip returning settings that have been overridden but are @@ -470,7 +512,7 @@ class SettingsWrapper(UserSettingsHolder): def is_overridden(self, setting): set_locally = False if setting in self.all_supported_settings: - with _log_database_error(): + with _ctit_db_wrapper(trans_safe=True): set_locally = Setting.objects.filter(key=setting, user__isnull=True).exists() set_on_default = getattr(self.default_settings, 'is_overridden', lambda s: False)(setting) return (set_locally or set_on_default) From 16cde26468e5c174e9bbab3d6659205a49d29e23 Mon Sep 17 00:00:00 2001 From: Pierre-Louis Bonicoli Date: Wed, 30 May 2018 01:56:33 +0200 Subject: [PATCH 082/762] ansible venv: re-add netaddr Python package Closes #1763 --- requirements/requirements_ansible.in | 1 + requirements/requirements_ansible.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements/requirements_ansible.in b/requirements/requirements_ansible.in index 26ecc32c44..3af40155e8 100644 --- a/requirements/requirements_ansible.in +++ b/requirements/requirements_ansible.in @@ -20,6 +20,7 @@ azure-mgmt-containerinstance>=0.3.1 backports.ssl-match-hostname==3.5.0.1 boto==2.47.0 # last which does not break ec2 scripts boto3==1.6.2 +netaddr ovirt-engine-sdk-python==4.2.4 # minimum set inside Ansible facts module requirements pexpect==4.6.0 # same as AWX requirement python-memcached==1.59 # same as AWX requirement diff --git a/requirements/requirements_ansible.txt b/requirements/requirements_ansible.txt index b1bde836ec..860bf817e6 100644 --- a/requirements/requirements_ansible.txt +++ b/requirements/requirements_ansible.txt @@ -59,6 +59,7 @@ monotonic==1.4 # via humanfriendly msrest==0.4.26 msrestazure==0.4.22 munch==2.2.0 # via openstacksdk +netaddr==0.7.19 netifaces==0.10.6 # via openstacksdk ntlm-auth==1.0.6 # via requests-credssp, requests-ntlm oauthlib==2.0.6 # via requests-oauthlib From 124fcd6f62784ca7650cd73524acf3e375753f07 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Wed, 30 May 2018 12:03:20 -0400 Subject: [PATCH 083/762] delete network canvas objects in delete inventory task --- awx/main/tasks.py | 3 ++ .../tests/functional/test_models.py | 41 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index dc3c8df28b..e6c2fa584a 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -558,6 +558,9 @@ def delete_inventory(self, inventory_id, user_id): i = Inventory.objects.get(id=inventory_id) for host in i.hosts.iterator(): host.job_events_as_primary_host.update(host=None) + for topology_rel in i.topologyinventory_set.iterator(): + topology_rel.topology.delete() + topology_rel.delete() i.delete() emit_channel_notification( 'inventories-status_changed', diff --git a/awx/network_ui/tests/functional/test_models.py b/awx/network_ui/tests/functional/test_models.py index e392662a99..8592061672 100644 --- a/awx/network_ui/tests/functional/test_models.py +++ b/awx/network_ui/tests/functional/test_models.py @@ -1,6 +1,13 @@ +import pytest +import inspect -from awx.network_ui.models import Device, Topology, Interface +from awx.network_ui.models import Device, Topology, Interface, Link + +from awx.main.models import Organization, Inventory +from awx.main.tasks import delete_inventory + +from django.db.models import Model def test_device(): @@ -13,3 +20,35 @@ def test_topology(): def test_interface(): assert str(Interface(name="foo")) == "foo" + + +@pytest.mark.django_db +def test_deletion(): + org = Organization.objects.create(name='Default') + inv = Inventory.objects.create(name='inv', organization=org) + host1 = inv.hosts.create(name='foo') + host2 = inv.hosts.create(name='bar') + topology = Topology.objects.create( + name='inv', scale=1.0, panX=0.0, panY=0.0 + ) + inv.topologyinventory_set.create(topology=topology) + device1 = topology.device_set.create(name='foo', host=host1, x=0.0, y=0.0, cid=1) + interface1 = Interface.objects.create(device=device1, name='foo', cid=2) + device2 = topology.device_set.create(name='bar', host=host2, x=0.0, y=0.0, cid=3) + interface2 = Interface.objects.create(device=device2, name='bar', cid=4) + Link.objects.create( + from_device=device1, to_device=device2, + from_interface=interface1, to_interface=interface2, + cid=10 + ) + + network_ui_models = [] + from awx.network_ui import models as network_models + for name, model in vars(network_models).items(): + if not inspect.isclass(model) or not issubclass(model, Model): + continue + network_ui_models.append(model) + + delete_inventory.run(inv.pk, None) + for cls in network_ui_models: + assert cls.objects.count() == 0, cls From 79202708af6b3c017af22f97122d5e853428c22f Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 30 May 2018 11:49:01 -0400 Subject: [PATCH 084/762] Fix vault credential password prompt toggle show and hide --- .../prompt/steps/credential/prompt-credential.partial.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.partial.html b/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.partial.html index 7b1ca2b093..4421e9c933 100644 --- a/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.partial.html +++ b/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.partial.html @@ -118,7 +118,7 @@
- +
From 354b076956442cd51b57e1db4cd9a9ddc959d619 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 30 May 2018 13:34:14 -0400 Subject: [PATCH 085/762] clean up Tower inventory error handling related: https://github.com/ansible/tower/pull/1697 related: https://github.com/ansible/tower-qa/pull/1746 --- awx/plugins/inventory/tower.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/awx/plugins/inventory/tower.py b/awx/plugins/inventory/tower.py index ff14f6b731..f6aee944ff 100755 --- a/awx/plugins/inventory/tower.py +++ b/awx/plugins/inventory/tower.py @@ -90,7 +90,6 @@ def read_tower_inventory(tower_host, tower_user, tower_pass, inventory, license_ tower_host = "https://{}".format(tower_host) inventory_url = urljoin(tower_host, "/api/v2/inventories/{}/script/?hostvars=1&towervars=1&all=1".format(inventory.replace('/', ''))) config_url = urljoin(tower_host, "/api/v2/config/") - reason = None try: if license_type != "open": config_response = requests.get(config_url, @@ -102,22 +101,22 @@ def read_tower_inventory(tower_host, tower_user, tower_pass, inventory, license_ raise RuntimeError("Tower server licenses must match: source: {} local: {}".format(source_type, license_type)) else: - raise RuntimeError("Failed to validate the license of the remote Tower: {}".format(config_response.data)) + raise RuntimeError("Failed to validate the license of the remote Tower: {}".format(config_response)) response = requests.get(inventory_url, auth=HTTPBasicAuth(tower_user, tower_pass), verify=not ignore_ssl) + if not response.ok: + # If the GET /api/v2/inventories/N/script is not HTTP 200, print the error code + raise RuntimeError("Connection to remote host failed: {}".format(response)) try: - json_response = response.json() + # Attempt to parse JSON + return response.json() except (ValueError, TypeError) as e: - reason = "Failed to parse json from host: {}".format(e) - if response.ok: - return json_response - if not reason: - reason = json_response.get('detail', 'Retrieving Tower Inventory Failed') + # If the JSON parse fails, print the ValueError + raise RuntimeError("Failed to parse json from host: {}".format(e) except requests.ConnectionError as e: - reason = "Connection to remote host failed: {}".format(e) - raise RuntimeError(reason) + raise RuntimeError("Connection to remote host failed: {}".format(e)) def main(): From e889c976ebcef6fc5b89512841f124ff7cf63154 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Wed, 30 May 2018 13:56:19 -0400 Subject: [PATCH 086/762] Remove network_ui Client model --- awx/network_ui/CONTRIBUTING.md | 5 ++--- awx/network_ui/consumers.py | 13 +++++++------ awx/network_ui/docs/models.yml | 7 ------- .../migrations/0002_delete_client.py | 18 ++++++++++++++++++ awx/network_ui/models.py | 5 ----- .../tests/functional/test_consumers.py | 14 +++++++------- 6 files changed, 34 insertions(+), 28 deletions(-) create mode 100644 awx/network_ui/migrations/0002_delete_client.py diff --git a/awx/network_ui/CONTRIBUTING.md b/awx/network_ui/CONTRIBUTING.md index cd028ef1e1..106e5df02d 100644 --- a/awx/network_ui/CONTRIBUTING.md +++ b/awx/network_ui/CONTRIBUTING.md @@ -33,7 +33,7 @@ information about the interfaces on the devices and the links connecting those interfaces. These requirements determine the database schema needed for the network UI which -requires these models: Topology, Device, Interface, Link, Client, and TopologyInventory. +requires these models: Topology, Device, Interface, Link, and TopologyInventory. ![Models](designs/models.png) @@ -46,7 +46,6 @@ The models are: * Link - a physical connection between two devices to their respective interfaces * Topology - a collection of devices and links * TopologyInventory - a mapping between topologies and Tower inventories -* Client - a UI client session Network UI Websocket Protocol @@ -117,7 +116,7 @@ the database. Client Tracking --------------- -Each user session to the network UI canvas is tracked with the `Client` model. Multiple +Each user session to the network UI canvas is tracked with the `client_id` param. Multiple clients can view and interact with the network UI canvas at a time. They will see each other's edits to the canvas in real time. This works by broadcasting the canvas change events to all clients viewing the same topology. diff --git a/awx/network_ui/consumers.py b/awx/network_ui/consumers.py index d07b68b729..8b062883f3 100644 --- a/awx/network_ui/consumers.py +++ b/awx/network_ui/consumers.py @@ -1,13 +1,15 @@ # Copyright (c) 2017 Red Hat, Inc import channels from channels.auth import channel_session_user, channel_session_user_from_http -from awx.network_ui.models import Topology, Device, Link, Client, Interface +from awx.network_ui.models import Topology, Device, Link, Interface from awx.network_ui.models import TopologyInventory from awx.main.models.inventory import Inventory import urlparse from django.db.models import Q from collections import defaultdict import logging +import uuid +import six from awx.network_ui.utils import transform_dict @@ -245,11 +247,10 @@ def ws_connect(message): topology_id = topology.pk message.channel_session['topology_id'] = topology_id channels.Group("topology-%s" % topology_id).add(message.reply_channel) - client = Client() - client.save() - message.channel_session['client_id'] = client.pk - channels.Group("client-%s" % client.pk).add(message.reply_channel) - message.reply_channel.send({"text": json.dumps(["id", client.pk])}) + client_id = six.text_type(uuid.uuid4()) + message.channel_session['client_id'] = client_id + channels.Group("client-%s" % client_id).add(message.reply_channel) + message.reply_channel.send({"text": json.dumps(["id", client_id])}) message.reply_channel.send({"text": json.dumps(["topology_id", topology_id])}) topology_data = transform_dict(dict(id='topology_id', name='name', diff --git a/awx/network_ui/docs/models.yml b/awx/network_ui/docs/models.yml index 683dde4bfd..3176306249 100644 --- a/awx/network_ui/docs/models.yml +++ b/awx/network_ui/docs/models.yml @@ -86,13 +86,6 @@ models: name: Topology x: 111 y: 127 -- fields: - - name: client_id - pk: true - type: AutoField - name: Client - x: -162 - y: 282 - display: name fields: - name: interface_id diff --git a/awx/network_ui/migrations/0002_delete_client.py b/awx/network_ui/migrations/0002_delete_client.py new file mode 100644 index 0000000000..fe5708fa3c --- /dev/null +++ b/awx/network_ui/migrations/0002_delete_client.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-05-30 17:18 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('network_ui', '0001_initial'), + ] + + operations = [ + migrations.DeleteModel( + name='Client', + ), + ] diff --git a/awx/network_ui/models.py b/awx/network_ui/models.py index 07d87e26cc..9712a6ce00 100644 --- a/awx/network_ui/models.py +++ b/awx/network_ui/models.py @@ -42,11 +42,6 @@ class Topology(models.Model): return self.name -class Client(models.Model): - - id = models.AutoField(primary_key=True,) - - class Interface(models.Model): id = models.AutoField(primary_key=True,) diff --git a/awx/network_ui/tests/functional/test_consumers.py b/awx/network_ui/tests/functional/test_consumers.py index f5d3d36173..e60f028651 100644 --- a/awx/network_ui/tests/functional/test_consumers.py +++ b/awx/network_ui/tests/functional/test_consumers.py @@ -8,7 +8,7 @@ patch('channels.auth.channel_session_user_from_http', lambda x: x).start() from awx.main.models import Inventory # noqa from awx.network_ui.consumers import parse_inventory_id, networking_events_dispatcher, send_snapshot # noqa -from awx.network_ui.models import Topology, Device, Link, Interface, TopologyInventory, Client # noqa +from awx.network_ui.models import Topology, Device, Link, Interface, TopologyInventory # noqa import awx # noqa import awx.network_ui # noqa import awx.network_ui.consumers # noqa @@ -181,7 +181,7 @@ def test_ws_connect_new_topology(): mock_user = mock.Mock() message = mock.MagicMock(user=mock_user) logger = logging.getLogger('awx.network_ui.consumers') - with mock.patch('awx.network_ui.consumers.Client') as client_mock,\ + with mock.patch('awx.network_ui.consumers.uuid') as client_mock,\ mock.patch('awx.network_ui.consumers.Topology') as topology_mock,\ mock.patch('channels.Group'),\ mock.patch('awx.network_ui.consumers.send_snapshot') as send_snapshot_mock,\ @@ -194,13 +194,13 @@ def test_ws_connect_new_topology(): mock.patch.object(Link, 'objects'),\ mock.patch.object(Interface, 'objects'),\ mock.patch.object(Inventory, 'objects') as inventory_objects: - client_mock.return_value.pk = 777 + client_mock.uuid4 = mock.MagicMock(return_value="777") topology_mock.return_value = Topology( name="topology", scale=1.0, panX=0, panY=0, pk=999) inventory_objects.get.return_value = mock.Mock(admin_role=[mock_user]) awx.network_ui.consumers.ws_connect(message) message.reply_channel.send.assert_has_calls([ - mock.call({'text': '["id", 777]'}), + mock.call({'text': '["id", "777"]'}), mock.call({'text': '["topology_id", 999]'}), mock.call( {'text': '["Topology", {"scale": 1.0, "name": "topology", "device_id_seq": 0, "panY": 0, "panX": 0, "topology_id": 999, "link_id_seq": 0}]'}), @@ -212,7 +212,7 @@ def test_ws_connect_existing_topology(): mock_user = mock.Mock() message = mock.MagicMock(user=mock_user) logger = logging.getLogger('awx.network_ui.consumers') - with mock.patch('awx.network_ui.consumers.Client') as client_mock,\ + with mock.patch('awx.network_ui.consumers.uuid') as client_mock,\ mock.patch('awx.network_ui.consumers.send_snapshot') as send_snapshot_mock,\ mock.patch('channels.Group'),\ mock.patch.object(logger, 'warning'),\ @@ -226,7 +226,7 @@ def test_ws_connect_existing_topology(): mock.patch.object(Inventory, 'objects') as inventory_objects: topology_inventory_objects_mock.filter.return_value.values_list.return_value = [ 1] - client_mock.return_value.pk = 888 + client_mock.uuid4 = mock.MagicMock(return_value="888") topology_objects_mock.get.return_value = Topology(pk=1001, id=1, name="topo", @@ -238,7 +238,7 @@ def test_ws_connect_existing_topology(): inventory_objects.get.return_value = mock.Mock(admin_role=[mock_user]) awx.network_ui.consumers.ws_connect(message) message.reply_channel.send.assert_has_calls([ - mock.call({'text': '["id", 888]'}), + mock.call({'text': '["id", "888"]'}), mock.call({'text': '["topology_id", 1001]'}), mock.call( {'text': '["Topology", {"scale": 1.0, "name": "topo", "device_id_seq": 1, "panY": 0, "panX": 0, "topology_id": 1001, "link_id_seq": 1}]'}), From c690da40580fa9d7567631270dac903776e60edb Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Wed, 30 May 2018 13:04:50 -0400 Subject: [PATCH 087/762] make copy endpoints specific to v2 --- awx/api/generics.py | 8 ++++++++ awx/api/serializers.py | 21 ++++++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index 4f13e8585c..b0155e1429 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -821,6 +821,10 @@ class CopyAPIView(GenericAPIView): new_in_330 = True new_in_api_v2 = True + def v1_not_allowed(self): + return Response({'detail': 'Action only possible starting with v2 API.'}, + status=status.HTTP_404_NOT_FOUND) + def _get_copy_return_serializer(self, *args, **kwargs): if not self.copy_return_serializer_class: return self.get_serializer(*args, **kwargs) @@ -922,6 +926,8 @@ class CopyAPIView(GenericAPIView): return ret def get(self, request, *args, **kwargs): + if get_request_version(request) < 2: + return self.v1_not_allowed() obj = self.get_object() create_kwargs = self._build_create_dict(obj) for key in create_kwargs: @@ -929,6 +935,8 @@ class CopyAPIView(GenericAPIView): return Response({'can_copy': request.user.can_access(self.model, 'add', create_kwargs)}) def post(self, request, *args, **kwargs): + if get_request_version(request) < 2: + return self.v1_not_allowed() obj = self.get_object() create_kwargs = self._build_create_dict(obj) create_kwargs_check = {} diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 9a27eadf0c..6ed8026960 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1298,8 +1298,9 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): notification_templates_error = self.reverse('api:project_notification_templates_error_list', kwargs={'pk': obj.pk}), access_list = self.reverse('api:project_access_list', kwargs={'pk': obj.pk}), object_roles = self.reverse('api:project_object_roles_list', kwargs={'pk': obj.pk}), - copy = self.reverse('api:project_copy', kwargs={'pk': obj.pk}), )) + if self.version > 1: + res['copy'] = self.reverse('api:project_copy', kwargs={'pk': obj.pk}) if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) @@ -1456,8 +1457,9 @@ class InventorySerializer(BaseSerializerWithVariables): access_list = self.reverse('api:inventory_access_list', kwargs={'pk': obj.pk}), object_roles = self.reverse('api:inventory_object_roles_list', kwargs={'pk': obj.pk}), instance_groups = self.reverse('api:inventory_instance_groups_list', kwargs={'pk': obj.pk}), - copy = self.reverse('api:inventory_copy', kwargs={'pk': obj.pk}), )) + if self.version > 1: + res['copy'] = self.reverse('api:inventory_copy', kwargs={'pk': obj.pk}) if obj.insights_credential: res['insights_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.insights_credential.pk}) if obj.organization: @@ -1819,8 +1821,9 @@ class CustomInventoryScriptSerializer(BaseSerializer): res = super(CustomInventoryScriptSerializer, self).get_related(obj) res.update(dict( object_roles = self.reverse('api:inventory_script_object_roles_list', kwargs={'pk': obj.pk}), - copy = self.reverse('api:inventory_script_copy', kwargs={'pk': obj.pk}), )) + if self.version > 1: + res['copy'] = self.reverse('api:inventory_script_copy', kwargs={'pk': obj.pk}) if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) @@ -2457,8 +2460,9 @@ class CredentialSerializer(BaseSerializer): object_roles = self.reverse('api:credential_object_roles_list', kwargs={'pk': obj.pk}), owner_users = self.reverse('api:credential_owner_users_list', kwargs={'pk': obj.pk}), owner_teams = self.reverse('api:credential_owner_teams_list', kwargs={'pk': obj.pk}), - copy = self.reverse('api:credential_copy', kwargs={'pk': obj.pk}), )) + if self.version > 1: + res['copy'] = self.reverse('api:credential_copy', kwargs={'pk': obj.pk}) # TODO: remove when API v1 is removed if self.version > 1: @@ -2932,8 +2936,9 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO labels = self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}), object_roles = self.reverse('api:job_template_object_roles_list', kwargs={'pk': obj.pk}), instance_groups = self.reverse('api:job_template_instance_groups_list', kwargs={'pk': obj.pk}), - copy = self.reverse('api:job_template_copy', kwargs={'pk': obj.pk}), )) + if self.version > 1: + res['copy'] = self.reverse('api:job_template_copy', kwargs={'pk': obj.pk}) if obj.host_config_key: res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk}) return res @@ -3398,7 +3403,6 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo workflow_jobs = self.reverse('api:workflow_job_template_jobs_list', kwargs={'pk': obj.pk}), schedules = self.reverse('api:workflow_job_template_schedules_list', kwargs={'pk': obj.pk}), launch = self.reverse('api:workflow_job_template_launch', kwargs={'pk': obj.pk}), - copy = self.reverse('api:workflow_job_template_copy', kwargs={'pk': obj.pk}), workflow_nodes = self.reverse('api:workflow_job_template_workflow_nodes_list', kwargs={'pk': obj.pk}), labels = self.reverse('api:workflow_job_template_label_list', kwargs={'pk': obj.pk}), activity_stream = self.reverse('api:workflow_job_template_activity_stream_list', kwargs={'pk': obj.pk}), @@ -3409,6 +3413,8 @@ class WorkflowJobTemplateSerializer(JobTemplateMixin, LabelsListMixin, UnifiedJo object_roles = self.reverse('api:workflow_job_template_object_roles_list', kwargs={'pk': obj.pk}), survey_spec = self.reverse('api:workflow_job_template_survey_spec', kwargs={'pk': obj.pk}), )) + if self.version > 1: + res['copy'] = self.reverse('api:workflow_job_template_copy', kwargs={'pk': obj.pk}) if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) return res @@ -4222,8 +4228,9 @@ class NotificationTemplateSerializer(BaseSerializer): res.update(dict( test = self.reverse('api:notification_template_test', kwargs={'pk': obj.pk}), notifications = self.reverse('api:notification_template_notification_list', kwargs={'pk': obj.pk}), - copy = self.reverse('api:notification_template_copy', kwargs={'pk': obj.pk}), )) + if self.version > 1: + res['copy'] = self.reverse('api:notification_template_copy', kwargs={'pk': obj.pk}) if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) return res From 9bc871db478bb606d56dd5c2d8357541ca592f38 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 30 May 2018 15:13:49 -0400 Subject: [PATCH 088/762] add a test for tower inventory syncs --- awx/main/tests/unit/test_tasks.py | 34 +++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index bc403bd015..07a111654b 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -2100,6 +2100,40 @@ class TestInventoryUpdateCredentials(TestJobExecution): self.run_pexpect.side_effect = run_pexpect_side_effect self.task.run(self.pk) + @pytest.mark.parametrize('verify', [True, False]) + def test_tower_source(self, verify): + tower = CredentialType.defaults['tower']() + self.instance.source = 'tower' + self.instance.instance_filters = '12345' + inputs = { + 'host': 'https://tower.example.org', + 'username': 'bob', + 'password': 'secret', + 'verify_ssl': verify + } + + def get_cred(): + cred = Credential(pk=1, credential_type=tower, inputs = inputs) + cred.inputs['password'] = encrypt_field(cred, 'password') + return cred + self.instance.get_cloud_credential = get_cred + + def run_pexpect_side_effect(*args, **kwargs): + args, cwd, env, stdout = args + assert env['TOWER_HOST'] == 'https://tower.example.org' + assert env['TOWER_USERNAME'] == 'bob' + assert env['TOWER_PASSWORD'] == 'secret' + assert env['TOWER_INVENTORY'] == '12345' + if verify: + assert env['TOWER_VERIFY_SSL'] == 'True' + else: + assert env['TOWER_VERIFY_SSL'] == 'False' + return ['successful', 0] + + self.run_pexpect.side_effect = run_pexpect_side_effect + self.task.run(self.pk) + assert self.instance.job_env['TOWER_PASSWORD'] == tasks.HIDDEN_PASSWORD + def test_awx_task_env(self): gce = CredentialType.defaults['gce']() self.instance.source = 'gce' From 015b19d8c3c5c6bcb1f5f365b6c3b01dd86a335b Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Wed, 30 May 2018 15:21:51 -0400 Subject: [PATCH 089/762] interpret null protocol as logging.NullHandler --- awx/main/tests/unit/utils/test_handlers.py | 11 +++++++++++ awx/main/utils/handlers.py | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/awx/main/tests/unit/utils/test_handlers.py b/awx/main/tests/unit/utils/test_handlers.py index f57d86158d..eb94727f21 100644 --- a/awx/main/tests/unit/utils/test_handlers.py +++ b/awx/main/tests/unit/utils/test_handlers.py @@ -127,6 +127,17 @@ def test_invalid_kwarg_to_real_handler(): assert not hasattr(handler, 'verify_cert') +def test_protocol_not_specified(): + settings = LazySettings() + settings.configure(**{ + 'LOG_AGGREGATOR_HOST': 'https://server.invalid', + 'LOG_AGGREGATOR_PORT': 22222, + 'LOG_AGGREGATOR_PROTOCOL': None # awx/settings/defaults.py + }) + handler = AWXProxyHandler().get_handler(custom_settings=settings) + assert isinstance(handler, logging.NullHandler) + + def test_base_logging_handler_emit_system_tracking(dummy_log_record): handler = BaseHandler(host='127.0.0.1', indv_facts=True) handler.setFormatter(LogstashFormatter()) diff --git a/awx/main/utils/handlers.py b/awx/main/utils/handlers.py index 214c40ff11..fcccf5d9b1 100644 --- a/awx/main/utils/handlers.py +++ b/awx/main/utils/handlers.py @@ -257,6 +257,15 @@ class UDPHandler(BaseHandler): return SocketResult(True, reason=self.message) +class AWXNullHandler(logging.NullHandler): + ''' + Only additional this does is accept arbitrary __init__ params because + the proxy handler does not (yet) work with arbitrary handler classes + ''' + def __init__(self, *args, **kwargs): + super(AWXNullHandler, self).__init__() + + HANDLER_MAPPING = { 'https': BaseHTTPSHandler, 'tcp': TCPHandler, @@ -285,7 +294,7 @@ class AWXProxyHandler(logging.Handler): self._old_kwargs = {} def get_handler_class(self, protocol): - return HANDLER_MAPPING[protocol] + return HANDLER_MAPPING.get(protocol, AWXNullHandler) def get_handler(self, custom_settings=None, force_create=False): new_kwargs = {} From 70897d3503f4dcc8771b40f896f4af11261a83ea Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Wed, 30 May 2018 14:19:07 -0700 Subject: [PATCH 090/762] Prevents Network UI from scrolling and showing Tower I also fixed an issue where the mouse-wheel events were not zooming the network UI in firefox. --- awx/ui/client/index.template.ejs | 2 +- .../src/network-ui/network-nav/network.nav.block.less | 8 ++++++++ .../src/network-ui/network-nav/network.nav.controller.js | 3 ++- awx/ui/client/src/network-ui/network.ui.controller.js | 7 +++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/index.template.ejs b/awx/ui/client/index.template.ejs index c8d4e0d758..146d3290d7 100644 --- a/awx/ui/client/index.template.ejs +++ b/awx/ui/client/index.template.ejs @@ -19,7 +19,7 @@ -
+
diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.block.less b/awx/ui/client/src/network-ui/network-nav/network.nav.block.less index 552c0151bc..c7d283bedd 100644 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.block.less +++ b/awx/ui/client/src/network-ui/network-nav/network.nav.block.less @@ -1,3 +1,11 @@ +.NetworkingUIView{ + position:absolute; + display:block; + width:100vw; + height: 100vh; + z-index: 1101; +} + .Networking-shell{ display:flex; flex-direction: column; diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js b/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js index 820ed7e37d..fba7aa1058 100644 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js +++ b/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js @@ -5,7 +5,7 @@ function NetworkingController (models, $state, $scope, strings) { const { inventory } = models; - + vm.networkUIisOpen = true; vm.strings = strings; vm.panelTitle = `${strings.get('state.BREADCRUMB_LABEL')} | ${inventory.name}`; vm.hostDetail = {}; @@ -16,6 +16,7 @@ function NetworkingController (models, $state, $scope, strings) { vm.groups = []; $scope.devices = []; vm.close = () => { + vm.networkUIisOpen = false; $scope.$broadcast('awxNet-closeNetworkUI'); $state.go('inventories'); }; diff --git a/awx/ui/client/src/network-ui/network.ui.controller.js b/awx/ui/client/src/network-ui/network.ui.controller.js index 7f324988c7..8e753e4e19 100644 --- a/awx/ui/client/src/network-ui/network.ui.controller.js +++ b/awx/ui/client/src/network-ui/network.ui.controller.js @@ -550,6 +550,13 @@ var NetworkUIController = function($scope, if (originalEvent.wheelDeltaY !== undefined) { $event.deltaY = $event.originalEvent.wheelDeltaY; } + if (originalEvent.deltaX !== undefined) { + $event.deltaX = $event.originalEvent.deltaX; + } + if (originalEvent.deltaY !== undefined) { + $event.deltaY = $event.originalEvent.deltaY; + $event.delta = $event.originalEvent.deltaY; + } } }; From 253606c8bf5fac08c5e7ed3654ed4178c10cd8f4 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 31 May 2018 08:30:41 -0400 Subject: [PATCH 091/762] allow managing credentials with external user management --- awx/main/access.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index ed2886f4b8..0f1a100946 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -537,8 +537,8 @@ class UserAccess(BaseAccess): return not self.user_membership_roles(u).exists() @check_superuser - def can_admin(self, obj, data, allow_orphans=False): - if not settings.MANAGE_ORGANIZATION_AUTH: + def can_admin(self, obj, data, allow_orphans=False, check_setting=True): + if check_setting and (not settings.MANAGE_ORGANIZATION_AUTH): return False if obj.is_superuser or obj.is_system_auditor: # must be superuser to admin users with system roles @@ -1071,7 +1071,7 @@ class CredentialAccess(BaseAccess): return True if data and data.get('user', None): user_obj = get_object_from_data('user', User, data) - return check_user_access(self.user, User, 'change', user_obj, None) + return bool(self.user == user_obj or UserAccess(self.user).can_admin(user_obj, None, check_setting=False)) if data and data.get('team', None): team_obj = get_object_from_data('team', Team, data) return check_user_access(self.user, Team, 'change', team_obj, None) From f692f0c70a3c90e53c7415f18faf6a7d15fd28f9 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 31 May 2018 08:58:38 -0400 Subject: [PATCH 092/762] test creating credential for self & org_member --- .../tests/functional/test_rbac_credential.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/awx/main/tests/functional/test_rbac_credential.py b/awx/main/tests/functional/test_rbac_credential.py index 783fdd9e82..2b134c18f5 100644 --- a/awx/main/tests/functional/test_rbac_credential.py +++ b/awx/main/tests/functional/test_rbac_credential.py @@ -1,5 +1,7 @@ import pytest +import mock + from awx.main.access import CredentialAccess from awx.main.models.credential import Credential from django.contrib.auth.models import User @@ -22,6 +24,21 @@ def test_credential_access_superuser(): assert access.can_delete(credential) +@pytest.mark.django_db +def test_credential_access_self(rando): + access = CredentialAccess(rando) + assert access.can_add({'user': rando.pk}) + + +@pytest.mark.django_db +@pytest.mark.parametrize('ext_auth', [True, False]) +def test_credential_access_org_user(org_member, org_admin, ext_auth): + access = CredentialAccess(org_admin) + with mock.patch('awx.main.access.settings') as settings_mock: + settings_mock.MANAGE_ORGANIZATION_AUTH = ext_auth + assert access.can_add({'user': org_member.pk}) + + @pytest.mark.django_db def test_credential_access_auditor(credential, organization_factory): objects = organization_factory("org_cred_auditor", From b7a5c10ce4bec1a4c9881804a497a23aa637df02 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 31 May 2018 10:19:43 -0400 Subject: [PATCH 093/762] make our oauth model creation a dependency of other app --- .../0025_v330_add_oauth_activity_stream_registrar.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/awx/main/migrations/0025_v330_add_oauth_activity_stream_registrar.py b/awx/main/migrations/0025_v330_add_oauth_activity_stream_registrar.py index e7d2ef49b9..993dcc2d33 100644 --- a/awx/main/migrations/0025_v330_add_oauth_activity_stream_registrar.py +++ b/awx/main/migrations/0025_v330_add_oauth_activity_stream_registrar.py @@ -13,6 +13,12 @@ class Migration(migrations.Migration): dependencies = [ ('main', '0024_v330_create_user_session_membership'), ] + run_before = [ + # As of this migration, OAuth2Application and OAuth2AccessToken are models in main app + # Grant and RefreshToken models are still in the oauth2_provider app and reference + # the app and token models, so these must be created before the oauth2_provider models + ('oauth2_provider', '0001_initial') + ] operations = [ From 246e63bdb628ae0e68b73208252a7d15b164d5db Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Thu, 31 May 2018 10:53:57 -0400 Subject: [PATCH 094/762] Update translation templates --- awx/locale/django.pot | 1130 ++++++++++++++++++++------------ awx/ui/po/ansible-tower-ui.pot | 597 ++++++++++------- 2 files changed, 1074 insertions(+), 653 deletions(-) diff --git a/awx/locale/django.pot b/awx/locale/django.pot index af37525098..ec44ccf81a 100644 --- a/awx/locale/django.pot +++ b/awx/locale/django.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2018-05-07 21:24+0000\n" +"POT-Creation-Date: 2018-05-31 14:49+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -107,15 +107,15 @@ msgstr "" msgid "cannot filter on kind %s" msgstr "" -#: awx/api/generics.py:600 awx/api/generics.py:662 +#: awx/api/generics.py:620 awx/api/generics.py:682 msgid "\"id\" field must be an integer." msgstr "" -#: awx/api/generics.py:659 +#: awx/api/generics.py:679 msgid "\"id\" is required to disassociate" msgstr "" -#: awx/api/generics.py:710 +#: awx/api/generics.py:730 msgid "{} 'id' field is missing." msgstr "" @@ -223,425 +223,429 @@ msgstr "" msgid "Unable to change %s on user managed by LDAP." msgstr "" -#: awx/api/serializers.py:1169 +#: awx/api/serializers.py:1055 msgid "Must be a simple space-separated string with allowed scopes {}." msgstr "" -#: awx/api/serializers.py:1386 +#: awx/api/serializers.py:1250 msgid "This path is already being used by another manual project." msgstr "" -#: awx/api/serializers.py:1467 +#: awx/api/serializers.py:1276 +msgid "This field has been deprecated and will be removed in a future release" +msgstr "" + +#: awx/api/serializers.py:1335 msgid "Organization is missing" msgstr "" -#: awx/api/serializers.py:1471 +#: awx/api/serializers.py:1339 msgid "Update options must be set to false for manual projects." msgstr "" -#: awx/api/serializers.py:1477 +#: awx/api/serializers.py:1345 msgid "Array of playbooks available within this project." msgstr "" -#: awx/api/serializers.py:1496 +#: awx/api/serializers.py:1364 msgid "" "Array of inventory files and directories available within this project, not " "comprehensive." msgstr "" -#: awx/api/serializers.py:1629 +#: awx/api/serializers.py:1498 msgid "Smart inventories must specify host_filter" msgstr "" -#: awx/api/serializers.py:1733 +#: awx/api/serializers.py:1602 #, python-format msgid "Invalid port specification: %s" msgstr "" -#: awx/api/serializers.py:1744 +#: awx/api/serializers.py:1613 msgid "Cannot create Host for Smart Inventory" msgstr "" -#: awx/api/serializers.py:1856 +#: awx/api/serializers.py:1725 msgid "Invalid group name." msgstr "" -#: awx/api/serializers.py:1861 +#: awx/api/serializers.py:1730 msgid "Cannot create Group for Smart Inventory" msgstr "" -#: awx/api/serializers.py:1936 +#: awx/api/serializers.py:1805 msgid "" "Script must begin with a hashbang sequence: i.e.... #!/usr/bin/env python" msgstr "" -#: awx/api/serializers.py:1984 +#: awx/api/serializers.py:1854 msgid "`{}` is a prohibited environment variable" msgstr "" -#: awx/api/serializers.py:1995 +#: awx/api/serializers.py:1865 msgid "If 'source' is 'custom', 'source_script' must be provided." msgstr "" -#: awx/api/serializers.py:2001 +#: awx/api/serializers.py:1871 msgid "Must provide an inventory." msgstr "" -#: awx/api/serializers.py:2005 +#: awx/api/serializers.py:1875 msgid "" "The 'source_script' does not belong to the same organization as the " "inventory." msgstr "" -#: awx/api/serializers.py:2007 +#: awx/api/serializers.py:1877 msgid "'source_script' doesn't exist." msgstr "" -#: awx/api/serializers.py:2041 +#: awx/api/serializers.py:1911 msgid "Automatic group relationship, will be removed in 3.3" msgstr "" -#: awx/api/serializers.py:2127 +#: awx/api/serializers.py:1997 msgid "Cannot use manual project for SCM-based inventory." msgstr "" -#: awx/api/serializers.py:2133 +#: awx/api/serializers.py:2003 msgid "" "Manual inventory sources are created automatically when a group is created " "in the v1 API." msgstr "" -#: awx/api/serializers.py:2138 +#: awx/api/serializers.py:2008 msgid "Setting not compatible with existing schedules." msgstr "" -#: awx/api/serializers.py:2143 +#: awx/api/serializers.py:2013 msgid "Cannot create Inventory Source for Smart Inventory" msgstr "" -#: awx/api/serializers.py:2194 +#: awx/api/serializers.py:2064 #, python-format msgid "Cannot set %s if not SCM type." msgstr "" -#: awx/api/serializers.py:2461 +#: awx/api/serializers.py:2331 msgid "Modifications not allowed for managed credential types" msgstr "" -#: awx/api/serializers.py:2466 +#: awx/api/serializers.py:2336 msgid "" "Modifications to inputs are not allowed for credential types that are in use" msgstr "" -#: awx/api/serializers.py:2472 +#: awx/api/serializers.py:2342 #, python-format msgid "Must be 'cloud' or 'net', not %s" msgstr "" -#: awx/api/serializers.py:2478 +#: awx/api/serializers.py:2348 msgid "'ask_at_runtime' is not supported for custom credentials." msgstr "" -#: awx/api/serializers.py:2656 +#: awx/api/serializers.py:2528 #, python-format msgid "\"%s\" is not a valid choice" msgstr "" -#: awx/api/serializers.py:2675 +#: awx/api/serializers.py:2547 #, python-brace-format msgid "'{field_name}' is not a valid field for {credential_type_name}" msgstr "" -#: awx/api/serializers.py:2696 +#: awx/api/serializers.py:2568 msgid "" "You cannot change the credential type of the credential, as it may break the " "functionality of the resources using it." msgstr "" -#: awx/api/serializers.py:2708 +#: awx/api/serializers.py:2580 msgid "" "Write-only field used to add user to owner role. If provided, do not give " "either team or organization. Only valid for creation." msgstr "" -#: awx/api/serializers.py:2713 +#: awx/api/serializers.py:2585 msgid "" "Write-only field used to add team to owner role. If provided, do not give " "either user or organization. Only valid for creation." msgstr "" -#: awx/api/serializers.py:2718 +#: awx/api/serializers.py:2590 msgid "" "Inherit permissions from organization roles. If provided on creation, do not " "give either user or team." msgstr "" -#: awx/api/serializers.py:2734 +#: awx/api/serializers.py:2606 msgid "Missing 'user', 'team', or 'organization'." msgstr "" -#: awx/api/serializers.py:2774 +#: awx/api/serializers.py:2646 msgid "" "Credential organization must be set and match before assigning to a team" msgstr "" -#: awx/api/serializers.py:2974 +#: awx/api/serializers.py:2846 msgid "You must provide a cloud credential." msgstr "" -#: awx/api/serializers.py:2975 +#: awx/api/serializers.py:2847 msgid "You must provide a network credential." msgstr "" -#: awx/api/serializers.py:2976 awx/main/models/jobs.py:155 +#: awx/api/serializers.py:2848 awx/main/models/jobs.py:155 msgid "You must provide an SSH credential." msgstr "" -#: awx/api/serializers.py:2977 +#: awx/api/serializers.py:2849 msgid "You must provide a vault credential." msgstr "" -#: awx/api/serializers.py:2996 +#: awx/api/serializers.py:2868 msgid "This field is required." msgstr "" -#: awx/api/serializers.py:2998 awx/api/serializers.py:3000 +#: awx/api/serializers.py:2870 awx/api/serializers.py:2872 msgid "Playbook not found for project." msgstr "" -#: awx/api/serializers.py:3002 +#: awx/api/serializers.py:2874 msgid "Must select playbook for project." msgstr "" -#: awx/api/serializers.py:3082 +#: awx/api/serializers.py:2955 msgid "Cannot enable provisioning callback without an inventory set." msgstr "" -#: awx/api/serializers.py:3085 +#: awx/api/serializers.py:2958 msgid "Must either set a default value or ask to prompt on launch." msgstr "" -#: awx/api/serializers.py:3087 awx/main/models/jobs.py:310 +#: awx/api/serializers.py:2960 awx/main/models/jobs.py:310 msgid "Job types 'run' and 'check' must have assigned a project." msgstr "" -#: awx/api/serializers.py:3203 +#: awx/api/serializers.py:3076 msgid "Invalid job template." msgstr "" -#: awx/api/serializers.py:3276 +#: awx/api/serializers.py:3149 msgid "No change to job limit" msgstr "" -#: awx/api/serializers.py:3277 +#: awx/api/serializers.py:3150 msgid "All failed and unreachable hosts" msgstr "" -#: awx/api/serializers.py:3292 +#: awx/api/serializers.py:3165 msgid "Missing passwords needed to start: {}" msgstr "" -#: awx/api/serializers.py:3311 +#: awx/api/serializers.py:3184 msgid "Relaunch by host status not available until job finishes running." msgstr "" -#: awx/api/serializers.py:3325 +#: awx/api/serializers.py:3198 msgid "Job Template Project is missing or undefined." msgstr "" -#: awx/api/serializers.py:3327 +#: awx/api/serializers.py:3200 msgid "Job Template Inventory is missing or undefined." msgstr "" -#: awx/api/serializers.py:3365 +#: awx/api/serializers.py:3238 msgid "Unknown, job may have been ran before launch configurations were saved." msgstr "" -#: awx/api/serializers.py:3432 awx/main/tasks.py:2238 +#: awx/api/serializers.py:3305 awx/main/tasks.py:2262 msgid "{} are prohibited from use in ad hoc commands." msgstr "" -#: awx/api/serializers.py:3501 awx/api/views.py:4763 +#: awx/api/serializers.py:3374 awx/api/views.py:4818 #, python-brace-format msgid "" "Standard Output too large to display ({text_size} bytes), only download " "supported for sizes over {supported_size} bytes." msgstr "" -#: awx/api/serializers.py:3697 +#: awx/api/serializers.py:3571 msgid "Provided variable {} has no database value to replace with." msgstr "" -#: awx/api/serializers.py:3773 +#: awx/api/serializers.py:3647 #, python-format msgid "Cannot nest a %s inside a WorkflowJobTemplate" msgstr "" -#: awx/api/serializers.py:3780 awx/api/views.py:776 +#: awx/api/serializers.py:3654 awx/api/views.py:781 msgid "Related template is not configured to accept credentials on launch." msgstr "" -#: awx/api/serializers.py:4234 +#: awx/api/serializers.py:4108 msgid "The inventory associated with this Job Template is being deleted." msgstr "" -#: awx/api/serializers.py:4236 +#: awx/api/serializers.py:4110 msgid "The provided inventory is being deleted." msgstr "" -#: awx/api/serializers.py:4244 +#: awx/api/serializers.py:4118 msgid "Cannot assign multiple {} credentials." msgstr "" -#: awx/api/serializers.py:4257 +#: awx/api/serializers.py:4131 msgid "" "Removing {} credential at launch time without replacement is not supported. " "Provided list lacked credential(s): {}." msgstr "" -#: awx/api/serializers.py:4382 +#: awx/api/serializers.py:4257 msgid "" "Missing required fields for Notification Configuration: notification_type" msgstr "" -#: awx/api/serializers.py:4405 +#: awx/api/serializers.py:4280 msgid "No values specified for field '{}'" msgstr "" -#: awx/api/serializers.py:4410 +#: awx/api/serializers.py:4285 msgid "Missing required fields for Notification Configuration: {}." msgstr "" -#: awx/api/serializers.py:4413 +#: awx/api/serializers.py:4288 msgid "Configuration field '{}' incorrect type, expected {}." msgstr "" -#: awx/api/serializers.py:4475 +#: awx/api/serializers.py:4350 msgid "" "Valid DTSTART required in rrule. Value should start with: DTSTART:" "YYYYMMDDTHHMMSSZ" msgstr "" -#: awx/api/serializers.py:4477 +#: awx/api/serializers.py:4352 msgid "" "DTSTART cannot be a naive datetime. Specify ;TZINFO= or YYYYMMDDTHHMMSSZZ." msgstr "" -#: awx/api/serializers.py:4479 +#: awx/api/serializers.py:4354 msgid "Multiple DTSTART is not supported." msgstr "" -#: awx/api/serializers.py:4481 +#: awx/api/serializers.py:4356 msgid "RRULE required in rrule." msgstr "" -#: awx/api/serializers.py:4483 +#: awx/api/serializers.py:4358 msgid "Multiple RRULE is not supported." msgstr "" -#: awx/api/serializers.py:4485 +#: awx/api/serializers.py:4360 msgid "INTERVAL required in rrule." msgstr "" -#: awx/api/serializers.py:4487 +#: awx/api/serializers.py:4362 msgid "SECONDLY is not supported." msgstr "" -#: awx/api/serializers.py:4489 +#: awx/api/serializers.py:4364 msgid "Multiple BYMONTHDAYs not supported." msgstr "" -#: awx/api/serializers.py:4491 +#: awx/api/serializers.py:4366 msgid "Multiple BYMONTHs not supported." msgstr "" -#: awx/api/serializers.py:4493 +#: awx/api/serializers.py:4368 msgid "BYDAY with numeric prefix not supported." msgstr "" -#: awx/api/serializers.py:4495 +#: awx/api/serializers.py:4370 msgid "BYYEARDAY not supported." msgstr "" -#: awx/api/serializers.py:4497 +#: awx/api/serializers.py:4372 msgid "BYWEEKNO not supported." msgstr "" -#: awx/api/serializers.py:4499 +#: awx/api/serializers.py:4374 msgid "RRULE may not contain both COUNT and UNTIL" msgstr "" -#: awx/api/serializers.py:4503 +#: awx/api/serializers.py:4378 msgid "COUNT > 999 is unsupported." msgstr "" -#: awx/api/serializers.py:4507 +#: awx/api/serializers.py:4382 msgid "rrule parsing failed validation: {}" msgstr "" -#: awx/api/serializers.py:4538 +#: awx/api/serializers.py:4423 msgid "Inventory Source must be a cloud resource." msgstr "" -#: awx/api/serializers.py:4540 +#: awx/api/serializers.py:4425 msgid "Manual Project cannot have a schedule set." msgstr "" -#: awx/api/serializers.py:4553 +#: awx/api/serializers.py:4438 msgid "" "Count of jobs in the running or waiting state that are targeted for this " "instance" msgstr "" -#: awx/api/serializers.py:4593 +#: awx/api/serializers.py:4478 msgid "" "Minimum percentage of all instances that will be automatically assigned to " "this group when new instances come online." msgstr "" -#: awx/api/serializers.py:4598 +#: awx/api/serializers.py:4483 msgid "" "Static minimum number of Instances that will be automatically assign to this " "group when new instances come online." msgstr "" -#: awx/api/serializers.py:4603 +#: awx/api/serializers.py:4488 msgid "List of exact-match Instances that will be assigned to this group" msgstr "" -#: awx/api/serializers.py:4624 +#: awx/api/serializers.py:4509 msgid "Duplicate entry {}." msgstr "" -#: awx/api/serializers.py:4626 +#: awx/api/serializers.py:4511 msgid "{} is not a valid hostname of an existing instance." msgstr "" -#: awx/api/serializers.py:4631 +#: awx/api/serializers.py:4516 msgid "tower instance group name may not be changed." msgstr "" -#: awx/api/serializers.py:4711 +#: awx/api/serializers.py:4596 msgid "" "A summary of the new and changed values when an object is created, updated, " "or deleted" msgstr "" -#: awx/api/serializers.py:4713 +#: awx/api/serializers.py:4598 msgid "" "For create, update, and delete events this is the object type that was " "affected. For associate and disassociate events this is the object type " "associated or disassociated with object2." msgstr "" -#: awx/api/serializers.py:4716 +#: awx/api/serializers.py:4601 msgid "" "Unpopulated for create, update, and delete events. For associate and " "disassociate events this is the object type that object1 is being associated " "with." msgstr "" -#: awx/api/serializers.py:4719 +#: awx/api/serializers.py:4604 msgid "The action taken with respect to the given object(s)." msgstr "" @@ -701,514 +705,514 @@ msgstr "" msgid "Configuration" msgstr "" -#: awx/api/views.py:414 +#: awx/api/views.py:416 msgid "Invalid license data" msgstr "" -#: awx/api/views.py:416 +#: awx/api/views.py:418 msgid "Missing 'eula_accepted' property" msgstr "" -#: awx/api/views.py:420 +#: awx/api/views.py:422 msgid "'eula_accepted' value is invalid" msgstr "" -#: awx/api/views.py:423 +#: awx/api/views.py:425 msgid "'eula_accepted' must be True" msgstr "" -#: awx/api/views.py:430 +#: awx/api/views.py:432 msgid "Invalid JSON" msgstr "" -#: awx/api/views.py:438 +#: awx/api/views.py:440 msgid "Invalid License" msgstr "" -#: awx/api/views.py:448 +#: awx/api/views.py:450 msgid "Invalid license" msgstr "" -#: awx/api/views.py:456 +#: awx/api/views.py:458 #, python-format msgid "Failed to remove license (%s)" msgstr "" -#: awx/api/views.py:461 +#: awx/api/views.py:463 msgid "Dashboard" msgstr "" -#: awx/api/views.py:560 +#: awx/api/views.py:562 msgid "Dashboard Jobs Graphs" msgstr "" -#: awx/api/views.py:596 +#: awx/api/views.py:598 #, python-format msgid "Unknown period \"%s\"" msgstr "" -#: awx/api/views.py:610 +#: awx/api/views.py:612 msgid "Instances" msgstr "" -#: awx/api/views.py:617 +#: awx/api/views.py:620 msgid "Instance Detail" msgstr "" -#: awx/api/views.py:638 +#: awx/api/views.py:641 msgid "Instance Jobs" msgstr "" -#: awx/api/views.py:652 +#: awx/api/views.py:655 msgid "Instance's Instance Groups" msgstr "" -#: awx/api/views.py:661 +#: awx/api/views.py:664 msgid "Instance Groups" msgstr "" -#: awx/api/views.py:669 +#: awx/api/views.py:672 msgid "Instance Group Detail" msgstr "" -#: awx/api/views.py:677 +#: awx/api/views.py:680 msgid "Isolated Groups can not be removed from the API" msgstr "" -#: awx/api/views.py:679 +#: awx/api/views.py:682 msgid "" "Instance Groups acting as a controller for an Isolated Group can not be " "removed from the API" msgstr "" -#: awx/api/views.py:685 +#: awx/api/views.py:688 msgid "Instance Group Running Jobs" msgstr "" -#: awx/api/views.py:694 +#: awx/api/views.py:697 msgid "Instance Group's Instances" msgstr "" -#: awx/api/views.py:703 +#: awx/api/views.py:707 msgid "Schedules" msgstr "" -#: awx/api/views.py:717 +#: awx/api/views.py:721 msgid "Schedule Recurrence Rule Preview" msgstr "" -#: awx/api/views.py:763 +#: awx/api/views.py:768 msgid "Cannot assign credential when related template is null." msgstr "" -#: awx/api/views.py:768 +#: awx/api/views.py:773 msgid "Related template cannot accept {} on launch." msgstr "" -#: awx/api/views.py:770 +#: awx/api/views.py:775 msgid "" "Credential that requires user input on launch cannot be used in saved launch " "configuration." msgstr "" -#: awx/api/views.py:778 +#: awx/api/views.py:783 #, python-brace-format msgid "" "This launch configuration already provides a {credential_type} credential." msgstr "" -#: awx/api/views.py:781 +#: awx/api/views.py:786 #, python-brace-format msgid "Related template already uses {credential_type} credential." msgstr "" -#: awx/api/views.py:799 +#: awx/api/views.py:804 msgid "Schedule Jobs List" msgstr "" -#: awx/api/views.py:954 +#: awx/api/views.py:959 msgid "Your license only permits a single organization to exist." msgstr "" -#: awx/api/views.py:1183 awx/api/views.py:4972 +#: awx/api/views.py:1191 awx/api/views.py:5031 msgid "You cannot assign an Organization role as a child role for a Team." msgstr "" -#: awx/api/views.py:1187 awx/api/views.py:4986 +#: awx/api/views.py:1195 awx/api/views.py:5045 msgid "You cannot grant system-level permissions to a team." msgstr "" -#: awx/api/views.py:1194 awx/api/views.py:4978 +#: awx/api/views.py:1202 awx/api/views.py:5037 msgid "" "You cannot grant credential access to a team when the Organization field " "isn't set, or belongs to a different organization" msgstr "" -#: awx/api/views.py:1306 +#: awx/api/views.py:1316 msgid "Project Schedules" msgstr "" -#: awx/api/views.py:1317 +#: awx/api/views.py:1327 msgid "Project SCM Inventory Sources" msgstr "" -#: awx/api/views.py:1417 +#: awx/api/views.py:1428 msgid "Project Update Events List" msgstr "" -#: awx/api/views.py:1430 +#: awx/api/views.py:1442 msgid "System Job Events List" msgstr "" -#: awx/api/views.py:1443 +#: awx/api/views.py:1456 msgid "Inventory Update Events List" msgstr "" -#: awx/api/views.py:1475 +#: awx/api/views.py:1490 msgid "Project Update SCM Inventory Updates" msgstr "" -#: awx/api/views.py:1533 +#: awx/api/views.py:1549 msgid "Me" msgstr "" -#: awx/api/views.py:1541 +#: awx/api/views.py:1557 msgid "OAuth 2 Applications" msgstr "" -#: awx/api/views.py:1550 +#: awx/api/views.py:1566 msgid "OAuth 2 Application Detail" msgstr "" -#: awx/api/views.py:1559 +#: awx/api/views.py:1575 msgid "OAuth 2 Application Tokens" msgstr "" -#: awx/api/views.py:1580 +#: awx/api/views.py:1597 msgid "OAuth2 Tokens" msgstr "" -#: awx/api/views.py:1589 -msgid "OAuth2 Authorized Access Tokens" +#: awx/api/views.py:1606 +msgid "OAuth2 User Tokens" msgstr "" -#: awx/api/views.py:1604 +#: awx/api/views.py:1618 msgid "OAuth2 User Authorized Access Tokens" msgstr "" -#: awx/api/views.py:1619 +#: awx/api/views.py:1633 msgid "Organization OAuth2 Applications" msgstr "" -#: awx/api/views.py:1631 +#: awx/api/views.py:1645 msgid "OAuth2 Personal Access Tokens" msgstr "" -#: awx/api/views.py:1646 +#: awx/api/views.py:1660 msgid "OAuth Token Detail" msgstr "" -#: awx/api/views.py:1704 awx/api/views.py:4939 +#: awx/api/views.py:1720 awx/api/views.py:4998 msgid "" "You cannot grant credential access to a user not in the credentials' " "organization" msgstr "" -#: awx/api/views.py:1708 awx/api/views.py:4943 +#: awx/api/views.py:1724 awx/api/views.py:5002 msgid "You cannot grant private credential access to another user" msgstr "" -#: awx/api/views.py:1805 +#: awx/api/views.py:1822 #, python-format msgid "Cannot change %s." msgstr "" -#: awx/api/views.py:1811 +#: awx/api/views.py:1828 msgid "Cannot delete user." msgstr "" -#: awx/api/views.py:1835 +#: awx/api/views.py:1852 msgid "Deletion not allowed for managed credential types" msgstr "" -#: awx/api/views.py:1837 +#: awx/api/views.py:1854 msgid "Credential types that are in use cannot be deleted" msgstr "" -#: awx/api/views.py:2009 +#: awx/api/views.py:2029 msgid "Cannot delete inventory script." msgstr "" -#: awx/api/views.py:2099 +#: awx/api/views.py:2120 #, python-brace-format msgid "{0}" msgstr "" -#: awx/api/views.py:2326 +#: awx/api/views.py:2351 msgid "Fact not found." msgstr "" -#: awx/api/views.py:2348 +#: awx/api/views.py:2373 msgid "SSLError while trying to connect to {}" msgstr "" -#: awx/api/views.py:2350 +#: awx/api/views.py:2375 msgid "Request to {} timed out." msgstr "" -#: awx/api/views.py:2352 +#: awx/api/views.py:2377 msgid "Unknown exception {} while trying to GET {}" msgstr "" -#: awx/api/views.py:2355 +#: awx/api/views.py:2380 msgid "" "Unauthorized access. Please check your Insights Credential username and " "password." msgstr "" -#: awx/api/views.py:2358 +#: awx/api/views.py:2383 msgid "" "Failed to gather reports and maintenance plans from Insights API at URL {}. " "Server responded with {} status code and message {}" msgstr "" -#: awx/api/views.py:2365 +#: awx/api/views.py:2390 msgid "Expected JSON response from Insights but instead got {}" msgstr "" -#: awx/api/views.py:2372 +#: awx/api/views.py:2397 msgid "This host is not recognized as an Insights host." msgstr "" -#: awx/api/views.py:2377 +#: awx/api/views.py:2402 msgid "The Insights Credential for \"{}\" was not found." msgstr "" -#: awx/api/views.py:2445 +#: awx/api/views.py:2470 msgid "Cyclical Group association." msgstr "" -#: awx/api/views.py:2658 +#: awx/api/views.py:2684 msgid "Inventory Source List" msgstr "" -#: awx/api/views.py:2670 +#: awx/api/views.py:2696 msgid "Inventory Sources Update" msgstr "" -#: awx/api/views.py:2703 +#: awx/api/views.py:2729 msgid "Could not start because `can_update` returned False" msgstr "" -#: awx/api/views.py:2711 +#: awx/api/views.py:2737 msgid "No inventory sources to update." msgstr "" -#: awx/api/views.py:2740 +#: awx/api/views.py:2766 msgid "Inventory Source Schedules" msgstr "" -#: awx/api/views.py:2767 +#: awx/api/views.py:2794 msgid "Notification Templates can only be assigned when source is one of {}." msgstr "" -#: awx/api/views.py:2822 +#: awx/api/views.py:2849 msgid "Vault credentials are not yet supported for inventory sources." msgstr "" -#: awx/api/views.py:2827 +#: awx/api/views.py:2854 msgid "Source already has cloud credential assigned." msgstr "" -#: awx/api/views.py:2986 +#: awx/api/views.py:3014 msgid "" "'credentials' cannot be used in combination with 'credential', " "'vault_credential', or 'extra_credentials'." msgstr "" -#: awx/api/views.py:3098 +#: awx/api/views.py:3126 msgid "Job Template Schedules" msgstr "" -#: awx/api/views.py:3116 awx/api/views.py:3127 +#: awx/api/views.py:3144 awx/api/views.py:3155 msgid "Your license does not allow adding surveys." msgstr "" -#: awx/api/views.py:3146 +#: awx/api/views.py:3174 msgid "Field '{}' is missing from survey spec." msgstr "" -#: awx/api/views.py:3148 +#: awx/api/views.py:3176 msgid "Expected {} for field '{}', received {} type." msgstr "" -#: awx/api/views.py:3152 +#: awx/api/views.py:3180 msgid "'spec' doesn't contain any items." msgstr "" -#: awx/api/views.py:3161 +#: awx/api/views.py:3189 #, python-format msgid "Survey question %s is not a json object." msgstr "" -#: awx/api/views.py:3163 +#: awx/api/views.py:3191 #, python-format msgid "'type' missing from survey question %s." msgstr "" -#: awx/api/views.py:3165 +#: awx/api/views.py:3193 #, python-format msgid "'question_name' missing from survey question %s." msgstr "" -#: awx/api/views.py:3167 +#: awx/api/views.py:3195 #, python-format msgid "'variable' missing from survey question %s." msgstr "" -#: awx/api/views.py:3169 +#: awx/api/views.py:3197 #, python-format msgid "'variable' '%(item)s' duplicated in survey question %(survey)s." msgstr "" -#: awx/api/views.py:3174 +#: awx/api/views.py:3202 #, python-format msgid "'required' missing from survey question %s." msgstr "" -#: awx/api/views.py:3179 +#: awx/api/views.py:3207 #, python-brace-format msgid "Value {question_default} for '{variable_name}' expected to be a string." msgstr "" -#: awx/api/views.py:3189 +#: awx/api/views.py:3217 #, python-brace-format msgid "" "$encrypted$ is a reserved keyword for password question defaults, survey " "question {question_position} is type {question_type}." msgstr "" -#: awx/api/views.py:3205 +#: awx/api/views.py:3233 #, python-brace-format msgid "" "$encrypted$ is a reserved keyword, may not be used for new default in " "position {question_position}." msgstr "" -#: awx/api/views.py:3278 +#: awx/api/views.py:3307 #, python-brace-format msgid "Cannot assign multiple {credential_type} credentials." msgstr "" -#: awx/api/views.py:3296 +#: awx/api/views.py:3325 msgid "Extra credentials must be network or cloud." msgstr "" -#: awx/api/views.py:3318 +#: awx/api/views.py:3347 msgid "Maximum number of labels for {} reached." msgstr "" -#: awx/api/views.py:3441 +#: awx/api/views.py:3470 msgid "No matching host could be found!" msgstr "" -#: awx/api/views.py:3444 +#: awx/api/views.py:3473 msgid "Multiple hosts matched the request!" msgstr "" -#: awx/api/views.py:3449 +#: awx/api/views.py:3478 msgid "Cannot start automatically, user input required!" msgstr "" -#: awx/api/views.py:3456 +#: awx/api/views.py:3485 msgid "Host callback job already pending." msgstr "" -#: awx/api/views.py:3471 awx/api/views.py:4212 +#: awx/api/views.py:3500 awx/api/views.py:4259 msgid "Error starting job!" msgstr "" -#: awx/api/views.py:3587 +#: awx/api/views.py:3620 #, python-brace-format msgid "Cannot associate {0} when {1} have been associated." msgstr "" -#: awx/api/views.py:3612 +#: awx/api/views.py:3645 msgid "Multiple parent relationship not allowed." msgstr "" -#: awx/api/views.py:3617 +#: awx/api/views.py:3650 msgid "Cycle detected." msgstr "" -#: awx/api/views.py:3807 +#: awx/api/views.py:3848 msgid "Workflow Job Template Schedules" msgstr "" -#: awx/api/views.py:3938 awx/api/views.py:4610 +#: awx/api/views.py:3984 awx/api/views.py:4665 msgid "Superuser privileges needed." msgstr "" -#: awx/api/views.py:3970 +#: awx/api/views.py:4016 msgid "System Job Template Schedules" msgstr "" -#: awx/api/views.py:4028 +#: awx/api/views.py:4074 msgid "POST not allowed for Job launching in version 2 of the api" msgstr "" -#: awx/api/views.py:4195 +#: awx/api/views.py:4242 #, python-brace-format msgid "Wait until job finishes before retrying on {status_value} hosts." msgstr "" -#: awx/api/views.py:4200 +#: awx/api/views.py:4247 #, python-brace-format msgid "Cannot retry on {status_value} hosts, playbook stats not available." msgstr "" -#: awx/api/views.py:4205 +#: awx/api/views.py:4252 #, python-brace-format msgid "Cannot relaunch because previous job had 0 {status_value} hosts." msgstr "" -#: awx/api/views.py:4234 +#: awx/api/views.py:4281 msgid "Cannot create schedule because job requires credential passwords." msgstr "" -#: awx/api/views.py:4239 +#: awx/api/views.py:4286 msgid "Cannot create schedule because job was launched by legacy method." msgstr "" -#: awx/api/views.py:4241 +#: awx/api/views.py:4288 msgid "Cannot create schedule because a related resource is missing." msgstr "" -#: awx/api/views.py:4295 +#: awx/api/views.py:4343 msgid "Job Host Summaries List" msgstr "" -#: awx/api/views.py:4342 +#: awx/api/views.py:4392 msgid "Job Event Children List" msgstr "" -#: awx/api/views.py:4351 +#: awx/api/views.py:4402 msgid "Job Event Hosts List" msgstr "" -#: awx/api/views.py:4360 +#: awx/api/views.py:4411 msgid "Job Events List" msgstr "" -#: awx/api/views.py:4570 +#: awx/api/views.py:4622 msgid "Ad Hoc Command Events List" msgstr "" -#: awx/api/views.py:4809 +#: awx/api/views.py:4864 msgid "Delete not allowed while there are pending notifications" msgstr "" -#: awx/api/views.py:4817 +#: awx/api/views.py:4872 msgid "Notification Template Test" msgstr "" @@ -1364,7 +1368,7 @@ msgstr "" msgid "User" msgstr "" -#: awx/conf/fields.py:60 awx/sso/fields.py:583 +#: awx/conf/fields.py:60 awx/sso/fields.py:595 #, python-brace-format msgid "" "Expected None, True, False, a string or list of strings but got {input_type} " @@ -1566,27 +1570,31 @@ msgstr "" msgid "The admin_role for a User cannot be assigned to a team" msgstr "" -#: awx/main/access.py:1517 +#: awx/main/access.py:1504 +msgid "Job was launched with prompts provided by another user." +msgstr "" + +#: awx/main/access.py:1524 msgid "Job has been orphaned from its job template." msgstr "" -#: awx/main/access.py:1519 +#: awx/main/access.py:1526 msgid "Job was launched with unknown prompted fields." msgstr "" -#: awx/main/access.py:1521 +#: awx/main/access.py:1528 msgid "Job was launched with prompted fields." msgstr "" -#: awx/main/access.py:1523 +#: awx/main/access.py:1530 msgid " Organization level permissions required." msgstr "" -#: awx/main/access.py:1525 +#: awx/main/access.py:1532 msgid " You do not have permission to related resources." msgstr "" -#: awx/main/access.py:1935 +#: awx/main/access.py:1942 msgid "" "You do not have permission to the workflow job resources required for " "relaunch." @@ -2102,75 +2110,84 @@ msgstr "" msgid "'{value}' is not one of ['{allowed_values}']" msgstr "" -#: awx/main/fields.py:418 +#: awx/main/fields.py:421 #, python-brace-format msgid "{type} provided in relative path {path}, expected {expected_type}" msgstr "" -#: awx/main/fields.py:423 +#: awx/main/fields.py:426 #, python-brace-format msgid "{type} provided, expected {expected_type}" msgstr "" -#: awx/main/fields.py:428 +#: awx/main/fields.py:431 #, python-brace-format msgid "Schema validation error in relative path {path} ({error})" msgstr "" -#: awx/main/fields.py:549 +#: awx/main/fields.py:552 msgid "secret values must be of type string, not {}" msgstr "" -#: awx/main/fields.py:584 +#: awx/main/fields.py:587 #, python-format msgid "cannot be set unless \"%s\" is set" msgstr "" -#: awx/main/fields.py:600 +#: awx/main/fields.py:603 #, python-format msgid "required for %s" msgstr "" -#: awx/main/fields.py:624 +#: awx/main/fields.py:627 msgid "must be set when SSH key is encrypted." msgstr "" -#: awx/main/fields.py:630 +#: awx/main/fields.py:633 msgid "should not be set when SSH key is not encrypted." msgstr "" -#: awx/main/fields.py:688 +#: awx/main/fields.py:691 msgid "'dependencies' is not supported for custom credentials." msgstr "" -#: awx/main/fields.py:702 +#: awx/main/fields.py:705 msgid "\"tower\" is a reserved field name" msgstr "" -#: awx/main/fields.py:709 +#: awx/main/fields.py:712 #, python-format msgid "field IDs must be unique (%s)" msgstr "" -#: awx/main/fields.py:722 +#: awx/main/fields.py:725 msgid "become_method is a reserved type name" msgstr "" -#: awx/main/fields.py:733 +#: awx/main/fields.py:736 #, python-brace-format msgid "{sub_key} not allowed for {element_type} type ({element_id})" msgstr "" -#: awx/main/fields.py:813 +#: awx/main/fields.py:810 +msgid "" +"Must define unnamed file injector in order to reference `tower.filename`." +msgstr "" + +#: awx/main/fields.py:817 +msgid "Cannot directly reference reserved `tower` namespace container." +msgstr "" + +#: awx/main/fields.py:827 msgid "Must use multi-file syntax when injecting multiple files" msgstr "" -#: awx/main/fields.py:831 +#: awx/main/fields.py:844 #, python-brace-format msgid "{sub_key} uses an undefined field ({error_msg})" msgstr "" -#: awx/main/fields.py:838 +#: awx/main/fields.py:851 #, python-brace-format msgid "" "Syntax error rendering template for {sub_key} inside of {type} ({error_msg})" @@ -2297,6 +2314,17 @@ msgid "The hostname or IP address to use." msgstr "" #: awx/main/models/credential/__init__.py:117 +#: awx/main/models/credential/__init__.py:681 +#: awx/main/models/credential/__init__.py:736 +#: awx/main/models/credential/__init__.py:801 +#: awx/main/models/credential/__init__.py:879 +#: awx/main/models/credential/__init__.py:925 +#: awx/main/models/credential/__init__.py:953 +#: awx/main/models/credential/__init__.py:982 +#: awx/main/models/credential/__init__.py:1046 +#: awx/main/models/credential/__init__.py:1087 +#: awx/main/models/credential/__init__.py:1120 +#: awx/main/models/credential/__init__.py:1172 msgid "Username" msgstr "" @@ -2305,6 +2333,16 @@ msgid "Username for this credential." msgstr "" #: awx/main/models/credential/__init__.py:124 +#: awx/main/models/credential/__init__.py:685 +#: awx/main/models/credential/__init__.py:740 +#: awx/main/models/credential/__init__.py:805 +#: awx/main/models/credential/__init__.py:929 +#: awx/main/models/credential/__init__.py:957 +#: awx/main/models/credential/__init__.py:986 +#: awx/main/models/credential/__init__.py:1050 +#: awx/main/models/credential/__init__.py:1091 +#: awx/main/models/credential/__init__.py:1124 +#: awx/main/models/credential/__init__.py:1176 msgid "Password" msgstr "" @@ -2411,18 +2449,22 @@ msgid "" msgstr "" #: awx/main/models/credential/__init__.py:457 +#: awx/main/models/credential/__init__.py:676 msgid "Machine" msgstr "" #: awx/main/models/credential/__init__.py:458 +#: awx/main/models/credential/__init__.py:767 msgid "Vault" msgstr "" #: awx/main/models/credential/__init__.py:459 +#: awx/main/models/credential/__init__.py:796 msgid "Network" msgstr "" #: awx/main/models/credential/__init__.py:460 +#: awx/main/models/credential/__init__.py:731 msgid "Source Control" msgstr "" @@ -2431,6 +2473,7 @@ msgid "Cloud" msgstr "" #: awx/main/models/credential/__init__.py:462 +#: awx/main/models/credential/__init__.py:1082 msgid "Insights" msgstr "" @@ -2446,127 +2489,376 @@ msgstr "" msgid "adding %s credential type" msgstr "" -#: awx/main/models/events.py:71 awx/main/models/events.py:598 +#: awx/main/models/credential/__init__.py:691 +#: awx/main/models/credential/__init__.py:810 +msgid "SSH Private Key" +msgstr "" + +#: awx/main/models/credential/__init__.py:698 +#: awx/main/models/credential/__init__.py:752 +#: awx/main/models/credential/__init__.py:817 +msgid "Private Key Passphrase" +msgstr "" + +#: awx/main/models/credential/__init__.py:704 +msgid "Privilege Escalation Method" +msgstr "" + +#: awx/main/models/credential/__init__.py:706 +msgid "" +"Specify a method for \"become\" operations. This is equivalent to specifying " +"the --become-method Ansible parameter." +msgstr "" + +#: awx/main/models/credential/__init__.py:711 +msgid "Privilege Escalation Username" +msgstr "" + +#: awx/main/models/credential/__init__.py:715 +msgid "Privilege Escalation Password" +msgstr "" + +#: awx/main/models/credential/__init__.py:745 +msgid "SCM Private Key" +msgstr "" + +#: awx/main/models/credential/__init__.py:772 +msgid "Vault Password" +msgstr "" + +#: awx/main/models/credential/__init__.py:778 +msgid "Vault Identifier" +msgstr "" + +#: awx/main/models/credential/__init__.py:781 +msgid "" +"Specify an (optional) Vault ID. This is equivalent to specifying the --vault-" +"id Ansible parameter for providing multiple Vault passwords. Note: this " +"feature only works in Ansible 2.4+." +msgstr "" + +#: awx/main/models/credential/__init__.py:822 +msgid "Authorize" +msgstr "" + +#: awx/main/models/credential/__init__.py:826 +msgid "Authorize Password" +msgstr "" + +#: awx/main/models/credential/__init__.py:843 +msgid "Amazon Web Services" +msgstr "" + +#: awx/main/models/credential/__init__.py:848 +msgid "Access Key" +msgstr "" + +#: awx/main/models/credential/__init__.py:852 +msgid "Secret Key" +msgstr "" + +#: awx/main/models/credential/__init__.py:857 +msgid "STS Token" +msgstr "" + +#: awx/main/models/credential/__init__.py:860 +msgid "" +"Security Token Service (STS) is a web service that enables you to request " +"temporary, limited-privilege credentials for AWS Identity and Access " +"Management (IAM) users." +msgstr "" + +#: awx/main/models/credential/__init__.py:874 awx/main/models/inventory.py:990 +msgid "OpenStack" +msgstr "" + +#: awx/main/models/credential/__init__.py:883 +msgid "Password (API Key)" +msgstr "" + +#: awx/main/models/credential/__init__.py:888 +#: awx/main/models/credential/__init__.py:1115 +msgid "Host (Authentication URL)" +msgstr "" + +#: awx/main/models/credential/__init__.py:890 +msgid "" +"The host to authenticate with. For example, https://openstack.business.com/" +"v2.0/" +msgstr "" + +#: awx/main/models/credential/__init__.py:894 +msgid "Project (Tenant Name)" +msgstr "" + +#: awx/main/models/credential/__init__.py:898 +msgid "Domain Name" +msgstr "" + +#: awx/main/models/credential/__init__.py:900 +msgid "" +"OpenStack domains define administrative boundaries. It is only needed for " +"Keystone v3 authentication URLs. Refer to Ansible Tower documentation for " +"common scenarios." +msgstr "" + +#: awx/main/models/credential/__init__.py:914 awx/main/models/inventory.py:987 +msgid "VMware vCenter" +msgstr "" + +#: awx/main/models/credential/__init__.py:919 +msgid "VCenter Host" +msgstr "" + +#: awx/main/models/credential/__init__.py:921 +msgid "" +"Enter the hostname or IP address that corresponds to your VMware vCenter." +msgstr "" + +#: awx/main/models/credential/__init__.py:942 awx/main/models/inventory.py:988 +msgid "Red Hat Satellite 6" +msgstr "" + +#: awx/main/models/credential/__init__.py:947 +msgid "Satellite 6 URL" +msgstr "" + +#: awx/main/models/credential/__init__.py:949 +msgid "" +"Enter the URL that corresponds to your Red Hat Satellite 6 server. For " +"example, https://satellite.example.org" +msgstr "" + +#: awx/main/models/credential/__init__.py:970 awx/main/models/inventory.py:989 +msgid "Red Hat CloudForms" +msgstr "" + +#: awx/main/models/credential/__init__.py:975 +msgid "CloudForms URL" +msgstr "" + +#: awx/main/models/credential/__init__.py:977 +msgid "" +"Enter the URL for the virtual machine that corresponds to your CloudForm " +"instance. For example, https://cloudforms.example.org" +msgstr "" + +#: awx/main/models/credential/__init__.py:999 awx/main/models/inventory.py:985 +msgid "Google Compute Engine" +msgstr "" + +#: awx/main/models/credential/__init__.py:1004 +msgid "Service Account Email Address" +msgstr "" + +#: awx/main/models/credential/__init__.py:1006 +msgid "" +"The email address assigned to the Google Compute Engine service account." +msgstr "" + +#: awx/main/models/credential/__init__.py:1012 +msgid "" +"The Project ID is the GCE assigned identification. It is often constructed " +"as three words or two words followed by a three-digit number. Examples: " +"project-id-000 and another-project-id" +msgstr "" + +#: awx/main/models/credential/__init__.py:1018 +msgid "RSA Private Key" +msgstr "" + +#: awx/main/models/credential/__init__.py:1023 +msgid "" +"Paste the contents of the PEM file associated with the service account email." +msgstr "" + +#: awx/main/models/credential/__init__.py:1035 awx/main/models/inventory.py:986 +msgid "Microsoft Azure Resource Manager" +msgstr "" + +#: awx/main/models/credential/__init__.py:1040 +msgid "Subscription ID" +msgstr "" + +#: awx/main/models/credential/__init__.py:1042 +msgid "Subscription ID is an Azure construct, which is mapped to a username." +msgstr "" + +#: awx/main/models/credential/__init__.py:1055 +msgid "Client ID" +msgstr "" + +#: awx/main/models/credential/__init__.py:1059 +msgid "Client Secret" +msgstr "" + +#: awx/main/models/credential/__init__.py:1064 +msgid "Tenant ID" +msgstr "" + +#: awx/main/models/credential/__init__.py:1068 +msgid "Azure Cloud Environment" +msgstr "" + +#: awx/main/models/credential/__init__.py:1070 +msgid "" +"Environment variable AZURE_CLOUD_ENVIRONMENT when using Azure GovCloud or " +"Azure stack." +msgstr "" + +#: awx/main/models/credential/__init__.py:1110 awx/main/models/inventory.py:991 +msgid "Red Hat Virtualization" +msgstr "" + +#: awx/main/models/credential/__init__.py:1117 +msgid "The host to authenticate with." +msgstr "" + +#: awx/main/models/credential/__init__.py:1129 +msgid "CA File" +msgstr "" + +#: awx/main/models/credential/__init__.py:1131 +msgid "Absolute file path to the CA file to use (optional)" +msgstr "" + +#: awx/main/models/credential/__init__.py:1162 awx/main/models/inventory.py:992 +msgid "Ansible Tower" +msgstr "" + +#: awx/main/models/credential/__init__.py:1167 +msgid "Ansible Tower Hostname" +msgstr "" + +#: awx/main/models/credential/__init__.py:1169 +msgid "The Ansible Tower base URL to authenticate with." +msgstr "" + +#: awx/main/models/credential/__init__.py:1181 +msgid "Verify SSL" +msgstr "" + +#: awx/main/models/events.py:89 awx/main/models/events.py:608 msgid "Host Failed" msgstr "" -#: awx/main/models/events.py:72 awx/main/models/events.py:599 +#: awx/main/models/events.py:90 awx/main/models/events.py:609 msgid "Host OK" msgstr "" -#: awx/main/models/events.py:73 +#: awx/main/models/events.py:91 msgid "Host Failure" msgstr "" -#: awx/main/models/events.py:74 awx/main/models/events.py:605 +#: awx/main/models/events.py:92 awx/main/models/events.py:615 msgid "Host Skipped" msgstr "" -#: awx/main/models/events.py:75 awx/main/models/events.py:600 +#: awx/main/models/events.py:93 awx/main/models/events.py:610 msgid "Host Unreachable" msgstr "" -#: awx/main/models/events.py:76 awx/main/models/events.py:90 +#: awx/main/models/events.py:94 awx/main/models/events.py:108 msgid "No Hosts Remaining" msgstr "" -#: awx/main/models/events.py:77 +#: awx/main/models/events.py:95 msgid "Host Polling" msgstr "" -#: awx/main/models/events.py:78 +#: awx/main/models/events.py:96 msgid "Host Async OK" msgstr "" -#: awx/main/models/events.py:79 +#: awx/main/models/events.py:97 msgid "Host Async Failure" msgstr "" -#: awx/main/models/events.py:80 +#: awx/main/models/events.py:98 msgid "Item OK" msgstr "" -#: awx/main/models/events.py:81 +#: awx/main/models/events.py:99 msgid "Item Failed" msgstr "" -#: awx/main/models/events.py:82 +#: awx/main/models/events.py:100 msgid "Item Skipped" msgstr "" -#: awx/main/models/events.py:83 +#: awx/main/models/events.py:101 msgid "Host Retry" msgstr "" -#: awx/main/models/events.py:85 +#: awx/main/models/events.py:103 msgid "File Difference" msgstr "" -#: awx/main/models/events.py:86 +#: awx/main/models/events.py:104 msgid "Playbook Started" msgstr "" -#: awx/main/models/events.py:87 +#: awx/main/models/events.py:105 msgid "Running Handlers" msgstr "" -#: awx/main/models/events.py:88 +#: awx/main/models/events.py:106 msgid "Including File" msgstr "" -#: awx/main/models/events.py:89 +#: awx/main/models/events.py:107 msgid "No Hosts Matched" msgstr "" -#: awx/main/models/events.py:91 +#: awx/main/models/events.py:109 msgid "Task Started" msgstr "" -#: awx/main/models/events.py:93 +#: awx/main/models/events.py:111 msgid "Variables Prompted" msgstr "" -#: awx/main/models/events.py:94 +#: awx/main/models/events.py:112 msgid "Gathering Facts" msgstr "" -#: awx/main/models/events.py:95 +#: awx/main/models/events.py:113 msgid "internal: on Import for Host" msgstr "" -#: awx/main/models/events.py:96 +#: awx/main/models/events.py:114 msgid "internal: on Not Import for Host" msgstr "" -#: awx/main/models/events.py:97 +#: awx/main/models/events.py:115 msgid "Play Started" msgstr "" -#: awx/main/models/events.py:98 +#: awx/main/models/events.py:116 msgid "Playbook Complete" msgstr "" -#: awx/main/models/events.py:102 awx/main/models/events.py:615 +#: awx/main/models/events.py:120 awx/main/models/events.py:625 msgid "Debug" msgstr "" -#: awx/main/models/events.py:103 awx/main/models/events.py:616 +#: awx/main/models/events.py:121 awx/main/models/events.py:626 msgid "Verbose" msgstr "" -#: awx/main/models/events.py:104 awx/main/models/events.py:617 +#: awx/main/models/events.py:122 awx/main/models/events.py:627 msgid "Deprecated" msgstr "" -#: awx/main/models/events.py:105 awx/main/models/events.py:618 +#: awx/main/models/events.py:123 awx/main/models/events.py:628 msgid "Warning" msgstr "" -#: awx/main/models/events.py:106 awx/main/models/events.py:619 +#: awx/main/models/events.py:124 awx/main/models/events.py:629 msgid "System Warning" msgstr "" -#: awx/main/models/events.py:107 awx/main/models/events.py:620 +#: awx/main/models/events.py:125 awx/main/models/events.py:630 #: awx/main/models/unified_jobs.py:67 msgid "Error" msgstr "" @@ -2585,24 +2877,24 @@ msgid "" "host." msgstr "" -#: awx/main/models/ha.py:129 +#: awx/main/models/ha.py:145 msgid "Instances that are members of this InstanceGroup" msgstr "" -#: awx/main/models/ha.py:134 +#: awx/main/models/ha.py:150 msgid "Instance Group to remotely control this group." msgstr "" -#: awx/main/models/ha.py:141 +#: awx/main/models/ha.py:157 msgid "Percentage of Instances to automatically assign to this group" msgstr "" -#: awx/main/models/ha.py:145 +#: awx/main/models/ha.py:161 msgid "" "Static minimum number of Instances to automatically assign to this group" msgstr "" -#: awx/main/models/ha.py:150 +#: awx/main/models/ha.py:166 msgid "" "List of exact-match Instances that will always be automatically assigned to " "this group" @@ -2782,38 +3074,6 @@ msgstr "" msgid "Amazon EC2" msgstr "" -#: awx/main/models/inventory.py:985 -msgid "Google Compute Engine" -msgstr "" - -#: awx/main/models/inventory.py:986 -msgid "Microsoft Azure Resource Manager" -msgstr "" - -#: awx/main/models/inventory.py:987 -msgid "VMware vCenter" -msgstr "" - -#: awx/main/models/inventory.py:988 -msgid "Red Hat Satellite 6" -msgstr "" - -#: awx/main/models/inventory.py:989 -msgid "Red Hat CloudForms" -msgstr "" - -#: awx/main/models/inventory.py:990 -msgid "OpenStack" -msgstr "" - -#: awx/main/models/inventory.py:991 -msgid "Red Hat Virtualization" -msgstr "" - -#: awx/main/models/inventory.py:992 -msgid "Ansible Tower" -msgstr "" - #: awx/main/models/inventory.py:993 msgid "Custom Script" msgstr "" @@ -3006,7 +3266,7 @@ msgstr "" msgid "Job Template {} is missing or undefined." msgstr "" -#: awx/main/models/jobs.py:496 awx/main/models/projects.py:276 +#: awx/main/models/jobs.py:496 awx/main/models/projects.py:277 msgid "SCM Revision" msgstr "" @@ -3025,28 +3285,28 @@ msgstr "" msgid "{status_value} is not a valid status option." msgstr "" -#: awx/main/models/jobs.py:991 +#: awx/main/models/jobs.py:993 msgid "job host summaries" msgstr "" -#: awx/main/models/jobs.py:1062 +#: awx/main/models/jobs.py:1064 msgid "Remove jobs older than a certain number of days" msgstr "" -#: awx/main/models/jobs.py:1063 +#: awx/main/models/jobs.py:1065 msgid "Remove activity stream entries older than a certain number of days" msgstr "" -#: awx/main/models/jobs.py:1064 +#: awx/main/models/jobs.py:1066 msgid "Purge and/or reduce the granularity of system tracking data" msgstr "" -#: awx/main/models/jobs.py:1134 +#: awx/main/models/jobs.py:1136 #, python-brace-format msgid "Variables {list_of_keys} are not allowed for system jobs." msgstr "" -#: awx/main/models/jobs.py:1149 +#: awx/main/models/jobs.py:1151 msgid "days must be a positive integer." msgstr "" @@ -3061,7 +3321,11 @@ msgid "" "Launch setting on the Job Template to include Extra Variables." msgstr "" -#: awx/main/models/mixins.py:446 +#: awx/main/models/mixins.py:440 +msgid "Local absolute file path containing a custom Python virtualenv to use" +msgstr "" + +#: awx/main/models/mixins.py:447 msgid "{} is not a valid virtualenv in {}" msgstr "" @@ -3226,33 +3490,33 @@ msgstr "" msgid "Invalid credential." msgstr "" -#: awx/main/models/projects.py:262 +#: awx/main/models/projects.py:263 msgid "Update the project when a job is launched that uses the project." msgstr "" -#: awx/main/models/projects.py:267 +#: awx/main/models/projects.py:268 msgid "" "The number of seconds after the last project update ran that a newproject " "update will be launched as a job dependency." msgstr "" -#: awx/main/models/projects.py:277 +#: awx/main/models/projects.py:278 msgid "The last revision fetched by a project update" msgstr "" -#: awx/main/models/projects.py:284 +#: awx/main/models/projects.py:285 msgid "Playbook Files" msgstr "" -#: awx/main/models/projects.py:285 +#: awx/main/models/projects.py:286 msgid "List of playbooks found in the project" msgstr "" -#: awx/main/models/projects.py:292 +#: awx/main/models/projects.py:293 msgid "Inventory Files" msgstr "" -#: awx/main/models/projects.py:293 +#: awx/main/models/projects.py:294 msgid "" "Suggested list of content that could be Ansible inventory in the project" msgstr "" @@ -3402,25 +3666,25 @@ msgstr "" msgid "role_ancestors" msgstr "" -#: awx/main/models/schedules.py:72 +#: awx/main/models/schedules.py:79 msgid "Enables processing of this schedule." msgstr "" -#: awx/main/models/schedules.py:78 +#: awx/main/models/schedules.py:85 msgid "The first occurrence of the schedule occurs on or after this time." msgstr "" -#: awx/main/models/schedules.py:84 +#: awx/main/models/schedules.py:91 msgid "" "The last occurrence of the schedule occurs before this time, aftewards the " "schedule expires." msgstr "" -#: awx/main/models/schedules.py:88 +#: awx/main/models/schedules.py:95 msgid "A value representing the schedules iCal recurrence rule." msgstr "" -#: awx/main/models/schedules.py:94 +#: awx/main/models/schedules.py:101 msgid "The next time that the scheduled action will run." msgstr "" @@ -3592,19 +3856,19 @@ msgid "" "resource such as project or inventory" msgstr "" -#: awx/main/signals.py:617 +#: awx/main/signals.py:608 msgid "limit_reached" msgstr "" -#: awx/main/tasks.py:273 +#: awx/main/tasks.py:281 msgid "Ansible Tower host usage over 90%" msgstr "" -#: awx/main/tasks.py:278 +#: awx/main/tasks.py:286 msgid "Ansible Tower license will expire soon" msgstr "" -#: awx/main/tasks.py:1321 +#: awx/main/tasks.py:1329 msgid "Job could not start because it does not have a valid inventory." msgstr "" @@ -3613,53 +3877,53 @@ msgstr "" msgid "Unable to convert \"%s\" to boolean" msgstr "" -#: awx/main/utils/common.py:251 +#: awx/main/utils/common.py:254 #, python-format msgid "Unsupported SCM type \"%s\"" msgstr "" -#: awx/main/utils/common.py:258 awx/main/utils/common.py:270 -#: awx/main/utils/common.py:289 +#: awx/main/utils/common.py:261 awx/main/utils/common.py:273 +#: awx/main/utils/common.py:292 #, python-format msgid "Invalid %s URL" msgstr "" -#: awx/main/utils/common.py:260 awx/main/utils/common.py:299 +#: awx/main/utils/common.py:263 awx/main/utils/common.py:302 #, python-format msgid "Unsupported %s URL" msgstr "" -#: awx/main/utils/common.py:301 +#: awx/main/utils/common.py:304 #, python-format msgid "Unsupported host \"%s\" for file:// URL" msgstr "" -#: awx/main/utils/common.py:303 +#: awx/main/utils/common.py:306 #, python-format msgid "Host is required for %s URL" msgstr "" -#: awx/main/utils/common.py:321 +#: awx/main/utils/common.py:324 #, python-format msgid "Username must be \"git\" for SSH access to %s." msgstr "" -#: awx/main/utils/common.py:327 +#: awx/main/utils/common.py:330 #, python-format msgid "Username must be \"hg\" for SSH access to %s." msgstr "" -#: awx/main/utils/common.py:608 +#: awx/main/utils/common.py:611 #, python-brace-format msgid "Input type `{data_type}` is not a dictionary" msgstr "" -#: awx/main/utils/common.py:641 +#: awx/main/utils/common.py:644 #, python-brace-format msgid "Variables not compatible with JSON standard (error: {json_error})" msgstr "" -#: awx/main/utils/common.py:647 +#: awx/main/utils/common.py:650 #, python-brace-format msgid "" "Cannot parse as JSON (error: {json_error}) or YAML (error: {yaml_error})." @@ -3771,287 +4035,287 @@ msgstr "" msgid "A server error has occurred." msgstr "" -#: awx/settings/defaults.py:722 +#: awx/settings/defaults.py:721 msgid "US East (Northern Virginia)" msgstr "" -#: awx/settings/defaults.py:723 +#: awx/settings/defaults.py:722 msgid "US East (Ohio)" msgstr "" -#: awx/settings/defaults.py:724 +#: awx/settings/defaults.py:723 msgid "US West (Oregon)" msgstr "" -#: awx/settings/defaults.py:725 +#: awx/settings/defaults.py:724 msgid "US West (Northern California)" msgstr "" -#: awx/settings/defaults.py:726 +#: awx/settings/defaults.py:725 msgid "Canada (Central)" msgstr "" -#: awx/settings/defaults.py:727 +#: awx/settings/defaults.py:726 msgid "EU (Frankfurt)" msgstr "" -#: awx/settings/defaults.py:728 +#: awx/settings/defaults.py:727 msgid "EU (Ireland)" msgstr "" -#: awx/settings/defaults.py:729 +#: awx/settings/defaults.py:728 msgid "EU (London)" msgstr "" -#: awx/settings/defaults.py:730 +#: awx/settings/defaults.py:729 msgid "Asia Pacific (Singapore)" msgstr "" -#: awx/settings/defaults.py:731 +#: awx/settings/defaults.py:730 msgid "Asia Pacific (Sydney)" msgstr "" -#: awx/settings/defaults.py:732 +#: awx/settings/defaults.py:731 msgid "Asia Pacific (Tokyo)" msgstr "" -#: awx/settings/defaults.py:733 +#: awx/settings/defaults.py:732 msgid "Asia Pacific (Seoul)" msgstr "" -#: awx/settings/defaults.py:734 +#: awx/settings/defaults.py:733 msgid "Asia Pacific (Mumbai)" msgstr "" -#: awx/settings/defaults.py:735 +#: awx/settings/defaults.py:734 msgid "South America (Sao Paulo)" msgstr "" -#: awx/settings/defaults.py:736 +#: awx/settings/defaults.py:735 msgid "US West (GovCloud)" msgstr "" -#: awx/settings/defaults.py:737 +#: awx/settings/defaults.py:736 msgid "China (Beijing)" msgstr "" -#: awx/settings/defaults.py:786 +#: awx/settings/defaults.py:785 msgid "US East 1 (B)" msgstr "" -#: awx/settings/defaults.py:787 +#: awx/settings/defaults.py:786 msgid "US East 1 (C)" msgstr "" -#: awx/settings/defaults.py:788 +#: awx/settings/defaults.py:787 msgid "US East 1 (D)" msgstr "" -#: awx/settings/defaults.py:789 +#: awx/settings/defaults.py:788 msgid "US East 4 (A)" msgstr "" -#: awx/settings/defaults.py:790 +#: awx/settings/defaults.py:789 msgid "US East 4 (B)" msgstr "" -#: awx/settings/defaults.py:791 +#: awx/settings/defaults.py:790 msgid "US East 4 (C)" msgstr "" -#: awx/settings/defaults.py:792 +#: awx/settings/defaults.py:791 msgid "US Central (A)" msgstr "" -#: awx/settings/defaults.py:793 +#: awx/settings/defaults.py:792 msgid "US Central (B)" msgstr "" -#: awx/settings/defaults.py:794 +#: awx/settings/defaults.py:793 msgid "US Central (C)" msgstr "" -#: awx/settings/defaults.py:795 +#: awx/settings/defaults.py:794 msgid "US Central (F)" msgstr "" -#: awx/settings/defaults.py:796 +#: awx/settings/defaults.py:795 msgid "US West (A)" msgstr "" -#: awx/settings/defaults.py:797 +#: awx/settings/defaults.py:796 msgid "US West (B)" msgstr "" -#: awx/settings/defaults.py:798 +#: awx/settings/defaults.py:797 msgid "US West (C)" msgstr "" -#: awx/settings/defaults.py:799 +#: awx/settings/defaults.py:798 msgid "Europe West 1 (B)" msgstr "" -#: awx/settings/defaults.py:800 +#: awx/settings/defaults.py:799 msgid "Europe West 1 (C)" msgstr "" -#: awx/settings/defaults.py:801 +#: awx/settings/defaults.py:800 msgid "Europe West 1 (D)" msgstr "" -#: awx/settings/defaults.py:802 +#: awx/settings/defaults.py:801 msgid "Europe West 2 (A)" msgstr "" -#: awx/settings/defaults.py:803 +#: awx/settings/defaults.py:802 msgid "Europe West 2 (B)" msgstr "" -#: awx/settings/defaults.py:804 +#: awx/settings/defaults.py:803 msgid "Europe West 2 (C)" msgstr "" -#: awx/settings/defaults.py:805 +#: awx/settings/defaults.py:804 msgid "Asia East (A)" msgstr "" -#: awx/settings/defaults.py:806 +#: awx/settings/defaults.py:805 msgid "Asia East (B)" msgstr "" -#: awx/settings/defaults.py:807 +#: awx/settings/defaults.py:806 msgid "Asia East (C)" msgstr "" -#: awx/settings/defaults.py:808 +#: awx/settings/defaults.py:807 msgid "Asia Southeast (A)" msgstr "" -#: awx/settings/defaults.py:809 +#: awx/settings/defaults.py:808 msgid "Asia Southeast (B)" msgstr "" -#: awx/settings/defaults.py:810 +#: awx/settings/defaults.py:809 msgid "Asia Northeast (A)" msgstr "" -#: awx/settings/defaults.py:811 +#: awx/settings/defaults.py:810 msgid "Asia Northeast (B)" msgstr "" -#: awx/settings/defaults.py:812 +#: awx/settings/defaults.py:811 msgid "Asia Northeast (C)" msgstr "" -#: awx/settings/defaults.py:813 +#: awx/settings/defaults.py:812 msgid "Australia Southeast (A)" msgstr "" -#: awx/settings/defaults.py:814 +#: awx/settings/defaults.py:813 msgid "Australia Southeast (B)" msgstr "" -#: awx/settings/defaults.py:815 +#: awx/settings/defaults.py:814 msgid "Australia Southeast (C)" msgstr "" -#: awx/settings/defaults.py:837 +#: awx/settings/defaults.py:836 msgid "US East" msgstr "" -#: awx/settings/defaults.py:838 +#: awx/settings/defaults.py:837 msgid "US East 2" msgstr "" -#: awx/settings/defaults.py:839 +#: awx/settings/defaults.py:838 msgid "US Central" msgstr "" -#: awx/settings/defaults.py:840 +#: awx/settings/defaults.py:839 msgid "US North Central" msgstr "" -#: awx/settings/defaults.py:841 +#: awx/settings/defaults.py:840 msgid "US South Central" msgstr "" -#: awx/settings/defaults.py:842 +#: awx/settings/defaults.py:841 msgid "US West Central" msgstr "" -#: awx/settings/defaults.py:843 +#: awx/settings/defaults.py:842 msgid "US West" msgstr "" -#: awx/settings/defaults.py:844 +#: awx/settings/defaults.py:843 msgid "US West 2" msgstr "" -#: awx/settings/defaults.py:845 +#: awx/settings/defaults.py:844 msgid "Canada East" msgstr "" -#: awx/settings/defaults.py:846 +#: awx/settings/defaults.py:845 msgid "Canada Central" msgstr "" -#: awx/settings/defaults.py:847 +#: awx/settings/defaults.py:846 msgid "Brazil South" msgstr "" -#: awx/settings/defaults.py:848 +#: awx/settings/defaults.py:847 msgid "Europe North" msgstr "" -#: awx/settings/defaults.py:849 +#: awx/settings/defaults.py:848 msgid "Europe West" msgstr "" -#: awx/settings/defaults.py:850 +#: awx/settings/defaults.py:849 msgid "UK West" msgstr "" -#: awx/settings/defaults.py:851 +#: awx/settings/defaults.py:850 msgid "UK South" msgstr "" -#: awx/settings/defaults.py:852 +#: awx/settings/defaults.py:851 msgid "Asia East" msgstr "" -#: awx/settings/defaults.py:853 +#: awx/settings/defaults.py:852 msgid "Asia Southeast" msgstr "" -#: awx/settings/defaults.py:854 +#: awx/settings/defaults.py:853 msgid "Australia East" msgstr "" -#: awx/settings/defaults.py:855 +#: awx/settings/defaults.py:854 msgid "Australia Southeast" msgstr "" -#: awx/settings/defaults.py:856 +#: awx/settings/defaults.py:855 msgid "India West" msgstr "" -#: awx/settings/defaults.py:857 +#: awx/settings/defaults.py:856 msgid "India South" msgstr "" -#: awx/settings/defaults.py:858 +#: awx/settings/defaults.py:857 msgid "Japan East" msgstr "" -#: awx/settings/defaults.py:859 +#: awx/settings/defaults.py:858 msgid "Japan West" msgstr "" -#: awx/settings/defaults.py:860 +#: awx/settings/defaults.py:859 msgid "Korea Central" msgstr "" -#: awx/settings/defaults.py:861 +#: awx/settings/defaults.py:860 msgid "Korea South" msgstr "" @@ -4748,96 +5012,96 @@ msgstr "" msgid "Invalid connection option(s): {invalid_options}." msgstr "" -#: awx/sso/fields.py:254 +#: awx/sso/fields.py:266 msgid "Base" msgstr "" -#: awx/sso/fields.py:255 +#: awx/sso/fields.py:267 msgid "One Level" msgstr "" -#: awx/sso/fields.py:256 +#: awx/sso/fields.py:268 msgid "Subtree" msgstr "" -#: awx/sso/fields.py:274 +#: awx/sso/fields.py:286 #, python-brace-format msgid "Expected a list of three items but got {length} instead." msgstr "" -#: awx/sso/fields.py:275 +#: awx/sso/fields.py:287 #, python-brace-format msgid "Expected an instance of LDAPSearch but got {input_type} instead." msgstr "" -#: awx/sso/fields.py:311 +#: awx/sso/fields.py:323 #, python-brace-format msgid "" "Expected an instance of LDAPSearch or LDAPSearchUnion but got {input_type} " "instead." msgstr "" -#: awx/sso/fields.py:349 +#: awx/sso/fields.py:361 #, python-brace-format msgid "Invalid user attribute(s): {invalid_attrs}." msgstr "" -#: awx/sso/fields.py:366 +#: awx/sso/fields.py:378 #, python-brace-format msgid "Expected an instance of LDAPGroupType but got {input_type} instead." msgstr "" -#: awx/sso/fields.py:406 awx/sso/fields.py:453 +#: awx/sso/fields.py:418 awx/sso/fields.py:465 #, python-brace-format msgid "Invalid key(s): {invalid_keys}." msgstr "" -#: awx/sso/fields.py:431 +#: awx/sso/fields.py:443 #, python-brace-format msgid "Invalid user flag: \"{invalid_flag}\"." msgstr "" -#: awx/sso/fields.py:452 +#: awx/sso/fields.py:464 #, python-brace-format msgid "Missing key(s): {missing_keys}." msgstr "" -#: awx/sso/fields.py:502 awx/sso/fields.py:619 +#: awx/sso/fields.py:514 awx/sso/fields.py:631 #, python-brace-format msgid "Invalid key(s) for organization map: {invalid_keys}." msgstr "" -#: awx/sso/fields.py:520 +#: awx/sso/fields.py:532 #, python-brace-format msgid "Missing required key for team map: {invalid_keys}." msgstr "" -#: awx/sso/fields.py:521 awx/sso/fields.py:638 +#: awx/sso/fields.py:533 awx/sso/fields.py:650 #, python-brace-format msgid "Invalid key(s) for team map: {invalid_keys}." msgstr "" -#: awx/sso/fields.py:637 +#: awx/sso/fields.py:649 #, python-brace-format msgid "Missing required key for team map: {missing_keys}." msgstr "" -#: awx/sso/fields.py:655 +#: awx/sso/fields.py:667 #, python-brace-format msgid "Missing required key(s) for org info record: {missing_keys}." msgstr "" -#: awx/sso/fields.py:668 +#: awx/sso/fields.py:680 #, python-brace-format msgid "Invalid language code(s) for org info: {invalid_lang_codes}." msgstr "" -#: awx/sso/fields.py:687 +#: awx/sso/fields.py:699 #, python-brace-format msgid "Missing required key(s) for contact: {missing_keys}." msgstr "" -#: awx/sso/fields.py:699 +#: awx/sso/fields.py:711 #, python-brace-format msgid "Missing required key(s) for IdP: {missing_keys}." msgstr "" diff --git a/awx/ui/po/ansible-tower-ui.pot b/awx/ui/po/ansible-tower-ui.pot index ee20fdbbf5..2274de5f9d 100644 --- a/awx/ui/po/ansible-tower-ui.pot +++ b/awx/ui/po/ansible-tower-ui.pot @@ -5,12 +5,12 @@ msgstr "" "Project-Id-Version: \n" #: client/src/projects/add/projects-add.controller.js:161 -#: client/src/projects/edit/projects-edit.controller.js:295 +#: client/src/projects/edit/projects-edit.controller.js:296 msgid "%sNote:%s Mercurial does not support password authentication for SSH. Do not put the username and key in the URL. If using Bitbucket and SSH, do not supply your Bitbucket username." msgstr "" #: client/src/projects/add/projects-add.controller.js:140 -#: client/src/projects/edit/projects-edit.controller.js:274 +#: client/src/projects/edit/projects-edit.controller.js:275 msgid "%sNote:%s When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, do not enter a username (other than git). Additionally, GitHub and Bitbucket do not support password authentication when using SSH. GIT read only protocol (git://) does not use username or password information." msgstr "" @@ -114,6 +114,10 @@ msgstr "" msgid "ADDITIONAL INFORMATION" msgstr "" +#: client/features/output/output.strings.js:75 +msgid "ADDITIONAL_INFORMATION" +msgstr "" + #: client/src/organizations/linkout/organizations-linkout.route.js:258 #: client/src/organizations/list/organizations-list.controller.js:85 msgid "ADMINS" @@ -124,7 +128,7 @@ msgid "ALL ACTIVITY" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:239 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:253 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:254 msgid "ANY" msgstr "" @@ -140,6 +144,10 @@ msgstr "" msgid "API Token" msgstr "" +#: client/features/users/tokens/tokens.strings.js:39 +msgid "APPLICATION" +msgstr "" + #: client/features/applications/applications.strings.js:8 #: client/src/activity-stream/get-target-title.factory.js:47 msgid "APPLICATIONS" @@ -198,7 +206,7 @@ msgstr "" #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:115 #: client/src/organizations/organizations.form.js:93 #: client/src/teams/teams.form.js:85 -#: client/src/templates/workflows.form.js:148 +#: client/src/templates/workflows.form.js:147 msgid "Add" msgstr "" @@ -210,7 +218,7 @@ msgstr "" msgid "Add Inventories" msgstr "" -#: client/src/shared/stateDefinitions.factory.js:288 +#: client/src/shared/stateDefinitions.factory.js:304 msgid "Add Permissions" msgstr "" @@ -219,8 +227,8 @@ msgid "Add Project" msgstr "" #: client/src/shared/form-generator.js:1720 -#: client/src/templates/job_templates/job-template.form.js:458 -#: client/src/templates/workflows.form.js:196 +#: client/src/templates/job_templates/job-template.form.js:463 +#: client/src/templates/workflows.form.js:205 msgid "Add Survey" msgstr "" @@ -232,8 +240,8 @@ msgstr "" msgid "Add User" msgstr "" -#: client/src/shared/stateDefinitions.factory.js:410 -#: client/src/shared/stateDefinitions.factory.js:578 +#: client/src/shared/stateDefinitions.factory.js:426 +#: client/src/shared/stateDefinitions.factory.js:594 #: client/src/users/users.list.js:17 msgid "Add Users" msgstr "" @@ -260,7 +268,7 @@ msgstr "" #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:117 #: client/src/projects/projects.form.js:255 #: client/src/templates/job_templates/job-template.form.js:406 -#: client/src/templates/workflows.form.js:149 +#: client/src/templates/workflows.form.js:148 msgid "Add a permission" msgstr "" @@ -293,7 +301,7 @@ msgstr "" msgid "All Activity" msgstr "" -#: client/src/portal-mode/portal-mode-layout.partial.html:22 +#: client/src/portal-mode/portal-mode-layout.partial.html:31 msgid "All Jobs" msgstr "" @@ -302,6 +310,10 @@ msgstr "" msgid "Allow Provisioning Callbacks" msgstr "" +#: client/src/workflow-results/workflow-results.controller.js:68 +msgid "Always" +msgstr "" + #: client/src/projects/list/projects-list.controller.js:301 msgid "An SCM update does not appear to be running for project: %s. Click the %sRefresh%s button to view the latest status." msgstr "" @@ -372,7 +384,7 @@ msgstr "" msgid "Are you sure you want to permanently delete the inventory source below from the inventory?" msgstr "" -#: client/src/projects/edit/projects-edit.controller.js:251 +#: client/src/projects/edit/projects-edit.controller.js:252 msgid "Are you sure you want to remove the %s below from %s?" msgstr "" @@ -428,11 +440,11 @@ msgid "Authorize Password" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:226 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:239 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:240 msgid "Availability Zone:" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:125 +#: client/src/configuration/auth-form/configuration-auth.controller.js:128 msgid "Azure AD" msgstr "" @@ -440,6 +452,10 @@ msgstr "" msgid "BROWSE" msgstr "" +#: client/features/output/output.strings.js:93 +msgid "Back to Top" +msgstr "" + #: client/src/projects/projects.form.js:81 msgid "Base path used for locating playbooks. Directories found inside this path will be listed in the playbook directory drop-down. Together the base path and selected playbook directory provide the full path used to locate playbooks." msgstr "" @@ -486,6 +502,7 @@ msgstr "" msgid "CHOOSE A FILE" msgstr "" +#: client/features/output/output.strings.js:77 #: client/src/shared/smart-search/smart-search.partial.html:30 msgid "CLEAR ALL" msgstr "" @@ -495,6 +512,7 @@ msgid "CLOSE" msgstr "" #: client/features/jobs/routes/templateCompletedJobs.route.js:21 +#: client/features/jobs/routes/workflowJobTemplateCompletedJobs.route.js:21 msgid "COMPLETED JOBS" msgstr "" @@ -514,7 +532,7 @@ msgstr "" msgid "COULD NOT CREATE TOKEN" msgstr "" -#: client/src/shared/stateDefinitions.factory.js:157 +#: client/src/shared/stateDefinitions.factory.js:161 msgid "CREATE %s" msgstr "" @@ -550,14 +568,14 @@ msgstr "" msgid "CREATE INVENTORY SOURCE" msgstr "" -#: client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule-add.route.js:8 -#: client/src/scheduler/main.js:109 -#: client/src/scheduler/main.js:200 -#: client/src/scheduler/main.js:284 +#: client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule-add.route.js:9 +#: client/src/scheduler/schedules.route.js:163 +#: client/src/scheduler/schedules.route.js:245 +#: client/src/scheduler/schedules.route.js:74 msgid "CREATE SCHEDULE" msgstr "" -#: client/src/management-jobs/scheduler/main.js:81 +#: client/src/management-jobs/scheduler/main.js:83 msgid "CREATE SCHEDULED JOB" msgstr "" @@ -567,7 +585,7 @@ msgstr "" #: client/features/users/tokens/tokens.strings.js:18 #: client/features/users/tokens/tokens.strings.js:9 -#: client/features/users/tokens/users-tokens-add.route.js:25 +#: client/features/users/tokens/users-tokens-add.route.js:30 msgid "CREATE TOKEN" msgstr "" @@ -623,7 +641,7 @@ msgstr "" msgid "Call to %s failed. GET status:" msgstr "" -#: client/src/projects/edit/projects-edit.controller.js:245 +#: client/src/projects/edit/projects-edit.controller.js:246 msgid "Call to %s failed. POST returned status:" msgstr "" @@ -643,13 +661,13 @@ msgstr "" msgid "Call to {{ path }} failed. {{ action }} returned status: {{ status }}." msgstr "" -#: client/features/output/details.partial.html:17 +#: client/features/output/output.strings.js:17 #: client/lib/services/base-string.service.js:82 #: client/src/access/add-rbac-resource/rbac-resource.partial.html:105 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:188 -#: client/src/configuration/configuration.controller.js:594 +#: client/src/configuration/configuration.controller.js:611 #: client/src/shared/form-generator.js:1708 -#: client/src/workflow-results/workflow-results.partial.html:42 +#: client/src/workflow-results/workflow-results.controller.js:38 msgid "Cancel" msgstr "" @@ -790,8 +808,8 @@ msgstr "" msgid "CloudForms URL" msgstr "" -#: client/features/output/jobs.strings.js:29 -#: client/src/workflow-results/workflow-results.controller.js:118 +#: client/features/output/output.strings.js:18 +#: client/src/workflow-results/workflow-results.controller.js:156 msgid "Collapse Output" msgstr "" @@ -802,6 +820,7 @@ msgstr "" #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:155 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:172 #: client/src/templates/job_templates/job-template.form.js:438 +#: client/src/templates/workflows.form.js:180 msgid "Completed Jobs" msgstr "" @@ -813,11 +832,11 @@ msgstr "" msgid "Confirm Password" msgstr "" -#: client/src/configuration/configuration.controller.js:601 +#: client/src/configuration/configuration.controller.js:618 msgid "Confirm Reset" msgstr "" -#: client/src/configuration/configuration.controller.js:610 +#: client/src/configuration/configuration.controller.js:627 msgid "Confirm factory reset" msgstr "" @@ -961,6 +980,7 @@ msgstr "" msgid "Create a new user" msgstr "" +#: client/features/output/output.strings.js:43 #: client/features/templates/templates.strings.js:25 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:73 #: client/src/inventories-hosts/inventories/related/sources/sources.form.js:74 @@ -975,7 +995,7 @@ msgstr "" #: client/lib/components/components.strings.js:65 #: client/lib/models/models.strings.js:12 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:33 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:34 msgid "Credential Types" msgstr "" @@ -985,7 +1005,7 @@ msgstr "" #: client/lib/models/models.strings.js:8 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:129 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:58 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:25 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:26 #: client/src/templates/job_templates/job-template.form.js:133 msgid "Credentials" msgstr "" @@ -1022,7 +1042,7 @@ msgstr "" #: client/src/notifications/notification-templates-list/list.controller.js:221 #: client/src/organizations/list/organizations-list.controller.js:196 #: client/src/partials/survey-maker-modal.html:18 -#: client/src/projects/edit/projects-edit.controller.js:253 +#: client/src/projects/edit/projects-edit.controller.js:254 #: client/src/projects/list/projects-list.controller.js:244 #: client/src/users/list/users-list.controller.js:95 msgid "DELETE" @@ -1036,8 +1056,8 @@ msgstr "" msgid "DESCRIPTION" msgstr "" -#: client/features/output/details.partial.html:3 #: client/src/instance-groups/instance-groups.strings.js:17 +#: client/src/workflow-results/workflow-results.controller.js:57 msgid "DETAILS" msgstr "" @@ -1060,11 +1080,17 @@ msgstr "" msgid "Dashboard" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:137 +#: client/src/configuration/auth-form/configuration-auth.controller.js:140 msgid "Default" msgstr "" -#: client/features/output/details.partial.html:34 +#: client/src/organizations/organizations.form.js:48 +#: client/src/projects/projects.form.js:209 +#: client/src/templates/job_templates/job-template.form.js:237 +msgid "Default Environment" +msgstr "" + +#: client/features/output/output.strings.js:19 #: client/lib/services/base-string.service.js:75 #: client/src/credential-types/credential-types.list.js:73 #: client/src/credential-types/list/list.controller.js:106 @@ -1077,14 +1103,14 @@ msgstr "" #: client/src/notifications/notification-templates-list/list.controller.js:217 #: client/src/notifications/notificationTemplates.list.js:100 #: client/src/organizations/list/organizations-list.controller.js:192 -#: client/src/projects/edit/projects-edit.controller.js:250 +#: client/src/projects/edit/projects-edit.controller.js:251 #: client/src/projects/list/projects-list.controller.js:240 #: client/src/scheduler/schedules.list.js:98 #: client/src/teams/teams.list.js:72 #: client/src/templates/templates.list.js:116 #: client/src/users/list/users-list.controller.js:91 #: client/src/users/users.list.js:79 -#: client/src/workflow-results/workflow-results.partial.html:54 +#: client/src/workflow-results/workflow-results.controller.js:39 msgid "Delete" msgstr "" @@ -1201,7 +1227,7 @@ msgid "Depending on the size of the repository this may significantly increase t msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:246 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:260 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:261 msgid "Describe Instances documentation" msgstr "" @@ -1248,6 +1274,7 @@ msgstr "" #: client/features/applications/applications.strings.js:15 #: client/features/credentials/credentials.strings.js:13 +#: client/features/output/output.strings.js:34 #: client/features/users/tokens/tokens.strings.js:14 #: client/src/license/license.partial.html:5 #: client/src/shared/form-generator.js:1490 @@ -1288,7 +1315,7 @@ msgstr "" #: client/src/configuration/auth-form/configuration-auth.controller.js:93 #: client/src/configuration/configuration.controller.js:231 -#: client/src/configuration/configuration.controller.js:311 +#: client/src/configuration/configuration.controller.js:316 #: client/src/configuration/system-form/configuration-system.controller.js:57 msgid "Discard changes" msgstr "" @@ -1307,6 +1334,10 @@ msgstr "" msgid "Domain Name" msgstr "" +#: client/features/output/output.strings.js:20 +msgid "Download Output" +msgstr "" + #: client/src/inventory-scripts/inventory-scripts.form.js:59 msgid "Drag and drop your custom inventory script file here or create one in the field to import your custom inventory. Refer to the Ansible Tower documentation for example syntax." msgstr "" @@ -1331,7 +1362,7 @@ msgstr "" msgid "EDIT INSTANCE GROUP" msgstr "" -#: client/src/management-jobs/scheduler/main.js:95 +#: client/src/management-jobs/scheduler/main.js:97 msgid "EDIT SCHEDULED JOB" msgstr "" @@ -1343,6 +1374,10 @@ msgstr "" msgid "ENCRYPTED" msgstr "" +#: client/features/output/output.strings.js:79 +msgid "EXAMPLES" +msgstr "" + #: client/src/shared/smart-search/smart-search.partial.html:39 msgid "EXAMPLES:" msgstr "" @@ -1391,8 +1426,8 @@ msgid "Edit" msgstr "" #: client/src/shared/form-generator.js:1724 -#: client/src/templates/job_templates/job-template.form.js:465 -#: client/src/templates/workflows.form.js:203 +#: client/src/templates/job_templates/job-template.form.js:470 +#: client/src/templates/workflows.form.js:212 msgid "Edit Survey" msgstr "" @@ -1444,18 +1479,23 @@ msgstr "" msgid "Edit template" msgstr "" -#: client/src/workflow-results/workflow-results.partial.html:124 -msgid "Edit the User" -msgstr "" - #: client/src/projects/projects.list.js:88 msgid "Edit the project" msgstr "" #: client/src/scheduler/scheduled-jobs.list.js:67 +#: client/src/workflow-results/workflow-results.controller.js:42 msgid "Edit the schedule" msgstr "" +#: client/src/workflow-results/workflow-results.controller.js:40 +msgid "Edit the user" +msgstr "" + +#: client/src/workflow-results/workflow-results.controller.js:41 +msgid "Edit the workflow job template" +msgstr "" + #: client/src/users/users.list.js:64 msgid "Edit user" msgstr "" @@ -1464,6 +1504,11 @@ msgstr "" msgid "Either you do not have access or the SCM update process completed. Click the %sRefresh%s button to view the latest status." msgstr "" +#: client/features/output/output.strings.js:89 +#: client/src/workflow-results/workflow-results.controller.js:63 +msgid "Elapsed" +msgstr "" + #: client/src/credentials/credentials.form.js:191 #: client/src/users/users.form.js:54 msgid "Email" @@ -1471,8 +1516,8 @@ msgstr "" #: client/src/templates/job_templates/job-template.form.js:298 #: client/src/templates/job_templates/job-template.form.js:303 -#: client/src/templates/workflows.form.js:101 -#: client/src/templates/workflows.form.js:106 +#: client/src/templates/workflows.form.js:100 +#: client/src/templates/workflows.form.js:105 msgid "Enable Concurrent Jobs" msgstr "" @@ -1559,11 +1604,15 @@ msgstr "" msgid "Error" msgstr "" +#: client/features/output/output.strings.js:64 +msgid "Error Details" +msgstr "" + #: client/lib/services/base-string.service.js:89 -#: client/src/configuration/configuration.controller.js:402 -#: client/src/configuration/configuration.controller.js:502 -#: client/src/configuration/configuration.controller.js:536 -#: client/src/configuration/configuration.controller.js:583 +#: client/src/configuration/configuration.controller.js:408 +#: client/src/configuration/configuration.controller.js:517 +#: client/src/configuration/configuration.controller.js:552 +#: client/src/configuration/configuration.controller.js:600 #: client/src/configuration/system-form/configuration-system.controller.js:231 #: client/src/credentials/factories/credential-form-save.factory.js:77 #: client/src/credentials/factories/credential-form-save.factory.js:93 @@ -1575,15 +1624,16 @@ msgstr "" #: client/src/management-jobs/card/card.controller.js:102 #: client/src/management-jobs/card/card.controller.js:28 #: client/src/projects/add/projects-add.controller.js:116 -#: client/src/projects/edit/projects-edit.controller.js:163 -#: client/src/projects/edit/projects-edit.controller.js:229 -#: client/src/projects/edit/projects-edit.controller.js:245 +#: client/src/projects/edit/projects-edit.controller.js:164 +#: client/src/projects/edit/projects-edit.controller.js:230 +#: client/src/projects/edit/projects-edit.controller.js:246 #: client/src/projects/list/projects-list.controller.js:186 #: client/src/projects/list/projects-list.controller.js:213 #: client/src/projects/list/projects-list.controller.js:260 #: client/src/projects/list/projects-list.controller.js:281 #: client/src/projects/list/projects-list.controller.js:297 #: client/src/projects/list/projects-list.controller.js:306 +#: client/src/shared/stateDefinitions.factory.js:230 #: client/src/users/add/users-add.controller.js:99 #: client/src/users/edit/users-edit.controller.js:185 #: client/src/users/edit/users-edit.controller.js:82 @@ -1600,17 +1650,17 @@ msgid "Event summary not available" msgstr "" #: client/src/projects/add/projects-add.controller.js:137 -#: client/src/projects/edit/projects-edit.controller.js:272 +#: client/src/projects/edit/projects-edit.controller.js:273 msgid "Example URLs for GIT SCM include:" msgstr "" #: client/src/projects/add/projects-add.controller.js:158 -#: client/src/projects/edit/projects-edit.controller.js:292 +#: client/src/projects/edit/projects-edit.controller.js:293 msgid "Example URLs for Mercurial SCM include:" msgstr "" #: client/src/projects/add/projects-add.controller.js:149 -#: client/src/projects/edit/projects-edit.controller.js:283 +#: client/src/projects/edit/projects-edit.controller.js:284 msgid "Example URLs for Subversion SCM include:" msgstr "" @@ -1626,9 +1676,9 @@ msgstr "" msgid "Existing Host" msgstr "" -#: client/features/output/jobs.strings.js:28 -#: client/src/workflow-results/workflow-results.controller.js:120 -#: client/src/workflow-results/workflow-results.controller.js:76 +#: client/features/output/output.strings.js:22 +#: client/src/workflow-results/workflow-results.controller.js:158 +#: client/src/workflow-results/workflow-results.controller.js:43 msgid "Expand Output" msgstr "" @@ -1640,6 +1690,11 @@ msgstr "" msgid "Expires On" msgstr "" +#: client/features/output/output.strings.js:49 +msgid "Explanation" +msgstr "" + +#: client/features/output/output.strings.js:44 #: client/features/templates/templates.strings.js:53 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:133 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:145 @@ -1647,8 +1702,9 @@ msgstr "" #: client/src/partials/logviewer.html:8 #: client/src/templates/job_templates/job-template.form.js:352 #: client/src/templates/job_templates/job-template.form.js:359 -#: client/src/templates/workflows.form.js:84 -#: client/src/templates/workflows.form.js:91 +#: client/src/templates/workflows.form.js:83 +#: client/src/templates/workflows.form.js:90 +#: client/src/workflow-results/workflow-results.controller.js:126 msgid "Extra Variables" msgstr "" @@ -1661,6 +1717,10 @@ msgstr "" msgid "FAILED" msgstr "" +#: client/features/output/output.strings.js:80 +msgid "FIELDS" +msgstr "" + #: client/src/shared/smart-search/smart-search.partial.html:45 msgid "FIELDS:" msgstr "" @@ -1696,7 +1756,7 @@ msgstr "" msgid "Failed to retrieve job template extra variables." msgstr "" -#: client/src/projects/edit/projects-edit.controller.js:164 +#: client/src/projects/edit/projects-edit.controller.js:165 msgid "Failed to retrieve project: %s. GET status:" msgstr "" @@ -1705,11 +1765,11 @@ msgstr "" msgid "Failed to retrieve user: %s. GET status:" msgstr "" -#: client/src/configuration/configuration.controller.js:503 +#: client/src/configuration/configuration.controller.js:518 msgid "Failed to save settings. Returned status:" msgstr "" -#: client/src/configuration/configuration.controller.js:537 +#: client/src/configuration/configuration.controller.js:553 msgid "Failed to save toggle settings. Returned status:" msgstr "" @@ -1717,10 +1777,14 @@ msgstr "" msgid "Failed to update Credential. PUT status:" msgstr "" -#: client/src/projects/edit/projects-edit.controller.js:229 +#: client/src/projects/edit/projects-edit.controller.js:230 msgid "Failed to update project: %s. PUT status:" msgstr "" +#: client/features/output/output.strings.js:84 +msgid "Failed to update search results." +msgstr "" + #: client/src/job-submission/job-submission-factories/launchjob.factory.js:199 #: client/src/management-jobs/card/card.controller.js:103 msgid "Failed updating job %s with variables. POST returned: %d" @@ -1735,8 +1799,10 @@ msgid "Final Run" msgstr "" #: client/features/jobs/jobs.strings.js:9 +#: client/features/output/output.strings.js:45 #: client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.directive.js:54 #: client/src/inventories-hosts/shared/factories/set-status.factory.js:44 +#: client/src/workflow-results/workflow-results.controller.js:50 msgid "Finished" msgstr "" @@ -1750,8 +1816,9 @@ msgstr "" msgid "First Run" msgstr "" +#: client/features/output/output.strings.js:76 #: client/src/shared/smart-search/smart-search.partial.html:52 -msgid "For additional information on advanced search search syntax please see the Ansible Tower" +msgid "For additional information on advanced search syntax please see the Ansible Tower" msgstr "" #: client/src/credentials/factories/become-method-change.factory.js:63 @@ -1772,6 +1839,7 @@ msgstr "" msgid "For job templates, select run to execute the playbook. Select check to only check playbook syntax, test environment setup, and report problems without executing the playbook." msgstr "" +#: client/features/output/output.strings.js:46 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:110 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:97 #: client/src/templates/job_templates/job-template.form.js:143 @@ -1779,7 +1847,7 @@ msgstr "" msgid "Forks" msgstr "" -#: client/src/management-jobs/scheduler/schedulerForm.partial.html:172 +#: client/src/management-jobs/scheduler/schedulerForm.partial.html:173 msgid "Frequency Details" msgstr "" @@ -1793,19 +1861,19 @@ msgstr "" msgid "Get latest SCM revision" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:126 +#: client/src/configuration/auth-form/configuration-auth.controller.js:129 msgid "GitHub" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:127 +#: client/src/configuration/auth-form/configuration-auth.controller.js:130 msgid "GitHub Org" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:128 +#: client/src/configuration/auth-form/configuration-auth.controller.js:131 msgid "GitHub Team" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:129 +#: client/src/configuration/auth-form/configuration-auth.controller.js:132 msgid "Google OAuth2" msgstr "" @@ -1854,7 +1922,7 @@ msgstr "" #: client/src/activity-stream/get-target-title.factory.js:41 #: client/src/inventories-hosts/hosts/hosts.partial.html:9 #: client/src/inventories-hosts/hosts/main.js:80 -#: client/src/inventories-hosts/inventories/inventories.partial.html:14 +#: client/src/inventories-hosts/inventories/inventories.partial.html:15 #: client/src/inventories-hosts/inventories/related/hosts/related-host.route.js:18 #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory-hosts.route.js:17 msgid "HOSTS" @@ -1926,11 +1994,11 @@ msgstr "" msgid "Host is not available. Click to toggle." msgstr "" -#: client/features/output/jobs.strings.js:13 +#: client/features/output/output.strings.js:13 msgid "Host status information for this job is unavailable." msgstr "" -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:26 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:27 #: client/src/home/dashboard/counts/dashboard-counts.directive.js:39 #: client/src/inventories-hosts/inventories/related/groups/groups.form.js:98 #: client/src/inventories-hosts/inventories/related/groups/related/nested-hosts/group-nested-hosts.list.js:57 @@ -1953,7 +2021,7 @@ msgid "Hosts Used" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:239 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:253 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:254 msgid "Hosts are imported to" msgstr "" @@ -1973,6 +2041,7 @@ msgstr "" msgid "INSIGHTS" msgstr "" +#: client/lib/components/components.strings.js:75 #: client/src/instance-groups/instance-groups.list.js:6 #: client/src/instance-groups/instance-groups.list.js:7 #: client/src/instance-groups/instance-groups.strings.js:13 @@ -1985,7 +2054,7 @@ msgstr "" #: client/src/activity-stream/get-target-title.factory.js:14 #: client/src/inventories-hosts/hosts/hosts.partial.html:8 -#: client/src/inventories-hosts/inventories/inventories.partial.html:13 +#: client/src/inventories-hosts/inventories/inventories.partial.html:14 #: client/src/inventories-hosts/inventories/inventories.route.js:8 #: client/src/inventories-hosts/inventories/inventory.list.js:14 #: client/src/inventories-hosts/inventories/inventory.list.js:15 @@ -2010,10 +2079,6 @@ msgstr "" msgid "INVENTORY SCRIPTS" msgstr "" -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.route.js:8 -msgid "INVENTORY SOURCES" -msgstr "" - #: client/src/notifications/notificationTemplates.form.js:419 msgid "IRC Nick" msgstr "" @@ -2048,7 +2113,7 @@ msgid "Idle Session" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:236 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:249 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:250 msgid "If blank, all groups above are created except" msgstr "" @@ -2076,7 +2141,7 @@ msgstr "" msgid "If enabled, simultaneous runs of this job template will be allowed." msgstr "" -#: client/src/templates/workflows.form.js:104 +#: client/src/templates/workflows.form.js:103 msgid "If enabled, simultaneous runs of this workflow job template will be allowed." msgstr "" @@ -2093,7 +2158,7 @@ msgid "If you are ready to upgrade, please contact us by clicking the button bel msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:227 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:240 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:241 msgid "Image ID:" msgstr "" @@ -2135,11 +2200,14 @@ msgstr "" msgid "Instance Filters" msgstr "" +#: client/features/output/output.strings.js:47 +msgid "Instance Group" +msgstr "" + #: client/src/instance-groups/instance-groups.strings.js:47 msgid "Instance Group parameter is missing." msgstr "" -#: client/lib/components/components.strings.js:75 #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:54 #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:57 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:61 @@ -2152,17 +2220,17 @@ msgid "Instance Groups" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:236 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:249 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:250 msgid "Instance ID" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:228 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:241 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:242 msgid "Instance ID:" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:229 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:242 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:243 msgid "Instance Type:" msgstr "" @@ -2183,6 +2251,10 @@ msgstr "" msgid "Invalid input for this type." msgstr "" +#: client/features/output/output.strings.js:85 +msgid "Invalid search filter provided." +msgstr "" + #: client/src/login/loginModal/loginModal.partial.html:34 msgid "Invalid username and/or password. Please try again." msgstr "" @@ -2191,13 +2263,14 @@ msgstr "" #: client/lib/models/models.strings.js:16 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:122 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:52 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:27 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:28 #: client/src/home/dashboard/counts/dashboard-counts.directive.js:50 #: client/src/organizations/linkout/organizations-linkout.route.js:156 msgid "Inventories" msgstr "" #: client/features/jobs/jobs.strings.js:13 +#: client/features/output/output.strings.js:48 #: client/features/templates/templates.strings.js:16 #: client/features/templates/templates.strings.js:24 #: client/src/inventories-hosts/hosts/host.list.js:69 @@ -2217,7 +2290,7 @@ msgstr "" #: client/lib/components/components.strings.js:71 #: client/lib/models/models.strings.js:20 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:28 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:29 msgid "Inventory Scripts" msgstr "" @@ -2226,6 +2299,7 @@ msgid "Inventory Sources" msgstr "" #: client/src/home/dashboard/graphs/dashboard-graphs.partial.html:46 +#: client/src/workflow-results/workflow-results.controller.js:70 msgid "Inventory Sync" msgstr "" @@ -2241,6 +2315,14 @@ msgstr "" msgid "Inventory contains 0 hosts." msgstr "" +#: client/features/output/output.strings.js:35 +msgid "Isolated" +msgstr "" + +#: client/features/output/output.strings.js:83 +msgid "JOB IS STILL RUNNING" +msgstr "" + #: client/src/home/dashboard/graphs/dashboard-graphs.partial.html:4 msgid "JOB STATUS" msgstr "" @@ -2252,12 +2334,11 @@ msgstr "" #: client/features/templates/routes/organizationsTemplatesList.route.js:20 #: client/features/templates/routes/projectsTemplatesList.route.js:18 #: client/src/organizations/list/organizations-list.controller.js:79 -#: client/src/portal-mode/portal-job-templates.list.js:13 -#: client/src/portal-mode/portal-job-templates.list.js:14 +#: client/src/portal-mode/portal-mode-layout.partial.html:8 msgid "JOB TEMPLATES" msgstr "" -#: client/features/jobs/index.view.html:6 +#: client/features/jobs/index.view.html:7 #: client/features/jobs/routes/instanceGroupJobs.route.js:13 #: client/features/jobs/routes/instanceJobs.route.js:13 #: client/features/jobs/routes/inventoryCompletedJobs.route.js:22 @@ -2265,7 +2346,7 @@ msgstr "" #: client/src/activity-stream/get-target-title.factory.js:32 #: client/src/home/dashboard/graphs/job-status/job-status-graph.directive.js:118 #: client/src/instance-groups/instance-groups.strings.js:19 -#: client/src/portal-mode/portal-mode-layout.partial.html:10 +#: client/src/portal-mode/portal-mode-layout.partial.html:19 msgid "JOBS" msgstr "" @@ -2281,6 +2362,7 @@ msgstr "" msgid "JSON:" msgstr "" +#: client/features/output/output.strings.js:50 #: client/features/templates/templates.strings.js:47 #: client/src/job-submission/job-submission.partial.html:228 #: client/src/templates/job_templates/job-template.form.js:190 @@ -2289,6 +2371,7 @@ msgid "Job Tags" msgstr "" #: client/features/jobs/jobs.strings.js:12 +#: client/features/output/output.strings.js:51 #: client/features/templates/templates.strings.js:13 #: client/src/templates/templates.list.js:61 msgid "Job Template" @@ -2297,10 +2380,11 @@ msgstr "" #: client/lib/models/models.strings.js:30 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:103 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:36 -#: client/src/projects/projects.form.js:297 +#: client/src/projects/projects.form.js:303 msgid "Job Templates" msgstr "" +#: client/features/output/output.strings.js:52 #: client/features/templates/templates.strings.js:49 #: client/src/home/dashboard/graphs/dashboard-graphs.partial.html:32 #: client/src/job-submission/job-submission.partial.html:202 @@ -2310,12 +2394,17 @@ msgid "Job Type" msgstr "" #: client/lib/components/components.strings.js:60 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:29 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:30 #: client/src/configuration/configuration.partial.html:22 #: client/src/instance-groups/instance-groups.strings.js:37 msgid "Jobs" msgstr "" +#: client/features/output/output.strings.js:81 +#: client/src/workflow-results/workflow-results.controller.js:71 +msgid "KEY" +msgstr "" + #: client/src/access/add-rbac-resource/rbac-resource.partial.html:61 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:154 #: client/src/shared/smart-search/smart-search.partial.html:14 @@ -2323,7 +2412,7 @@ msgid "Key" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:230 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:243 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:244 msgid "Key Name:" msgstr "" @@ -2348,27 +2437,27 @@ msgstr "" msgid "LAUNCH JOB" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:130 +#: client/src/configuration/auth-form/configuration-auth.controller.js:133 msgid "LDAP" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:138 +#: client/src/configuration/auth-form/configuration-auth.controller.js:141 msgid "LDAP 1 (Optional)" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:139 +#: client/src/configuration/auth-form/configuration-auth.controller.js:142 msgid "LDAP 2 (Optional)" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:140 +#: client/src/configuration/auth-form/configuration-auth.controller.js:143 msgid "LDAP 3 (Optional)" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:141 +#: client/src/configuration/auth-form/configuration-auth.controller.js:144 msgid "LDAP 4 (Optional)" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:142 +#: client/src/configuration/auth-form/configuration-auth.controller.js:145 msgid "LDAP 5 (Optional)" msgstr "" @@ -2381,11 +2470,13 @@ msgstr "" msgid "LICENSE" msgstr "" +#: client/features/output/output.strings.js:53 #: client/src/templates/job_templates/job-template.form.js:224 #: client/src/templates/job_templates/job-template.form.js:228 #: client/src/templates/templates.list.js:43 #: client/src/templates/workflows.form.js:72 -#: client/src/templates/workflows.form.js:77 +#: client/src/templates/workflows.form.js:76 +#: client/src/workflow-results/workflow-results.controller.js:51 msgid "Labels" msgstr "" @@ -2420,6 +2511,8 @@ msgid "Launch Management Job" msgstr "" #: client/features/jobs/jobs.strings.js:11 +#: client/features/output/output.strings.js:54 +#: client/src/workflow-results/workflow-results.controller.js:48 msgid "Launched By" msgstr "" @@ -2438,6 +2531,10 @@ msgstr "" msgid "License" msgstr "" +#: client/features/output/output.strings.js:55 +msgid "License Error" +msgstr "" + #: client/src/license/license.partial.html:104 msgid "License File" msgstr "" @@ -2454,6 +2551,7 @@ msgstr "" msgid "License Type" msgstr "" +#: client/features/output/output.strings.js:56 #: client/features/templates/templates.strings.js:48 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:45 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:55 @@ -2464,17 +2562,17 @@ msgid "Limit" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:240 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:254 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:255 msgid "Limit to hosts having a tag:" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:242 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:256 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:257 msgid "Limit to hosts using either key pair:" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:244 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:258 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:259 msgid "Limit to hosts where the Name tag begins with" msgstr "" @@ -2527,7 +2625,7 @@ msgstr "" msgid "MANAGEMENT JOBS" msgstr "" -#: client/src/portal-mode/portal-mode.route.js:12 +#: client/features/templates/routes/portalModeTemplatesList.route.js:12 msgid "MY VIEW" msgstr "" @@ -2536,6 +2634,7 @@ msgstr "" msgid "Machine" msgstr "" +#: client/features/output/output.strings.js:57 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:60 msgid "Machine Credential" msgstr "" @@ -2574,6 +2673,10 @@ msgstr "" msgid "Module" msgstr "" +#: client/features/output/output.strings.js:58 +msgid "Module Args" +msgstr "" + #: client/src/inventories-hosts/shared/factories/set-status.factory.js:25 msgid "Most recent job failed. Click to view jobs." msgstr "" @@ -2586,7 +2689,7 @@ msgstr "" msgid "Multiple Choice Options" msgstr "" -#: client/src/portal-mode/portal-mode-layout.partial.html:16 +#: client/src/portal-mode/portal-mode-layout.partial.html:25 msgid "My Jobs" msgstr "" @@ -2674,6 +2777,7 @@ msgstr "" msgid "NOTIFICATIONS" msgstr "" +#: client/features/output/output.strings.js:59 #: client/src/credential-types/credential-types.form.js:27 #: client/src/credential-types/credential-types.list.js:24 #: client/src/credentials/credentials.form.js:32 @@ -2697,7 +2801,6 @@ msgstr "" #: client/src/notifications/notificationTemplates.list.js:32 #: client/src/notifications/notifications.list.js:27 #: client/src/organizations/organizations.form.js:26 -#: client/src/portal-mode/portal-job-templates.list.js:23 #: client/src/projects/projects.form.js:30 #: client/src/projects/projects.list.js:37 #: client/src/scheduler/scheduled-jobs.list.js:31 @@ -2864,6 +2967,16 @@ msgstr "" msgid "Normal User" msgstr "" +#: client/features/output/output.strings.js:36 +#: client/src/workflow-results/workflow-results.controller.js:58 +msgid "Not Finished" +msgstr "" + +#: client/features/output/output.strings.js:37 +#: client/src/workflow-results/workflow-results.controller.js:59 +msgid "Not Started" +msgstr "" + #: client/src/projects/list/projects-list.controller.js:93 msgid "Not configured for SCM" msgstr "" @@ -2891,7 +3004,7 @@ msgstr "" msgid "Notification Label" msgstr "" -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:30 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:31 msgid "Notification Templates" msgstr "" @@ -2945,6 +3058,14 @@ msgstr "" msgid "ORGANIZATIONS" msgstr "" +#: client/src/workflow-results/workflow-results.controller.js:67 +msgid "On Fail" +msgstr "" + +#: client/src/workflow-results/workflow-results.controller.js:66 +msgid "On Success" +msgstr "" + #: client/src/inventories-hosts/inventories/related/sources/sources.form.js:157 #: client/src/inventories-hosts/inventories/related/sources/sources.form.js:162 msgid "Only Group By" @@ -2955,14 +3076,14 @@ msgid "OpenStack domains define administrative boundaries. It is only needed for msgstr "" #: client/src/templates/job_templates/job-template.form.js:230 -#: client/src/templates/workflows.form.js:79 +#: client/src/templates/workflows.form.js:78 msgid "Optional labels that describe this job template, such as 'dev' or 'test'. Labels can be used to group and filter job templates and completed jobs." msgstr "" #: client/src/notifications/notificationTemplates.form.js:453 #: client/src/partials/logviewer.html:7 #: client/src/templates/job_templates/job-template.form.js:270 -#: client/src/templates/workflows.form.js:97 +#: client/src/templates/workflows.form.js:96 msgid "Options" msgstr "" @@ -2988,7 +3109,7 @@ msgstr "" #: client/lib/models/models.strings.js:35 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:136 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:64 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:31 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:32 #: client/src/users/users.form.js:135 msgid "Organizations" msgstr "" @@ -3031,6 +3152,7 @@ msgstr "" msgid "Override variables found in vmware.ini and used by the inventory update script. For a detailed description of these variables" msgstr "" +#: client/features/output/output.strings.js:60 #: client/src/inventories-hosts/inventories/related/sources/sources.form.js:328 #: client/src/inventories-hosts/inventories/related/sources/sources.form.js:333 msgid "Overwrite" @@ -3041,6 +3163,10 @@ msgstr "" msgid "Overwrite Variables" msgstr "" +#: client/features/output/output.strings.js:61 +msgid "Overwrite Vars" +msgstr "" + #: client/src/credentials/credentials.list.js:40 msgid "Owners" msgstr "" @@ -3061,7 +3187,7 @@ msgstr "" msgid "PLEASE ADD A SURVEY PROMPT." msgstr "" -#: client/src/organizations/list/organizations-list.partial.html:46 +#: client/src/organizations/list/organizations-list.partial.html:37 #: client/src/shared/form-generator.js:1894 #: client/src/shared/list-generator/list-generator.factory.js:248 msgid "PLEASE ADD ITEMS TO THIS LIST" @@ -3078,7 +3204,7 @@ msgstr "" #: client/src/activity-stream/get-target-title.factory.js:8 #: client/src/organizations/linkout/organizations-linkout.route.js:196 #: client/src/organizations/list/organizations-list.controller.js:73 -#: client/src/projects/main.js:86 +#: client/src/projects/main.js:92 #: client/src/projects/projects.list.js:14 #: client/src/projects/projects.list.js:15 msgid "PROJECTS" @@ -3096,7 +3222,7 @@ msgstr "" msgid "Pass extra command line variables to the playbook. Provide key/value pairs using either YAML or JSON. Refer to the Ansible Tower documentation for example syntax." msgstr "" -#: client/src/templates/workflows.form.js:90 +#: client/src/templates/workflows.form.js:89 msgid "Pass extra command line variables to the playbook. This is the -e or --extra-vars command line parameter for ansible-playbook. Provide key/value pairs using either YAML or JSON. Refer to the Ansible Tower documentaton for example syntax." msgstr "" @@ -3175,11 +3301,12 @@ msgstr "" #: client/src/projects/projects.form.js:247 #: client/src/teams/teams.form.js:120 #: client/src/templates/job_templates/job-template.form.js:397 -#: client/src/templates/workflows.form.js:140 +#: client/src/templates/workflows.form.js:139 #: client/src/users/users.form.js:190 msgid "Permissions" msgstr "" +#: client/features/output/output.strings.js:62 #: client/src/shared/form-generator.js:1078 #: client/src/templates/job_templates/job-template.form.js:107 #: client/src/templates/job_templates/job-template.form.js:115 @@ -3293,6 +3420,10 @@ msgstr "" msgid "Please enter an answer." msgstr "" +#: client/src/management-jobs/scheduler/schedulerForm.partial.html:169 +msgid "Please input a number greater than 1." +msgstr "" + #: client/src/templates/job_templates/add-job-template/job-template-add.controller.js:54 msgid "Please save before adding a survey to this job template." msgstr "" @@ -3315,7 +3446,7 @@ msgstr "" #: client/src/projects/projects.form.js:239 #: client/src/teams/teams.form.js:116 #: client/src/templates/job_templates/job-template.form.js:390 -#: client/src/templates/workflows.form.js:133 +#: client/src/templates/workflows.form.js:132 msgid "Please save before assigning permissions." msgstr "" @@ -3457,6 +3588,7 @@ msgid "Privilege Escalation Username" msgstr "" #: client/features/jobs/jobs.strings.js:14 +#: client/features/output/output.strings.js:63 #: client/features/templates/templates.strings.js:17 #: client/src/credentials/factories/become-method-change.factory.js:30 #: client/src/credentials/factories/kind-change.factory.js:87 @@ -3484,6 +3616,10 @@ msgstr "" msgid "Project Path" msgstr "" +#: client/src/workflow-results/workflow-results.controller.js:69 +msgid "Project Sync" +msgstr "" + #: client/src/home/dashboard/counts/dashboard-counts.directive.js:66 msgid "Project Sync Failures" msgstr "" @@ -3496,7 +3632,7 @@ msgstr "" #: client/lib/models/models.strings.js:40 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:115 #: client/src/access/add-rbac-user-team/rbac-user-team.partial.html:47 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:32 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:33 #: client/src/home/dashboard/counts/dashboard-counts.directive.js:61 #: client/src/organizations/linkout/organizations-linkout.route.js:207 msgid "Projects" @@ -3548,12 +3684,12 @@ msgid "Prompt on launch" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:238 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:252 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:253 msgid "Provide a comma-separated list of filter expressions." msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:254 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:270 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:271 msgid "Provide a comma-separated list of filter expressions. Hosts are imported when all of the filters match. Refer to Ansible Tower documentation for more detail." msgstr "" @@ -3576,7 +3712,7 @@ msgid "Provide environment variables to pass to the custom inventory script." msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:257 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:273 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:274 msgid "Provide the named URL encoded name or id of the remote Tower inventory to be imported." msgstr "" @@ -3590,7 +3726,7 @@ msgstr "" msgid "Purple" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:131 +#: client/src/configuration/auth-form/configuration-auth.controller.js:134 msgid "RADIUS" msgstr "" @@ -3639,7 +3775,7 @@ msgstr "" msgid "REPLACE" msgstr "" -#: client/features/output/jobs.strings.js:8 +#: client/features/output/output.strings.js:8 msgid "RESULTS" msgstr "" @@ -3677,10 +3813,11 @@ msgstr "" msgid "Read" msgstr "" -#: client/src/workflow-results/workflow-results.partial.html:155 +#: client/src/workflow-results/workflow-results.controller.js:125 msgid "Read only view of extra variables added to the workflow." msgstr "" +#: client/features/output/output.strings.js:23 #: client/lib/components/code-mirror/code-mirror.strings.js:49 msgid "Read-only view of extra variables added to the job template." msgstr "" @@ -3718,7 +3855,7 @@ msgid "Refresh the page" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:231 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:244 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:245 msgid "Region:" msgstr "" @@ -3739,7 +3876,7 @@ msgid "Relaunch using host parameters" msgstr "" #: client/lib/components/components.strings.js:87 -#: client/src/workflow-results/workflow-results.partial.html:29 +#: client/src/workflow-results/workflow-results.controller.js:37 msgid "Relaunch using the same parameters" msgstr "" @@ -3817,12 +3954,13 @@ msgstr "" msgid "Revert all to default" msgstr "" +#: client/features/output/output.strings.js:65 #: client/src/projects/projects.list.js:50 msgid "Revision" msgstr "" #: client/src/projects/add/projects-add.controller.js:154 -#: client/src/projects/edit/projects-edit.controller.js:288 +#: client/src/projects/edit/projects-edit.controller.js:289 msgid "Revision #" msgstr "" @@ -3831,10 +3969,10 @@ msgstr "" #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:130 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:132 #: client/src/organizations/organizations.form.js:109 -#: client/src/projects/projects.form.js:269 +#: client/src/projects/projects.form.js:270 #: client/src/teams/teams.form.js:101 #: client/src/teams/teams.form.js:138 -#: client/src/templates/workflows.form.js:164 +#: client/src/templates/workflows.form.js:163 #: client/src/users/users.form.js:208 msgid "Role" msgstr "" @@ -3843,7 +3981,7 @@ msgstr "" msgid "Running Jobs" msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:132 +#: client/src/configuration/auth-form/configuration-auth.controller.js:135 msgid "SAML" msgstr "" @@ -3856,40 +3994,34 @@ msgstr "" msgid "SAVE" msgstr "" -#: client/src/scheduler/main.js:325 -msgid "SCHEDULED" -msgstr "" - #: client/src/scheduler/scheduled-jobs.list.js:13 msgid "SCHEDULED JOBS" msgstr "" -#: client/features/jobs/index.view.html:12 +#: client/features/jobs/index.view.html:13 #: client/src/activity-stream/get-target-title.factory.js:38 -#: client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule.route.js:49 -#: client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule.route.js:8 -#: client/src/management-jobs/scheduler/main.js:26 -#: client/src/management-jobs/scheduler/main.js:32 -#: client/src/scheduler/main.js:139 -#: client/src/scheduler/main.js:177 -#: client/src/scheduler/main.js:229 -#: client/src/scheduler/main.js:267 -#: client/src/scheduler/main.js:86 +#: client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule.route.js:9 +#: client/src/management-jobs/scheduler/main.js:28 +#: client/src/management-jobs/scheduler/main.js:34 +#: client/src/scheduler/schedules.route.js:104 +#: client/src/scheduler/schedules.route.js:15 +#: client/src/scheduler/schedules.route.js:192 +#: client/src/scheduler/schedules.route.js:288 msgid "SCHEDULES" msgstr "" #: client/src/projects/add/projects-add.controller.js:126 -#: client/src/projects/edit/projects-edit.controller.js:261 +#: client/src/projects/edit/projects-edit.controller.js:262 msgid "SCM Branch" msgstr "" #: client/src/projects/add/projects-add.controller.js:145 -#: client/src/projects/edit/projects-edit.controller.js:279 +#: client/src/projects/edit/projects-edit.controller.js:280 msgid "SCM Branch/Tag/Commit" msgstr "" #: client/src/projects/add/projects-add.controller.js:166 -#: client/src/projects/edit/projects-edit.controller.js:300 +#: client/src/projects/edit/projects-edit.controller.js:301 msgid "SCM Branch/Tag/Revision" msgstr "" @@ -3928,6 +4060,14 @@ msgstr "" msgid "SCM update currently running" msgstr "" +#: client/features/users/tokens/tokens.strings.js:38 +msgid "SCOPE" +msgstr "" + +#: client/features/output/output.strings.js:82 +msgid "SEARCH" +msgstr "" + #: client/src/inventories-hosts/inventories/adhoc/adhoc-credential.route.js:36 #: client/src/templates/job_templates/multi-credential/multi-credential-modal.partial.html:84 msgid "SELECT" @@ -4048,9 +4188,13 @@ msgstr "" msgid "Save" msgstr "" +#: client/src/configuration/configuration.controller.js:510 +msgid "Save Complete" +msgstr "" + #: client/src/configuration/auth-form/configuration-auth.controller.js:104 #: client/src/configuration/configuration.controller.js:242 -#: client/src/configuration/configuration.controller.js:319 +#: client/src/configuration/configuration.controller.js:324 #: client/src/configuration/system-form/configuration-system.controller.js:68 msgid "Save changes" msgstr "" @@ -4080,7 +4224,11 @@ msgid "Schedule job template runs" msgstr "" #: client/lib/components/components.strings.js:61 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:34 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:35 +#: client/src/inventories-hosts/inventories/related/sources/sources.form.js:416 +#: client/src/projects/projects.form.js:290 +#: client/src/templates/job_templates/job-template.form.js:443 +#: client/src/templates/workflows.form.js:185 msgid "Schedules" msgstr "" @@ -4094,7 +4242,7 @@ msgid "Secret Key" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:232 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:245 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:246 msgid "Security Group:" msgstr "" @@ -4106,12 +4254,6 @@ msgstr "" msgid "Select" msgstr "" -#: client/src/organizations/organizations.form.js:48 -#: client/src/projects/projects.form.js:209 -#: client/src/templates/job_templates/job-template.form.js:237 -msgid "Select Ansible Environment" -msgstr "" - #: client/src/shared/instance-groups-multiselect/instance-groups-modal/instance-groups-modal.partial.html:5 msgid "Select Instance Groups" msgstr "" @@ -4157,8 +4299,8 @@ msgstr "" msgid "Select from the list of directories found in the Project Base Path. Together the base path and the playbook directory provide the full path used to locate playbooks." msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:343 -#: client/src/configuration/auth-form/configuration-auth.controller.js:362 +#: client/src/configuration/auth-form/configuration-auth.controller.js:346 +#: client/src/configuration/auth-form/configuration-auth.controller.js:365 msgid "Select group types" msgstr "" @@ -4215,7 +4357,7 @@ msgid "Select types" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:224 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:237 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:238 msgid "Select which groups to create automatically." msgstr "" @@ -4254,6 +4396,14 @@ msgstr "" msgid "Show Changes" msgstr "" +#: client/features/output/output.strings.js:38 +msgid "Show Less" +msgstr "" + +#: client/features/output/output.strings.js:39 +msgid "Show More" +msgstr "" + #: client/src/login/loginModal/thirdPartySignOn/thirdPartySignOn.service.js:33 #: client/src/login/loginModal/thirdPartySignOn/thirdPartySignOn.service.js:44 #: client/src/login/loginModal/thirdPartySignOn/thirdPartySignOn.service.js:55 @@ -4269,6 +4419,7 @@ msgstr "" msgid "Sign in with %s Teams" msgstr "" +#: client/features/output/output.strings.js:66 #: client/features/templates/templates.strings.js:46 #: client/src/job-submission/job-submission.partial.html:245 #: client/src/templates/job_templates/job-template.form.js:207 @@ -4296,6 +4447,7 @@ msgstr "" msgid "Solvable With Playbook" msgstr "" +#: client/features/output/output.strings.js:67 #: client/src/inventories-hosts/inventories/list/source-summary-popover/source-summary-popover.directive.js:57 #: client/src/inventories-hosts/inventories/related/sources/sources.form.js:64 msgid "Source" @@ -4305,6 +4457,10 @@ msgstr "" msgid "Source Control" msgstr "" +#: client/features/output/output.strings.js:68 +msgid "Source Credential" +msgstr "" + #: client/src/inventories-hosts/inventories/related/sources/sources.form.js:47 #: client/src/projects/projects.form.js:26 msgid "Source Details" @@ -4362,7 +4518,7 @@ msgid "Specify a scope for the token's access" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:253 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:269 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:270 msgid "Specify which groups to create automatically. Group names will be created similar to the options selected. If blank, all groups above are created. Refer to Ansible Tower documentation for more detail." msgstr "" @@ -4387,9 +4543,12 @@ msgid "Start sync process" msgstr "" #: client/features/jobs/jobs.strings.js:8 +#: client/features/output/output.strings.js:69 +#: client/src/workflow-results/workflow-results.controller.js:49 msgid "Started" msgstr "" +#: client/features/output/output.strings.js:70 #: client/src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.directive.js:53 #: client/src/inventories-hosts/inventories/list/source-summary-popover/source-summary-popover.directive.js:55 #: client/src/inventories-hosts/shared/factories/set-status.factory.js:43 @@ -4489,7 +4648,7 @@ msgstr "" msgid "System auditors have read-only permissions in this section." msgstr "" -#: client/src/configuration/auth-form/configuration-auth.controller.js:133 +#: client/src/configuration/auth-form/configuration-auth.controller.js:136 msgid "TACACS+" msgstr "" @@ -4531,11 +4690,12 @@ msgstr "" #: client/features/users/tokens/tokens.strings.js:10 #: client/features/users/tokens/tokens.strings.js:8 #: client/features/users/tokens/users-tokens-list.route.js:11 +#: client/src/activity-stream/get-target-title.factory.js:50 msgid "TOKENS" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:235 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:248 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:249 msgid "Tag None:" msgstr "" @@ -4544,7 +4704,7 @@ msgid "Tags are useful when you have a large playbook, and you want to run a spe msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:233 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:246 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:247 msgid "Tags:" msgstr "" @@ -4558,21 +4718,22 @@ msgstr "" #: client/src/credentials/credentials.form.js:468 #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:136 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:138 -#: client/src/projects/projects.form.js:275 -#: client/src/templates/workflows.form.js:170 +#: client/src/projects/projects.form.js:276 +#: client/src/templates/workflows.form.js:169 msgid "Team Roles" msgstr "" #: client/lib/components/components.strings.js:70 #: client/src/access/add-rbac-resource/rbac-resource.partial.html:40 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:35 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:36 #: client/src/organizations/linkout/organizations-linkout.route.js:109 -#: client/src/shared/stateDefinitions.factory.js:410 +#: client/src/shared/stateDefinitions.factory.js:426 #: client/src/users/users.form.js:162 msgid "Teams" msgstr "" #: client/src/templates/templates.list.js:14 +#: client/src/workflow-results/workflow-results.controller.js:47 msgid "Template" msgstr "" @@ -4581,7 +4742,7 @@ msgid "Template parameter is missing." msgstr "" #: client/lib/components/components.strings.js:67 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:36 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:37 msgid "Templates" msgstr "" @@ -4615,7 +4776,7 @@ msgstr "" msgid "The Project ID is the GCE assigned identification. It is constructed as two words followed by a three digit number. Such as:" msgstr "" -#: client/src/projects/edit/projects-edit.controller.js:339 +#: client/src/projects/edit/projects-edit.controller.js:340 msgid "The SCM update process is running." msgstr "" @@ -4623,7 +4784,7 @@ msgstr "" msgid "The email address assigned to the Google Compute Engine %sservice account." msgstr "" -#: client/features/output/jobs.strings.js:12 +#: client/features/output/output.strings.js:12 msgid "The host status bar will update when the job is complete." msgstr "" @@ -4672,10 +4833,6 @@ msgstr "" msgid "There are no events to display at this time" msgstr "" -#: client/src/portal-mode/portal-job-templates.list.js:18 -msgid "There are no job templates to display at this time" -msgstr "" - #: client/features/jobs/jobs.strings.js:16 msgid "There are no running jobs." msgstr "" @@ -4700,11 +4857,11 @@ msgstr "" msgid "There was an error getting config values:" msgstr "" -#: client/src/configuration/configuration.controller.js:403 +#: client/src/configuration/configuration.controller.js:409 msgid "There was an error resetting value. Returned status:" msgstr "" -#: client/src/configuration/configuration.controller.js:584 +#: client/src/configuration/configuration.controller.js:601 msgid "There was an error resetting values. Returned status:" msgstr "" @@ -4767,7 +4924,7 @@ msgstr "" msgid "This value does not match the password you entered previously. Please confirm that password." msgstr "" -#: client/src/configuration/configuration.controller.js:609 +#: client/src/configuration/configuration.controller.js:626 msgid "This will reset all configuration values to their factory defaults. Are you sure you want to proceed?" msgstr "" @@ -4803,6 +4960,7 @@ msgid "Token" msgstr "" #: client/features/applications/applications.strings.js:16 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:25 #: client/src/users/users.form.js:236 msgid "Tokens" msgstr "" @@ -4811,6 +4969,10 @@ msgstr "" msgid "Total Issues" msgstr "" +#: client/src/workflow-results/workflow-results.controller.js:62 +msgid "Total Jobs" +msgstr "" + #: client/src/partials/logviewer.html:6 msgid "Traceback" msgstr "" @@ -4837,7 +4999,7 @@ msgid "Type Details" msgstr "" #: client/src/projects/add/projects-add.controller.js:177 -#: client/src/projects/edit/projects-edit.controller.js:311 +#: client/src/projects/edit/projects-edit.controller.js:312 msgid "URL popover text" msgstr "" @@ -4898,6 +5060,10 @@ msgstr "" msgid "Unable to edit template." msgstr "" +#: client/src/shared/stateDefinitions.factory.js:231 +msgid "Unable to get resource:" +msgstr "" + #: client/features/templates/templates.strings.js:84 msgid "Unable to launch template." msgstr "" @@ -4950,7 +5116,7 @@ msgstr "" msgid "Update failed. Click for details" msgstr "" -#: client/src/projects/edit/projects-edit.controller.js:339 +#: client/src/projects/edit/projects-edit.controller.js:340 msgid "Update in Progress" msgstr "" @@ -5009,9 +5175,9 @@ msgstr "" #: client/src/inventories-hosts/inventories/smart-inventory/smart-inventory.form.js:125 #: client/src/inventories-hosts/inventories/standard-inventory/inventory.form.js:127 #: client/src/organizations/organizations.form.js:104 -#: client/src/projects/projects.form.js:264 +#: client/src/projects/projects.form.js:265 #: client/src/teams/teams.form.js:96 -#: client/src/templates/workflows.form.js:159 +#: client/src/templates/workflows.form.js:158 msgid "User" msgstr "" @@ -5044,7 +5210,7 @@ msgstr "" #: client/lib/components/components.strings.js:69 #: client/src/access/add-rbac-resource/rbac-resource.partial.html:35 -#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:37 +#: client/src/activity-stream/streamDropdownNav/stream-dropdown-nav.directive.js:38 #: client/src/organizations/organizations.form.js:86 #: client/src/teams/teams.form.js:78 msgid "Users" @@ -5064,7 +5230,7 @@ msgid "VIEW PER PAGE" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:234 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:247 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:248 msgid "VPC ID:" msgstr "" @@ -5097,6 +5263,7 @@ msgstr "" msgid "Vault Password" msgstr "" +#: client/features/output/output.strings.js:71 #: client/features/templates/templates.strings.js:50 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:82 #: client/src/inventories-hosts/inventories/adhoc/adhoc.form.js:91 @@ -5159,13 +5326,13 @@ msgstr "" msgid "View More" msgstr "" -#: client/features/output/jobs.strings.js:24 +#: client/features/output/output.strings.js:27 msgid "View Project checkout results" msgstr "" #: client/src/shared/form-generator.js:1728 -#: client/src/templates/job_templates/job-template.form.js:449 -#: client/src/templates/workflows.form.js:187 +#: client/src/templates/job_templates/job-template.form.js:454 +#: client/src/templates/workflows.form.js:196 msgid "View Survey" msgstr "" @@ -5235,31 +5402,31 @@ msgid "View template" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:246 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:260 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:261 msgid "View the" msgstr "" -#: client/features/output/jobs.strings.js:20 +#: client/features/output/output.strings.js:21 msgid "View the Credential" msgstr "" -#: client/features/output/jobs.strings.js:19 +#: client/features/output/output.strings.js:24 msgid "View the Inventory" msgstr "" -#: client/features/output/jobs.strings.js:21 +#: client/features/output/output.strings.js:25 msgid "View the Job Template" msgstr "" -#: client/features/output/jobs.strings.js:23 +#: client/features/output/output.strings.js:26 msgid "View the Project" msgstr "" -#: client/features/output/jobs.strings.js:18 +#: client/features/output/output.strings.js:28 msgid "View the Schedule" msgstr "" -#: client/features/output/jobs.strings.js:17 +#: client/features/output/output.strings.js:30 msgid "View the User" msgstr "" @@ -5271,7 +5438,7 @@ msgstr "" msgid "View the schedule" msgstr "" -#: client/features/output/jobs.strings.js:22 +#: client/features/output/output.strings.js:29 msgid "View the source Workflow Job" msgstr "" @@ -5289,7 +5456,7 @@ msgstr "" #: client/src/configuration/auth-form/configuration-auth.controller.js:91 #: client/src/configuration/configuration.controller.js:229 -#: client/src/configuration/configuration.controller.js:309 +#: client/src/configuration/configuration.controller.js:314 #: client/src/configuration/system-form/configuration-system.controller.js:55 msgid "Warning: Unsaved Changes" msgstr "" @@ -5311,7 +5478,7 @@ msgid "When not checked, local child hosts and groups not found on the external msgstr "" #: client/src/shared/form-generator.js:1732 -#: client/src/templates/workflows.form.js:213 +#: client/src/templates/workflows.form.js:222 msgid "Workflow Editor" msgstr "" @@ -5388,7 +5555,7 @@ msgstr "" #: client/src/configuration/auth-form/configuration-auth.controller.js:90 #: client/src/configuration/configuration.controller.js:228 -#: client/src/configuration/configuration.controller.js:308 +#: client/src/configuration/configuration.controller.js:313 #: client/src/configuration/system-form/configuration-system.controller.js:54 msgid "You have unsaved changes. Would you like to proceed without saving?" msgstr "" @@ -5421,12 +5588,13 @@ msgstr "" msgid "characters long." msgstr "" +#: client/features/output/output.strings.js:78 #: client/src/shared/smart-search/smart-search.partial.html:53 msgid "documentation" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:247 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:261 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:262 msgid "for a complete list of supported filters." msgstr "" @@ -5473,7 +5641,7 @@ msgid "of" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:239 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:253 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:254 msgid "of the filters match." msgstr "" @@ -5495,7 +5663,7 @@ msgid "sources with sync failures. Click for details" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:244 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:258 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:259 msgid "test" msgstr "" @@ -5541,12 +5709,12 @@ msgid "view vmware_inventory.ini in the Ansible github repo." msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:239 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:253 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:254 msgid "when" msgstr "" #: client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js:225 -#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:238 +#: client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js:239 msgid "will create group names similar to the following examples based on the options selected:" msgstr "" @@ -5554,25 +5722,14 @@ msgstr "" msgid "with failed jobs." msgstr "" +#: client/features/users/tokens/tokens.strings.js:40 +msgid "{{ appName }} Token" +msgstr "" + #: client/lib/services/base-string.service.js:93 msgid "{{ header }} {{ body }}" msgstr "" -#: client/features/output/details.partial.html:283 -#: client/features/output/details.partial.html:290 -msgid "{{ vm.jobTags.label }}" -msgstr "" - -#: client/features/output/details.partial.html:258 -#: client/features/output/details.partial.html:265 -msgid "{{ vm.labels.label }}" -msgstr "" - -#: client/features/output/details.partial.html:308 -#: client/features/output/details.partial.html:315 -msgid "{{ vm.skipTags.label }}" -msgstr "" - #: client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.partial.html:5 msgid "{{:: vm.strings.get('prompt.JOB_TYPE') }}" msgstr "" From 99d2f3ea348063a13bb826c9b5631ef432a95894 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 20 Apr 2018 16:32:18 -0400 Subject: [PATCH 095/762] allow Jinja2 in ansible -a when ALLOW_JINJA_IN_EXTRA_VARS is 'always' --- awx/main/tasks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index dc3c8df28b..8da7b9c1b5 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2266,7 +2266,10 @@ class RunAdHocCommand(BaseTask): args.extend(['-e', '@%s' % (extra_vars_path)]) args.extend(['-m', ad_hoc_command.module_name]) - args.extend(['-a', sanitize_jinja(ad_hoc_command.module_args)]) + module_args = ad_hoc_command.module_args + if settings.ALLOW_JINJA_IN_EXTRA_VARS != 'always': + module_args = sanitize_jinja(module_args) + args.extend(['-a', module_args]) if ad_hoc_command.limit: args.append(ad_hoc_command.limit) From 01611540f01b471fe3c80a4b9a711bdb47f8d2db Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Wed, 30 May 2018 17:01:35 -0700 Subject: [PATCH 096/762] Marking a Configure Tower string for translation Once this gets added to the UI's es.po file, this should show up translated, along with it's tooltip. --- .../configuration/system-form/configuration-system.partial.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/configuration/system-form/configuration-system.partial.html b/awx/ui/client/src/configuration/system-form/configuration-system.partial.html index 2b92cc1197..32337fc588 100644 --- a/awx/ui/client/src/configuration/system-form/configuration-system.partial.html +++ b/awx/ui/client/src/configuration/system-form/configuration-system.partial.html @@ -27,7 +27,7 @@
From a20eaae7bcc3621f272d87d4513863b4be56ad5b Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Thu, 31 May 2018 17:02:51 -0400 Subject: [PATCH 100/762] make app tab visiable to org admins --- awx/ui/client/lib/components/layout/layout.partial.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/lib/components/layout/layout.partial.html b/awx/ui/client/lib/components/layout/layout.partial.html index 4714a23172..df8c5d5843 100644 --- a/awx/ui/client/lib/components/layout/layout.partial.html +++ b/awx/ui/client/lib/components/layout/layout.partial.html @@ -70,7 +70,7 @@ -
+
{{:: $parent.layoutVm.getString('ADMINISTRATION_HEADER') }} @@ -88,7 +88,7 @@ ng-show="$parent.layoutVm.isSuperUser || $parent.layoutVm.isOrgAdmin"> + ng-show="$parent.layoutVm.isSuperUser || $parent.layoutVm.isOrgAdmin"> From 6183e5166df613596d1f087ccb2a7e16abe4265e Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Fri, 1 Jun 2018 09:51:42 -0400 Subject: [PATCH 101/762] fix a failing timezone test --- awx/main/tests/functional/models/test_schedule.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/awx/main/tests/functional/models/test_schedule.py b/awx/main/tests/functional/models/test_schedule.py index 0921971c47..b03e3aab8b 100644 --- a/awx/main/tests/functional/models/test_schedule.py +++ b/awx/main/tests/functional/models/test_schedule.py @@ -131,11 +131,11 @@ def test_leap_year_day(job_template): @pytest.mark.django_db @pytest.mark.parametrize('until, dtend', [ - ['20180602T170000Z', '2018-06-02 12:00:00+00:00'], - ['20180602T000000Z', '2018-06-01 12:00:00+00:00'], + ['20300602T170000Z', '2030-06-02 12:00:00+00:00'], + ['20300602T000000Z', '2030-06-01 12:00:00+00:00'], ]) def test_utc_until(job_template, until, dtend): - rrule = 'DTSTART:20180601T120000Z RRULE:FREQ=DAILY;INTERVAL=1;UNTIL={}'.format(until) + rrule = 'DTSTART:20300601T120000Z RRULE:FREQ=DAILY;INTERVAL=1;UNTIL={}'.format(until) s = Schedule( name='Some Schedule', rrule=rrule, @@ -143,7 +143,7 @@ def test_utc_until(job_template, until, dtend): ) s.save() - assert str(s.next_run) == '2018-06-01 12:00:00+00:00' + assert str(s.next_run) == '2030-06-01 12:00:00+00:00' assert str(s.next_run) == str(s.dtstart) assert str(s.dtend) == dtend From 7aa2b7f6a7e81550b00dfabe0b6bf0a4f420a53c Mon Sep 17 00:00:00 2001 From: Guoqiang Zhang Date: Fri, 1 Jun 2018 08:58:47 -0400 Subject: [PATCH 102/762] Fix the color of the tips badge for failed hosts in the host status bar --- awx/ui/client/features/output/_index.less | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less index 8ca2c8a5d0..9e4da5ab10 100644 --- a/awx/ui/client/features/output/_index.less +++ b/awx/ui/client/features/output/_index.less @@ -288,7 +288,7 @@ background-color: @default-warning; } -.HostStatusBar-tooltipBadge--failed { +.HostStatusBar-tooltipBadge--failures { background-color: @default-err; } From 97c5ff0b334a658d00e8018d4c7fe9ea84063e7f Mon Sep 17 00:00:00 2001 From: adamscmRH Date: Thu, 31 May 2018 14:11:35 -0400 Subject: [PATCH 103/762] make scope write by default --- awx/api/serializers.py | 4 ++-- .../migrations/0033_v330_oauth_help_text.py | 2 +- awx/main/models/oauth.py | 1 + awx/main/tests/functional/api/test_oauth.py | 20 +++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 6ed8026960..e7d09f8dde 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -999,7 +999,7 @@ class BaseOAuth2TokenSerializer(BaseSerializer): ) read_only_fields = ('user', 'token', 'expires', 'refresh_token') extra_kwargs = { - 'scope': {'allow_null': False, 'required': True}, + 'scope': {'allow_null': False, 'required': False}, 'user': {'allow_null': False, 'required': True} } @@ -1061,7 +1061,7 @@ class UserAuthorizedTokenSerializer(BaseOAuth2TokenSerializer): class Meta: extra_kwargs = { - 'scope': {'allow_null': False, 'required': True}, + 'scope': {'allow_null': False, 'required': False}, 'user': {'allow_null': False, 'required': True}, 'application': {'allow_null': False, 'required': True} } diff --git a/awx/main/migrations/0033_v330_oauth_help_text.py b/awx/main/migrations/0033_v330_oauth_help_text.py index 41704307b0..0b64579d65 100644 --- a/awx/main/migrations/0033_v330_oauth_help_text.py +++ b/awx/main/migrations/0033_v330_oauth_help_text.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='oauth2accesstoken', name='scope', - field=models.TextField(blank=True, help_text="Allowed scopes, further restricts user's permissions."), + field=models.TextField(blank=True, default=b'write', help_text="Allowed scopes, further restricts user's permissions."), ), migrations.AlterField( model_name='oauth2accesstoken', diff --git a/awx/main/models/oauth.py b/awx/main/models/oauth.py index 45e13fc8b0..a23ec6afeb 100644 --- a/awx/main/models/oauth.py +++ b/awx/main/models/oauth.py @@ -109,6 +109,7 @@ class OAuth2AccessToken(AbstractAccessToken): ) scope = models.TextField( blank=True, + default='write', help_text=_('Allowed scopes, further restricts user\'s permissions. Must be a simple space-separated string with allowed scopes [\'read\', \'write\'].') ) diff --git a/awx/main/tests/functional/api/test_oauth.py b/awx/main/tests/functional/api/test_oauth.py index 7e8b63eb08..f4011fa590 100644 --- a/awx/main/tests/functional/api/test_oauth.py +++ b/awx/main/tests/functional/api/test_oauth.py @@ -28,6 +28,26 @@ def test_personal_access_token_creation(oauth_application, post, alice): assert 'refresh_token' in resp_json +@pytest.mark.django_db +def test_pat_creation_no_default_scope(oauth_application, post, admin): + # tests that the default scope is overriden + url = reverse('api:o_auth2_token_list') + response = post(url, {'description': 'test token', + 'scope': 'read', + 'application': oauth_application.pk, + }, admin) + assert response.data['scope'] == 'read' + + +@pytest.mark.django_db +def test_pat_creation_no_scope(oauth_application, post, admin): + url = reverse('api:o_auth2_token_list') + response = post(url, {'description': 'test token', + 'application': oauth_application.pk, + }, admin) + assert response.data['scope'] == 'write' + + @pytest.mark.django_db def test_oauth2_application_create(admin, organization, post): response = post( From 427ea6752e4bbf3b7c7623a62ceced6d4298eff0 Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Fri, 1 Jun 2018 22:25:46 -0400 Subject: [PATCH 104/762] Fix syntax error --- awx/plugins/inventory/tower.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/plugins/inventory/tower.py b/awx/plugins/inventory/tower.py index f6aee944ff..a74e1361da 100755 --- a/awx/plugins/inventory/tower.py +++ b/awx/plugins/inventory/tower.py @@ -114,7 +114,7 @@ def read_tower_inventory(tower_host, tower_user, tower_pass, inventory, license_ return response.json() except (ValueError, TypeError) as e: # If the JSON parse fails, print the ValueError - raise RuntimeError("Failed to parse json from host: {}".format(e) + raise RuntimeError("Failed to parse json from host: {}".format(e)) except requests.ConnectionError as e: raise RuntimeError("Connection to remote host failed: {}".format(e)) From 239c60a2bdd3193bc211779def7a8484025a3fa0 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 31 May 2018 15:43:58 -0400 Subject: [PATCH 105/762] fix tracebacks when running migrations --- .../0010_v322_add_ovirt4_tower_inventory.py | 2 + .../migrations/0012_v322_update_cred_types.py | 2 + .../migrations/0013_v330_multi_credential.py | 9 +-- .../0023_v330_inventory_multicred.py | 4 +- awx/main/migrations/_credentialtypes.py | 11 +++ awx/main/migrations/_multi_cred.py | 72 ++++++++++++++++++- awx/main/signals.py | 26 ++++--- 7 files changed, 109 insertions(+), 17 deletions(-) diff --git a/awx/main/migrations/0010_v322_add_ovirt4_tower_inventory.py b/awx/main/migrations/0010_v322_add_ovirt4_tower_inventory.py index aac423cd1c..2cdf557856 100644 --- a/awx/main/migrations/0010_v322_add_ovirt4_tower_inventory.py +++ b/awx/main/migrations/0010_v322_add_ovirt4_tower_inventory.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals # AWX +from awx.main.migrations import _migration_utils as migration_utils from awx.main.migrations import _credentialtypes as credentialtypes from django.db import migrations, models @@ -14,6 +15,7 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython(migration_utils.set_current_apps_for_migrations), migrations.RunPython(credentialtypes.create_rhv_tower_credtype), migrations.AlterField( model_name='inventorysource', diff --git a/awx/main/migrations/0012_v322_update_cred_types.py b/awx/main/migrations/0012_v322_update_cred_types.py index 86d9fd55fa..b1e77fd810 100644 --- a/awx/main/migrations/0012_v322_update_cred_types.py +++ b/awx/main/migrations/0012_v322_update_cred_types.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals # AWX +from awx.main.migrations import _migration_utils as migration_utils from awx.main.migrations import _credentialtypes as credentialtypes from django.db import migrations @@ -14,5 +15,6 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RunPython(migration_utils.set_current_apps_for_migrations), migrations.RunPython(credentialtypes.add_azure_cloud_environment_field), ] diff --git a/awx/main/migrations/0013_v330_multi_credential.py b/awx/main/migrations/0013_v330_multi_credential.py index abc186c382..a5f00fdf08 100644 --- a/awx/main/migrations/0013_v330_multi_credential.py +++ b/awx/main/migrations/0013_v330_multi_credential.py @@ -5,7 +5,7 @@ from django.db import migrations, models from awx.main.migrations import _migration_utils as migration_utils from awx.main.migrations import _credentialtypes as credentialtypes -from awx.main.migrations._multi_cred import migrate_to_multi_cred +from awx.main.migrations._multi_cred import migrate_to_multi_cred, migrate_back_from_multi_cred class Migration(migrations.Migration): @@ -25,8 +25,8 @@ class Migration(migrations.Migration): name='credentials', field=models.ManyToManyField(related_name='unifiedjobtemplates', to='main.Credential'), ), - migrations.RunPython(migration_utils.set_current_apps_for_migrations), - migrations.RunPython(migrate_to_multi_cred), + migrations.RunPython(migration_utils.set_current_apps_for_migrations, migrate_back_from_multi_cred), + migrations.RunPython(migrate_to_multi_cred, migration_utils.set_current_apps_for_migrations), migrations.RemoveField( model_name='job', name='credential', @@ -51,5 +51,6 @@ class Migration(migrations.Migration): model_name='jobtemplate', name='vault_credential', ), - migrations.RunPython(credentialtypes.add_vault_id_field) + migrations.RunPython(migration_utils.set_current_apps_for_migrations, credentialtypes.remove_vault_id_field), + migrations.RunPython(credentialtypes.add_vault_id_field, migration_utils.set_current_apps_for_migrations) ] diff --git a/awx/main/migrations/0023_v330_inventory_multicred.py b/awx/main/migrations/0023_v330_inventory_multicred.py index b184abc296..fdb95e8ddc 100644 --- a/awx/main/migrations/0023_v330_inventory_multicred.py +++ b/awx/main/migrations/0023_v330_inventory_multicred.py @@ -19,8 +19,8 @@ class Migration(migrations.Migration): operations = [ # Run data migration before removing the old credential field - migrations.RunPython(migration_utils.set_current_apps_for_migrations, migrations.RunPython.noop), - migrations.RunPython(migrate_inventory_source_cred, migrate_inventory_source_cred_reverse), + migrations.RunPython(migration_utils.set_current_apps_for_migrations, migrate_inventory_source_cred_reverse), + migrations.RunPython(migrate_inventory_source_cred, migration_utils.set_current_apps_for_migrations), migrations.RemoveField( model_name='inventorysource', name='credential', diff --git a/awx/main/migrations/_credentialtypes.py b/awx/main/migrations/_credentialtypes.py index fbf812e8c2..9d78cec49d 100644 --- a/awx/main/migrations/_credentialtypes.py +++ b/awx/main/migrations/_credentialtypes.py @@ -180,6 +180,17 @@ def add_vault_id_field(apps, schema_editor): vault_credtype.save() +def remove_vault_id_field(apps, schema_editor): + vault_credtype = CredentialType.objects.get(kind='vault') + idx = 0 + for i, input in enumerate(vault_credtype.inputs['fields']): + if input['id'] == 'vault_id': + idx = i + break + vault_credtype.inputs['fields'].pop(idx) + vault_credtype.save() + + def create_rhv_tower_credtype(apps, schema_editor): CredentialType.setup_tower_managed_defaults() diff --git a/awx/main/migrations/_multi_cred.py b/awx/main/migrations/_multi_cred.py index 5e4ed5ff4b..1b2a99c110 100644 --- a/awx/main/migrations/_multi_cred.py +++ b/awx/main/migrations/_multi_cred.py @@ -1,56 +1,124 @@ +import logging + + +logger = logging.getLogger('awx.main.migrations') + + def migrate_to_multi_cred(app, schema_editor): Job = app.get_model('main', 'Job') JobTemplate = app.get_model('main', 'JobTemplate') + ct = 0 for cls in (Job, JobTemplate): for j in cls.objects.iterator(): if j.credential: + ct += 1 + logger.debug('Migrating cred %s to %s %s multi-cred relation.', j.credential_id, cls, j.id) j.credentials.add(j.credential) if j.vault_credential: + ct += 1 + logger.debug('Migrating cred %s to %s %s multi-cred relation.', j.vault_credential_id, cls, j.id) j.credentials.add(j.vault_credential) for cred in j.extra_credentials.all(): + ct += 1 + logger.debug('Migrating cred %s to %s %s multi-cred relation.', cred.id, cls, j.id) j.credentials.add(cred) + if ct: + logger.info('Finished migrating %s credentials to multi-cred', ct) + + +def migrate_back_from_multi_cred(app, schema_editor): + Job = app.get_model('main', 'Job') + JobTemplate = app.get_model('main', 'JobTemplate') + CredentialType = app.get_model('main', 'CredentialType') + vault_credtype = CredentialType.objects.get(kind='vault') + ssh_credtype = CredentialType.objects.get(kind='ssh') + + ct = 0 + for cls in (Job, JobTemplate): + for j in cls.objects.iterator(): + for cred in j.credentials.iterator(): + changed = False + if cred.credential_type_id == vault_credtype.id: + changed = True + ct += 1 + logger.debug('Reverse migrating vault cred %s for %s %s', cred.id, cls, j.id) + j.vault_credential = cred + elif cred.credential_type_id == ssh_credtype.id: + changed = True + ct += 1 + logger.debug('Reverse migrating ssh cred %s for %s %s', cred.id, cls, j.id) + j.credential = cred + else: + changed = True + ct += 1 + logger.debug('Reverse migrating cloud cred %s for %s %s', cred.id, cls, j.id) + j.extra_credentials.add(cred) + if changed: + j.save() + if ct: + logger.info('Finished reverse migrating %s credentials from multi-cred', ct) def migrate_workflow_cred(app, schema_editor): WorkflowJobTemplateNode = app.get_model('main', 'WorkflowJobTemplateNode') WorkflowJobNode = app.get_model('main', 'WorkflowJobNode') + ct = 0 for cls in (WorkflowJobNode, WorkflowJobTemplateNode): for node in cls.objects.iterator(): if node.credential: - node.credentials.add(j.credential) + logger.debug('Migrating prompted credential %s for %s %s', node.credential_id, cls, node.id) + ct += 1 + node.credentials.add(node.credential) + if ct: + logger.info('Finished migrating total of %s workflow prompted credentials', ct) def migrate_workflow_cred_reverse(app, schema_editor): WorkflowJobTemplateNode = app.get_model('main', 'WorkflowJobTemplateNode') WorkflowJobNode = app.get_model('main', 'WorkflowJobNode') + ct = 0 for cls in (WorkflowJobNode, WorkflowJobTemplateNode): for node in cls.objects.iterator(): cred = node.credentials.first() if cred: node.credential = cred - node.save() + logger.debug('Reverse migrating prompted credential %s for %s %s', node.credential_id, cls, node.id) + ct += 1 + node.save(update_fields=['credential']) + if ct: + logger.info('Finished reverse migrating total of %s workflow prompted credentials', ct) def migrate_inventory_source_cred(app, schema_editor): InventoryUpdate = app.get_model('main', 'InventoryUpdate') InventorySource = app.get_model('main', 'InventorySource') + ct = 0 for cls in (InventoryUpdate, InventorySource): for obj in cls.objects.iterator(): if obj.credential: + ct += 1 + logger.debug('Migrating credential %s for %s %s', obj.credential_id, cls, obj.id) obj.credentials.add(obj.credential) + if ct: + logger.info('Finished migrating %s inventory source credentials to multi-cred', ct) def migrate_inventory_source_cred_reverse(app, schema_editor): InventoryUpdate = app.get_model('main', 'InventoryUpdate') InventorySource = app.get_model('main', 'InventorySource') + ct = 0 for cls in (InventoryUpdate, InventorySource): for obj in cls.objects.iterator(): cred = obj.credentials.first() if cred: + ct += 1 + logger.debug('Reverse migrating credential %s for %s %s', cred.id, cls, obj.id) obj.credential = cred obj.save() + if ct: + logger.info('Finished reverse migrating %s inventory source credentials from multi-cred', ct) diff --git a/awx/main/signals.py b/awx/main/signals.py index 11d192e6e9..4bf8e38c8b 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -6,6 +6,7 @@ import contextlib import logging import threading import json +import sys # Django from django.conf import settings @@ -31,7 +32,7 @@ from awx.main.models import * # noqa from django.contrib.sessions.models import Session from awx.api.serializers import * # noqa from awx.main.constants import TOKEN_CENSOR -from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore +from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore, get_current_apps from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates from awx.main.tasks import update_inventory_computed_fields from awx.main.fields import ( @@ -51,6 +52,13 @@ logger = logging.getLogger('awx.main.signals') # when a Host-Group or Group-Group relationship is updated, or when a Job is deleted +def get_activity_stream_class(): + if 'migrate' in sys.argv: + return get_current_apps().get_model('main', 'ActivityStream') + else: + return ActivityStream + + def get_current_user_or_none(): u = get_current_user() if not isinstance(u, User): @@ -418,7 +426,7 @@ def activity_stream_create(sender, instance, created, **kwargs): changes['extra_vars'] = instance.display_extra_vars() if type(instance) == OAuth2AccessToken: changes['token'] = TOKEN_CENSOR - activity_entry = ActivityStream( + activity_entry = get_activity_stream_class()( operation='create', object1=object1, changes=json.dumps(changes), @@ -428,7 +436,7 @@ def activity_stream_create(sender, instance, created, **kwargs): # we don't really use them anyway. if instance._meta.model_name != 'setting': # Is not conf.Setting instance activity_entry.save() - getattr(activity_entry, object1).add(instance) + getattr(activity_entry, object1).add(instance.pk) else: activity_entry.setting = conf_to_dict(instance) activity_entry.save() @@ -452,14 +460,14 @@ def activity_stream_update(sender, instance, **kwargs): if getattr(_type, '_deferred', False): return object1 = camelcase_to_underscore(instance.__class__.__name__) - activity_entry = ActivityStream( + activity_entry = get_activity_stream_class()( operation='update', object1=object1, changes=json.dumps(changes), actor=get_current_user_or_none()) if instance._meta.model_name != 'setting': # Is not conf.Setting instance activity_entry.save() - getattr(activity_entry, object1).add(instance) + getattr(activity_entry, object1).add(instance.pk) else: activity_entry.setting = conf_to_dict(instance) activity_entry.save() @@ -485,7 +493,7 @@ def activity_stream_delete(sender, instance, **kwargs): object1 = camelcase_to_underscore(instance.__class__.__name__) if type(instance) == OAuth2AccessToken: changes['token'] = TOKEN_CENSOR - activity_entry = ActivityStream( + activity_entry = get_activity_stream_class()( operation='delete', changes=json.dumps(changes), object1=object1, @@ -532,7 +540,7 @@ def activity_stream_associate(sender, instance, **kwargs): continue if isinstance(obj1, SystemJob) or isinstance(obj2_actual, SystemJob): continue - activity_entry = ActivityStream( + activity_entry = get_activity_stream_class()( changes=json.dumps(dict(object1=object1, object1_pk=obj1.pk, object2=object2, @@ -545,8 +553,8 @@ def activity_stream_associate(sender, instance, **kwargs): object_relationship_type=obj_rel, actor=get_current_user_or_none()) activity_entry.save() - getattr(activity_entry, object1).add(obj1) - getattr(activity_entry, object2).add(obj2_actual) + getattr(activity_entry, object1).add(obj1.pk) + getattr(activity_entry, object2).add(obj2_actual.pk) # Record the role for RBAC changes if 'role' in kwargs: From e720fe5dd07845d1493d4ac28f88f531684ec2c2 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Tue, 22 May 2018 18:09:29 -0400 Subject: [PATCH 106/762] decide the node a job will run early * Deciding the Instance that a Job runs on at celery task run-time makes it hard to evenly distribute tasks among Instnaces. Instead, the task manager will look at the world of running jobs and choose an instance node to run on; applying a deterministic job distribution algo. --- awx/main/models/ha.py | 23 ++++++++++++ awx/main/models/unified_jobs.py | 2 +- awx/main/scheduler/task_manager.py | 57 ++++++++++++++++++++++-------- awx/main/tasks.py | 4 ++- 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 42e8117061..f158582871 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -92,6 +92,10 @@ class Instance(BaseModel): return sum(x.task_impact for x in UnifiedJob.objects.filter(execution_node=self.hostname, status__in=('running', 'waiting'))) + @property + def remaining_capacity(self): + return self.capacity - self.consumed_capacity + @property def role(self): # NOTE: TODO: Likely to repurpose this once standalone ramparts are a thing @@ -187,6 +191,25 @@ class InstanceGroup(BaseModel, RelatedJobsMixin): validate_queuename(self.name) return self.name + def fit_task_to_most_remaining_capacity_instance(self, task): + instance_most_capacity = None + for i in self.instances.order_by('hostname'): + if i.remaining_capacity >= task.task_impact and \ + (instance_most_capacity is None or + i.remaining_capacity > instance_most_capacity.remaining_capacity): + instance_most_capacity = i + return instance_most_capacity + + def find_largest_idle_instance(self): + largest_instance = None + for i in self.instances.order_by('hostname'): + if i.jobs_running == 0: + if largest_instance is None: + largest_instance = i + elif i.capacity > largest_instance.capacity: + largest_instance = i + return largest_instance + class TowerScheduleState(SingletonModel): schedule_last_run = models.DateTimeField(auto_now_add=True) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index fd3d6f4082..750472323b 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1228,9 +1228,9 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique raise RuntimeError("Expected celery_task_id to be set on model.") kwargs['task_id'] = self.celery_task_id task_class = self._get_task_class() + args = [self.pk] from awx.main.models.ha import InstanceGroup ig = InstanceGroup.objects.get(name=queue) - args = [self.pk] if ig.controller_id: if self.supports_isolation(): # case of jobs and ad hoc commands isolated_instance = ig.instances.order_by('-capacity').first() diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 810fbafdac..1408601b79 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -234,7 +234,7 @@ class TaskManager(): def get_dependent_jobs_for_inv_and_proj_update(self, job_obj): return [{'type': j.model_to_str(), 'id': j.id} for j in job_obj.dependent_jobs.all()] - def start_task(self, task, rampart_group, dependent_tasks=None): + def start_task(self, task, rampart_group, dependent_tasks=None, instance=None): from awx.main.tasks import handle_work_error, handle_work_success dependent_tasks = dependent_tasks or [] @@ -269,7 +269,11 @@ class TaskManager(): task.log_format, task.instance_group_id, rampart_group.controller_id) else: task.instance_group = rampart_group - logger.info('Submitting %s to instance group %s.', task.log_format, task.instance_group_id) + if instance is not None: + task.execution_node = instance.hostname + logger.debug(six.text_type("Dependent {} is blocked from running").format(task.log_format)) + logger.info(six.text_type('Submitting {} to <{},{}>.').format( + task.log_format, task.instance_group_id, task.execution_node)) with disable_activity_stream(): task.celery_task_id = str(uuid.uuid4()) task.save() @@ -280,8 +284,8 @@ class TaskManager(): def post_commit(): task.websocket_emit_status(task.status) if task.status != 'failed': - if rampart_group is not None: - actual_queue=rampart_group.name + if instance is not None: + actual_queue=instance.hostname else: actual_queue=settings.CELERY_DEFAULT_QUEUE task.start_celery_task(opts, error_callback=error_handler, success_callback=success_handler, queue=actual_queue) @@ -433,17 +437,32 @@ class TaskManager(): continue preferred_instance_groups = task.preferred_instance_groups found_acceptable_queue = False + idle_instance_that_fits = None for rampart_group in preferred_instance_groups: + if idle_instance_that_fits is None: + idle_instance_that_fits = rampart_group.find_largest_idle_instance() if self.get_remaining_capacity(rampart_group.name) <= 0: logger.debug(six.text_type("Skipping group {} capacity <= 0").format(rampart_group.name)) continue - if not self.would_exceed_capacity(task, rampart_group.name): - logger.debug(six.text_type("Starting dependent {} in group {}").format(task.log_format, rampart_group.name)) + + execution_instance = rampart_group.fit_task_to_most_remaining_capacity_instance(task) + if execution_instance: + logger.debug(six.text_type("Starting dependent {} in group {} instance {}").format( + task.log_format, rampart_group.name, execution_instance.hostname)) + elif not execution_instance and idle_instance_that_fits: + execution_instance = idle_instance_that_fits + logger.debug(six.text_type("Starting dependent {} in group {} on idle instance {}").format( + task.log_format, rampart_group.name, execution_instance.hostname)) + if execution_instance: self.graph[rampart_group.name]['graph'].add_job(task) tasks_to_fail = filter(lambda t: t != task, dependency_tasks) tasks_to_fail += [dependent_task] - self.start_task(task, rampart_group, tasks_to_fail) + self.start_task(task, rampart_group, tasks_to_fail, execution_instance) found_acceptable_queue = True + break + else: + logger.debug(six.text_type("No instance available in group {} to run job {} w/ capacity requirement {}").format( + rampart_group.name, task.log_format, task.task_impact)) if not found_acceptable_queue: logger.debug(six.text_type("Dependent {} couldn't be scheduled on graph, waiting for next cycle").format(task.log_format)) @@ -455,25 +474,35 @@ class TaskManager(): continue preferred_instance_groups = task.preferred_instance_groups found_acceptable_queue = False + idle_instance_that_fits = None if isinstance(task, WorkflowJob): - self.start_task(task, None, task.get_jobs_fail_chain()) + self.start_task(task, None, task.get_jobs_fail_chain(), None) continue for rampart_group in preferred_instance_groups: + if idle_instance_that_fits is None: + idle_instance_that_fits = rampart_group.find_largest_idle_instance() remaining_capacity = self.get_remaining_capacity(rampart_group.name) if remaining_capacity <= 0: logger.debug(six.text_type("Skipping group {}, remaining_capacity {} <= 0").format( rampart_group.name, remaining_capacity)) continue - if not self.would_exceed_capacity(task, rampart_group.name): - logger.debug(six.text_type("Starting {} in group {} (remaining_capacity={})").format( - task.log_format, rampart_group.name, remaining_capacity)) + + execution_instance = rampart_group.fit_task_to_most_remaining_capacity_instance(task) + if execution_instance: + logger.debug(six.text_type("Starting {} in group {} instance {} (remaining_capacity={})").format( + task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity)) + elif not execution_instance and idle_instance_that_fits: + execution_instance = idle_instance_that_fits + logger.debug(six.text_type("Starting {} in group {} instance {} (remaining_capacity={})").format( + task.log_format, rampart_group.name, execution_instance.hostname, remaining_capacity)) + if execution_instance: self.graph[rampart_group.name]['graph'].add_job(task) - self.start_task(task, rampart_group, task.get_jobs_fail_chain()) + self.start_task(task, rampart_group, task.get_jobs_fail_chain(), execution_instance) found_acceptable_queue = True break else: - logger.debug(six.text_type("Not enough capacity to run {} on {} (remaining_capacity={})").format( - task.log_format, rampart_group.name, remaining_capacity)) + logger.debug(six.text_type("No instance available in group {} to run job {} w/ capacity requirement {}").format( + rampart_group.name, task.log_format, task.task_impact)) if not found_acceptable_queue: logger.debug(six.text_type("{} couldn't be scheduled on graph, waiting for next cycle").format(task.log_format)) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 613caa6320..d1a06d2af7 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -872,10 +872,12 @@ class BaseTask(Task): ''' Run the job/task and capture its output. ''' + ''' execution_node = settings.CLUSTER_HOST_ID if isolated_host is not None: execution_node = isolated_host - instance = self.update_model(pk, status='running', execution_node=execution_node, + ''' + instance = self.update_model(pk, status='running', start_args='') # blank field to remove encrypted passwords instance.websocket_emit_status("running") From 8d352a4edfb54b46b8c441e2b0aad643ebd9103e Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 25 May 2018 14:53:24 -0400 Subject: [PATCH 107/762] conform isolated system to new early node choice * Randomly chose an instance in the controller instance group for which to control the isolated node run. Note the chosen instance via a job controller_node field --- awx/api/serializers.py | 7 ++--- .../0040_v330_unifiedjob_controller_node.py | 20 ++++++++++++++ awx/main/models/unified_jobs.py | 26 +++++++++++-------- awx/main/scheduler/task_manager.py | 22 +++++++++++----- awx/main/tasks.py | 9 +++---- 5 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 awx/main/migrations/0040_v330_unifiedjob_controller_node.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e7d09f8dde..2376956da5 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -702,7 +702,8 @@ class UnifiedJobSerializer(BaseSerializer): model = UnifiedJob fields = ('*', 'unified_job_template', 'launch_type', 'status', 'failed', 'started', 'finished', 'elapsed', 'job_args', - 'job_cwd', 'job_env', 'job_explanation', 'execution_node', + 'job_cwd', 'job_env', 'job_explanation', + 'execution_node', 'controller_node', 'result_traceback', 'event_processing_finished') extra_kwargs = { 'unified_job_template': { @@ -3434,7 +3435,7 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer): class Meta: model = WorkflowJob fields = ('*', 'workflow_job_template', 'extra_vars', 'allow_simultaneous', - '-execution_node', '-event_processing_finished',) + '-execution_node', '-event_processing_finished', '-controller_node',) def get_related(self, obj): res = super(WorkflowJobSerializer, self).get_related(obj) @@ -3463,7 +3464,7 @@ class WorkflowJobSerializer(LabelsListMixin, UnifiedJobSerializer): class WorkflowJobListSerializer(WorkflowJobSerializer, UnifiedJobListSerializer): class Meta: - fields = ('*', '-execution_node',) + fields = ('*', '-execution_node', '-controller_node',) class WorkflowJobCancelSerializer(WorkflowJobSerializer): diff --git a/awx/main/migrations/0040_v330_unifiedjob_controller_node.py b/awx/main/migrations/0040_v330_unifiedjob_controller_node.py new file mode 100644 index 0000000000..8b127dd06d --- /dev/null +++ b/awx/main/migrations/0040_v330_unifiedjob_controller_node.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-05-25 18:58 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0039_v330_custom_venv_help_text'), + ] + + operations = [ + migrations.AddField( + model_name='unifiedjob', + name='controller_node', + field=models.TextField(blank=True, default=b'', editable=False, help_text='The instance that managed the isolated execution environment.'), + ), + ] diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 750472323b..62c5fc4e42 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -507,7 +507,8 @@ class StdoutMaxBytesExceeded(Exception): self.supported = supported -class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique, UnifiedJobTypeStringMixin, TaskManagerUnifiedJobMixin): +class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique, + UnifiedJobTypeStringMixin, TaskManagerUnifiedJobMixin): ''' Concrete base class for unified job run by the task engine. ''' @@ -571,6 +572,12 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique editable=False, help_text=_("The node the job executed on."), ) + controller_node = models.TextField( + blank=True, + default='', + editable=False, + help_text=_("The instance that managed the isolated execution environment."), + ) notifications = models.ManyToManyField( 'Notification', editable=False, @@ -1228,17 +1235,8 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique raise RuntimeError("Expected celery_task_id to be set on model.") kwargs['task_id'] = self.celery_task_id task_class = self._get_task_class() - args = [self.pk] - from awx.main.models.ha import InstanceGroup - ig = InstanceGroup.objects.get(name=queue) - if ig.controller_id: - if self.supports_isolation(): # case of jobs and ad hoc commands - isolated_instance = ig.instances.order_by('-capacity').first() - args.append(isolated_instance.hostname) - else: # proj & inv updates, system jobs run on controller - queue = ig.controller.name kwargs['queue'] = queue - task_class().apply_async(args, opts, **kwargs) + task_class().apply_async([self.pk], opts, **kwargs) def start(self, error_callback, success_callback, **kwargs): ''' @@ -1400,3 +1398,9 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique r['{}_schedule_id'.format(name)] = self.schedule.pk r['{}_schedule_name'.format(name)] = self.schedule.name return r + + def get_celery_queue_name(self): + return self.controller_node or self.execution_node or settings.CELERY_DEFAULT_QUEUE + + def get_isolated_execution_node_name(self): + return self.execution_node if self.controller_node else None diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 1408601b79..9ad7a79652 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -7,6 +7,7 @@ import logging import uuid import json import six +import random from sets import Set # Django @@ -265,8 +266,16 @@ class TaskManager(): elif not task.supports_isolation() and rampart_group.controller_id: # non-Ansible jobs on isolated instances run on controller task.instance_group = rampart_group.controller - logger.info('Submitting isolated %s to queue %s via %s.', - task.log_format, task.instance_group_id, rampart_group.controller_id) + task.execution_node = random.choice(list(rampart_group.controller.instances.all().values_list('hostname', flat=True))) + logger.info(six.text_type('Submitting isolated {} to queue {}.').format( + task.log_format, task.instance_group.name, task.execution_node)) + elif task.supports_isolation() and rampart_group.controller_id: + # TODO: Select from only online nodes in the controller node + task.instance_group = rampart_group + task.execution_node = instance.hostname + task.controller_node = random.choice(list(rampart_group.controller.instances.all().values_list('hostname', flat=True))) + logger.info(six.text_type('Submitting isolated {} to queue {} controlled by {}.').format( + task.log_format, task.execution_node, task.controller_node)) else: task.instance_group = rampart_group if instance is not None: @@ -284,11 +293,10 @@ class TaskManager(): def post_commit(): task.websocket_emit_status(task.status) if task.status != 'failed': - if instance is not None: - actual_queue=instance.hostname - else: - actual_queue=settings.CELERY_DEFAULT_QUEUE - task.start_celery_task(opts, error_callback=error_handler, success_callback=success_handler, queue=actual_queue) + task.start_celery_task(opts, + error_callback=error_handler, + success_callback=success_handler, + queue=task.get_celery_queue_name()) connection.on_commit(post_commit) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index d1a06d2af7..e9ff60b0da 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -868,15 +868,10 @@ class BaseTask(Task): ''' @with_path_cleanup - def run(self, pk, isolated_host=None, **kwargs): + def run(self, pk, **kwargs): ''' Run the job/task and capture its output. ''' - ''' - execution_node = settings.CLUSTER_HOST_ID - if isolated_host is not None: - execution_node = isolated_host - ''' instance = self.update_model(pk, status='running', start_args='') # blank field to remove encrypted passwords @@ -886,6 +881,8 @@ class BaseTask(Task): extra_update_fields = {} event_ct = 0 stdout_handle = None + isolated_host = instance.get_isolated_execution_node_name() + try: kwargs['isolated'] = isolated_host is not None self.pre_run_hook(instance, **kwargs) From 9863fe71dcaa987ed28d70ac6873ece058344ab3 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 31 May 2018 09:37:38 -0400 Subject: [PATCH 108/762] do not require privileged iso container * The init call w/ privileged was causing my laptop to wig out. This changeset still functions w/ out requiring privileged access. --- tools/docker-isolated-override.yml | 4 +++- tools/docker-isolated/Dockerfile | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/docker-isolated-override.yml b/tools/docker-isolated-override.yml index 9f9d473def..55ff09e97d 100644 --- a/tools/docker-isolated-override.yml +++ b/tools/docker-isolated-override.yml @@ -14,4 +14,6 @@ services: - "../awx/main/expect:/awx_devel" - "../awx/lib:/awx_lib" - "/sys/fs/cgroup:/sys/fs/cgroup:ro" - privileged: true + tmpfs: + - "/tmp:exec" + - "/run" diff --git a/tools/docker-isolated/Dockerfile b/tools/docker-isolated/Dockerfile index 69af7526cc..53a8b67481 100644 --- a/tools/docker-isolated/Dockerfile +++ b/tools/docker-isolated/Dockerfile @@ -27,4 +27,7 @@ RUN ssh-keygen -A RUN mkdir -p /root/.ssh RUN touch /root/.ssh/authorized_keys +STOPSIGNAL SIGRTMIN+3 + + CMD ["/usr/sbin/init"] From 9d732cdbdf01cb94320873f8a01f309f9eb95767 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Thu, 31 May 2018 13:48:47 -0400 Subject: [PATCH 109/762] update unit and functional tests --- .../functional/models/test_unified_job.py | 4 +- .../task_management/test_rampart_groups.py | 21 +++++---- .../task_management/test_scheduler.py | 43 +++++++++++-------- awx/main/tests/unit/test_tasks.py | 17 +++++--- 4 files changed, 51 insertions(+), 34 deletions(-) diff --git a/awx/main/tests/functional/models/test_unified_job.py b/awx/main/tests/functional/models/test_unified_job.py index 05d5aa318a..0ee56349fe 100644 --- a/awx/main/tests/functional/models/test_unified_job.py +++ b/awx/main/tests/functional/models/test_unified_job.py @@ -88,7 +88,7 @@ class TestIsolatedRuns: with mock.patch.object(job, '_get_task_class') as task_class: task_class.return_value = MockTaskClass job.start_celery_task([], error_callback, success_callback, 'thepentagon') - mock_async.assert_called_with([job.id, 'iso2'], [], + mock_async.assert_called_with([job.id], [], link_error=error_callback, link=success_callback, queue='thepentagon', @@ -100,7 +100,7 @@ class TestIsolatedRuns: with mock.patch.object(job, '_get_task_class') as task_class: task_class.return_value = MockTaskClass job.start_celery_task([], error_callback, success_callback, 'thepentagon') - mock_async.assert_called_with([job.id, 'iso1'], [], + mock_async.assert_called_with([job.id], [], link_error=error_callback, link=success_callback, queue='thepentagon', diff --git a/awx/main/tests/functional/task_management/test_rampart_groups.py b/awx/main/tests/functional/task_management/test_rampart_groups.py index ce79b78003..0eae5b2805 100644 --- a/awx/main/tests/functional/task_management/test_rampart_groups.py +++ b/awx/main/tests/functional/task_management/test_rampart_groups.py @@ -31,7 +31,7 @@ def test_multi_group_basic_job_launch(instance_factory, default_instance_group, mock_task_impact.return_value = 500 with mocker.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_has_calls([mock.call(j1, ig1, []), mock.call(j2, ig2, [])]) + TaskManager.start_task.assert_has_calls([mock.call(j1, ig1, [], i1), mock.call(j2, ig2, [], i2)]) @@ -65,15 +65,18 @@ def test_multi_group_with_shared_dependency(instance_factory, default_instance_g with mocker.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() pu = p.project_updates.first() - TaskManager.start_task.assert_called_once_with(pu, default_instance_group, [j1]) + TaskManager.start_task.assert_called_once_with(pu, + default_instance_group, + [j1], + default_instance_group.instances.all()[0]) pu.finished = pu.created + timedelta(seconds=1) pu.status = "successful" pu.save() with mock.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_any_call(j1, ig1, []) - TaskManager.start_task.assert_any_call(j2, ig2, []) + TaskManager.start_task.assert_any_call(j1, ig1, [], i1) + TaskManager.start_task.assert_any_call(j2, ig2, [], i2) assert TaskManager.start_task.call_count == 2 @@ -85,7 +88,7 @@ def test_workflow_job_no_instancegroup(workflow_job_template_factory, default_in wfj.save() with mocker.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(wfj, None, []) + TaskManager.start_task.assert_called_once_with(wfj, None, [], None) assert wfj.instance_group is None @@ -131,8 +134,9 @@ def test_overcapacity_blocking_other_groups_unaffected(instance_factory, default mock_task_impact.return_value = 500 with mock.patch.object(TaskManager, "start_task", wraps=tm.start_task) as mock_job: tm.schedule() - mock_job.assert_has_calls([mock.call(j1, ig1, []), mock.call(j1_1, ig1, []), - mock.call(j2, ig2, [])]) + mock_job.assert_has_calls([mock.call(j1, ig1, [], i1), + mock.call(j1_1, ig1, [], i1), + mock.call(j2, ig2, [], i2)]) assert mock_job.call_count == 3 @@ -163,7 +167,8 @@ def test_failover_group_run(instance_factory, default_instance_group, mocker, mock_task_impact.return_value = 500 with mock.patch.object(TaskManager, "start_task", wraps=tm.start_task) as mock_job: tm.schedule() - mock_job.assert_has_calls([mock.call(j1, ig1, []), mock.call(j1_1, ig2, [])]) + mock_job.assert_has_calls([mock.call(j1, ig1, [], i1), + mock.call(j1_1, ig2, [], i2)]) assert mock_job.call_count == 2 diff --git a/awx/main/tests/functional/task_management/test_scheduler.py b/awx/main/tests/functional/task_management/test_scheduler.py index 82625533c7..813adff7cf 100644 --- a/awx/main/tests/functional/task_management/test_scheduler.py +++ b/awx/main/tests/functional/task_management/test_scheduler.py @@ -18,6 +18,7 @@ from awx.main.models.notifications import JobNotificationMixin @pytest.mark.django_db def test_single_job_scheduler_launch(default_instance_group, job_template_factory, mocker): + instance = default_instance_group.instances.all()[0] objects = job_template_factory('jt', organization='org1', project='proj', inventory='inv', credential='cred', jobs=["job_should_start"]) @@ -26,11 +27,12 @@ def test_single_job_scheduler_launch(default_instance_group, job_template_factor j.save() with mocker.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(j, default_instance_group, []) + TaskManager.start_task.assert_called_once_with(j, default_instance_group, [], instance) @pytest.mark.django_db def test_single_jt_multi_job_launch_blocks_last(default_instance_group, job_template_factory, mocker): + instance = default_instance_group.instances.all()[0] objects = job_template_factory('jt', organization='org1', project='proj', inventory='inv', credential='cred', jobs=["job_should_start", "job_should_not_start"]) @@ -42,16 +44,17 @@ def test_single_jt_multi_job_launch_blocks_last(default_instance_group, job_temp j2.save() with mock.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(j1, default_instance_group, []) + TaskManager.start_task.assert_called_once_with(j1, default_instance_group, [], instance) j1.status = "successful" j1.save() with mocker.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(j2, default_instance_group, []) + TaskManager.start_task.assert_called_once_with(j2, default_instance_group, [], instance) @pytest.mark.django_db def test_single_jt_multi_job_launch_allow_simul_allowed(default_instance_group, job_template_factory, mocker): + instance = default_instance_group.instances.all()[0] objects = job_template_factory('jt', organization='org1', project='proj', inventory='inv', credential='cred', jobs=["job_should_start", "job_should_not_start"]) @@ -68,12 +71,13 @@ def test_single_jt_multi_job_launch_allow_simul_allowed(default_instance_group, j2.save() with mock.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_has_calls([mock.call(j1, default_instance_group, []), - mock.call(j2, default_instance_group, [])]) + TaskManager.start_task.assert_has_calls([mock.call(j1, default_instance_group, [], instance), + mock.call(j2, default_instance_group, [], instance)]) @pytest.mark.django_db def test_multi_jt_capacity_blocking(default_instance_group, job_template_factory, mocker): + instance = default_instance_group.instances.all()[0] objects1 = job_template_factory('jt1', organization='org1', project='proj1', inventory='inv1', credential='cred1', jobs=["job_should_start"]) @@ -91,20 +95,20 @@ def test_multi_jt_capacity_blocking(default_instance_group, job_template_factory mock_task_impact.return_value = 500 with mock.patch.object(TaskManager, "start_task", wraps=tm.start_task) as mock_job: tm.schedule() - mock_job.assert_called_once_with(j1, default_instance_group, []) + mock_job.assert_called_once_with(j1, default_instance_group, [], instance) j1.status = "successful" j1.save() with mock.patch.object(TaskManager, "start_task", wraps=tm.start_task) as mock_job: tm.schedule() - mock_job.assert_called_once_with(j2, default_instance_group, []) - - + mock_job.assert_called_once_with(j2, default_instance_group, [], instance) + @pytest.mark.django_db def test_single_job_dependencies_project_launch(default_instance_group, job_template_factory, mocker): objects = job_template_factory('jt', organization='org1', project='proj', inventory='inv', credential='cred', jobs=["job_should_start"]) + instance = default_instance_group.instances.all()[0] j = objects.jobs["job_should_start"] j.status = 'pending' j.save() @@ -121,12 +125,12 @@ def test_single_job_dependencies_project_launch(default_instance_group, job_temp mock_pu.assert_called_once_with(j) pu = [x for x in p.project_updates.all()] assert len(pu) == 1 - TaskManager.start_task.assert_called_once_with(pu[0], default_instance_group, [j]) + TaskManager.start_task.assert_called_once_with(pu[0], default_instance_group, [j], instance) pu[0].status = "successful" pu[0].save() with mock.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(j, default_instance_group, []) + TaskManager.start_task.assert_called_once_with(j, default_instance_group, [], instance) @pytest.mark.django_db @@ -134,6 +138,7 @@ def test_single_job_dependencies_inventory_update_launch(default_instance_group, objects = job_template_factory('jt', organization='org1', project='proj', inventory='inv', credential='cred', jobs=["job_should_start"]) + instance = default_instance_group.instances.all()[0] j = objects.jobs["job_should_start"] j.status = 'pending' j.save() @@ -151,12 +156,12 @@ def test_single_job_dependencies_inventory_update_launch(default_instance_group, mock_iu.assert_called_once_with(j, ii) iu = [x for x in ii.inventory_updates.all()] assert len(iu) == 1 - TaskManager.start_task.assert_called_once_with(iu[0], default_instance_group, [j]) + TaskManager.start_task.assert_called_once_with(iu[0], default_instance_group, [j], instance) iu[0].status = "successful" iu[0].save() with mock.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(j, default_instance_group, []) + TaskManager.start_task.assert_called_once_with(j, default_instance_group, [], instance) @pytest.mark.django_db @@ -164,6 +169,7 @@ def test_job_dependency_with_already_updated(default_instance_group, job_templat objects = job_template_factory('jt', organization='org1', project='proj', inventory='inv', credential='cred', jobs=["job_should_start"]) + instance = default_instance_group.instances.all()[0] j = objects.jobs["job_should_start"] j.status = 'pending' j.save() @@ -185,11 +191,12 @@ def test_job_dependency_with_already_updated(default_instance_group, job_templat mock_iu.assert_not_called() with mock.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(j, default_instance_group, []) + TaskManager.start_task.assert_called_once_with(j, default_instance_group, [], instance) @pytest.mark.django_db def test_shared_dependencies_launch(default_instance_group, job_template_factory, mocker, inventory_source_factory): + instance = default_instance_group.instances.all()[0] objects = job_template_factory('jt', organization='org1', project='proj', inventory='inv', credential='cred', jobs=["first_job", "second_job"]) @@ -218,8 +225,8 @@ def test_shared_dependencies_launch(default_instance_group, job_template_factory TaskManager().schedule() pu = p.project_updates.first() iu = ii.inventory_updates.first() - TaskManager.start_task.assert_has_calls([mock.call(pu, default_instance_group, [iu, j1]), - mock.call(iu, default_instance_group, [pu, j1])]) + TaskManager.start_task.assert_has_calls([mock.call(pu, default_instance_group, [iu, j1], instance), + mock.call(iu, default_instance_group, [pu, j1], instance)]) pu.status = "successful" pu.finished = pu.created + timedelta(seconds=1) pu.save() @@ -228,12 +235,12 @@ def test_shared_dependencies_launch(default_instance_group, job_template_factory iu.save() with mock.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(j1, default_instance_group, []) + TaskManager.start_task.assert_called_once_with(j1, default_instance_group, [], instance) j1.status = "successful" j1.save() with mock.patch("awx.main.scheduler.TaskManager.start_task"): TaskManager().schedule() - TaskManager.start_task.assert_called_once_with(j2, default_instance_group, []) + TaskManager.start_task.assert_called_once_with(j2, default_instance_group, [], instance) pu = [x for x in p.project_updates.all()] iu = [x for x in ii.inventory_updates.all()] assert len(pu) == 1 diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 07a111654b..0920767c57 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -196,7 +196,7 @@ def parse_extra_vars(args): return extra_vars -class TestJobExecution: +class TestJobExecution(object): """ For job runs, test that `ansible-playbook` is invoked with the proper arguments, environment variables, and pexpect passwords for a variety of @@ -440,7 +440,7 @@ class TestGenericRun(TestJobExecution): with pytest.raises(Exception): self.task.run(self.pk) for c in [ - mock.call(self.pk, execution_node=settings.CLUSTER_HOST_ID, status='running', start_args=''), + mock.call(self.pk, status='running', start_args=''), mock.call(self.pk, status='canceled') ]: assert c in self.task.update_model.call_args_list @@ -626,7 +626,12 @@ class TestAdhocRun(TestJobExecution): class TestIsolatedExecution(TestJobExecution): - REMOTE_HOST = 'some-isolated-host' + ISOLATED_HOST = 'some-isolated-host' + + def get_instance(self): + instance = super(TestIsolatedExecution, self).get_instance() + instance.get_isolated_execution_node_name = mock.Mock(return_value=self.ISOLATED_HOST) + return instance def test_with_ssh_credentials(self): ssh = CredentialType.defaults['ssh']() @@ -659,12 +664,12 @@ class TestIsolatedExecution(TestJobExecution): f.write(data) return ('successful', 0) self.run_pexpect.side_effect = _mock_job_artifacts - self.task.run(self.pk, self.REMOTE_HOST) + self.task.run(self.pk) playbook_run = self.run_pexpect.call_args_list[0][0] assert ' '.join(playbook_run[0]).startswith(' '.join([ 'ansible-playbook', 'run_isolated.yml', '-u', settings.AWX_ISOLATED_USERNAME, - '-T', str(settings.AWX_ISOLATED_CONNECTION_TIMEOUT), '-i', self.REMOTE_HOST + ',', + '-T', str(settings.AWX_ISOLATED_CONNECTION_TIMEOUT), '-i', self.ISOLATED_HOST + ',', '-e', ])) extra_vars = playbook_run[0][playbook_run[0].index('-e') + 1] @@ -705,7 +710,7 @@ class TestIsolatedExecution(TestJobExecution): with mock.patch('requests.get') as mock_get: mock_get.return_value = mock.Mock(content=inventory) with pytest.raises(Exception): - self.task.run(self.pk, self.REMOTE_HOST) + self.task.run(self.pk, self.ISOLATED_HOST) class TestJobCredentials(TestJobExecution): From b94cf379f61f781ac496a4484cf5f59ae53c4615 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 1 Jun 2018 15:36:24 -0400 Subject: [PATCH 110/762] do not choose offline instances --- awx/main/models/ha.py | 13 +++- awx/main/models/unified_jobs.py | 4 +- awx/main/scheduler/task_manager.py | 4 +- awx/main/tests/unit/models/test_ha.py | 85 +++++++++++++++++++++++++++ awx/main/tests/unit/test_tasks.py | 4 +- 5 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 awx/main/tests/unit/models/test_ha.py diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index f158582871..16589c0a77 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -1,6 +1,8 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +import six +import random from decimal import Decimal from django.core.exceptions import ValidationError @@ -11,7 +13,6 @@ from django.utils.translation import ugettext_lazy as _ from django.conf import settings from django.utils.timezone import now, timedelta -import six from solo.models import SingletonModel from awx import __version__ as awx_application_version @@ -193,7 +194,7 @@ class InstanceGroup(BaseModel, RelatedJobsMixin): def fit_task_to_most_remaining_capacity_instance(self, task): instance_most_capacity = None - for i in self.instances.order_by('hostname'): + for i in self.instances.filter(capacity__gt=0).order_by('hostname'): if i.remaining_capacity >= task.task_impact and \ (instance_most_capacity is None or i.remaining_capacity > instance_most_capacity.remaining_capacity): @@ -202,7 +203,7 @@ class InstanceGroup(BaseModel, RelatedJobsMixin): def find_largest_idle_instance(self): largest_instance = None - for i in self.instances.order_by('hostname'): + for i in self.instances.filter(capacity__gt=0).order_by('hostname'): if i.jobs_running == 0: if largest_instance is None: largest_instance = i @@ -210,6 +211,12 @@ class InstanceGroup(BaseModel, RelatedJobsMixin): largest_instance = i return largest_instance + def choose_online_controller_node(self): + return random.choice(list(self.controller + .instances + .filter(capacity__gt=0) + .values_list('hostname', flat=True))) + class TowerScheduleState(SingletonModel): schedule_last_run = models.DateTimeField(auto_now_add=True) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 62c5fc4e42..1be11ebcca 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -1402,5 +1402,5 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique def get_celery_queue_name(self): return self.controller_node or self.execution_node or settings.CELERY_DEFAULT_QUEUE - def get_isolated_execution_node_name(self): - return self.execution_node if self.controller_node else None + def is_isolated(self): + return bool(self.controller_node) diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 9ad7a79652..198f0e3652 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -270,17 +270,15 @@ class TaskManager(): logger.info(six.text_type('Submitting isolated {} to queue {}.').format( task.log_format, task.instance_group.name, task.execution_node)) elif task.supports_isolation() and rampart_group.controller_id: - # TODO: Select from only online nodes in the controller node task.instance_group = rampart_group task.execution_node = instance.hostname - task.controller_node = random.choice(list(rampart_group.controller.instances.all().values_list('hostname', flat=True))) + task.controller_node = rampart_group.choose_online_controller_node() logger.info(six.text_type('Submitting isolated {} to queue {} controlled by {}.').format( task.log_format, task.execution_node, task.controller_node)) else: task.instance_group = rampart_group if instance is not None: task.execution_node = instance.hostname - logger.debug(six.text_type("Dependent {} is blocked from running").format(task.log_format)) logger.info(six.text_type('Submitting {} to <{},{}>.').format( task.log_format, task.instance_group_id, task.execution_node)) with disable_activity_stream(): diff --git a/awx/main/tests/unit/models/test_ha.py b/awx/main/tests/unit/models/test_ha.py new file mode 100644 index 0000000000..4ceb83c77a --- /dev/null +++ b/awx/main/tests/unit/models/test_ha.py @@ -0,0 +1,85 @@ +import pytest +import mock +from mock import Mock + +from awx.main.models import ( + Job, + InstanceGroup, +) + + +def T(impact): + j = mock.Mock(Job()) + j.task_impact = impact + return j + + +def Is(param): + ''' + param: + [remaining_capacity1, remaining_capacity2, remaining_capacity3, ...] + [(jobs_running1, capacity1), (jobs_running2, capacity2), (jobs_running3, capacity3), ...] + ''' + + instances = [] + if isinstance(param[0], tuple): + for (jobs_running, capacity) in param: + inst = Mock() + inst.capacity = capacity + inst.jobs_running = jobs_running + instances.append(inst) + else: + for i in param: + inst = Mock() + inst.remaining_capacity = i + instances.append(inst) + return instances + + +class TestInstanceGroup(object): + @pytest.mark.parametrize('task,instances,instance_fit_index,reason', [ + (T(100), Is([100]), 0, "Only one, pick it"), + (T(100), Is([100, 100]), 0, "Two equally good fits, pick the first"), + (T(100), Is([50, 100]), 1, "First instance not as good as second instance"), + (T(100), Is([50, 0, 20, 100, 100, 100, 30, 20]), 3, "Pick Instance [3] as it is the first that the task fits in."), + (T(100), Is([50, 0, 20, 99, 11, 1, 5, 99]), None, "The task don't a fit, you must a quit!"), + ]) + def test_fit_task_to_most_remaining_capacity_instance(self, task, instances, instance_fit_index, reason): + with mock.patch.object(InstanceGroup, + 'instances', + Mock(spec_set=['filter'], + filter=lambda *args, **kargs: Mock(spec_set=['order_by'], + order_by=lambda x: instances))): + ig = InstanceGroup(id=10) + + if instance_fit_index is None: + assert ig.fit_task_to_most_remaining_capacity_instance(task) is None, reason + else: + assert ig.fit_task_to_most_remaining_capacity_instance(task) == \ + instances[instance_fit_index], reason + + + @pytest.mark.parametrize('instances,instance_fit_index,reason', [ + (Is([(0, 100)]), 0, "One idle instance, pick it"), + (Is([(1, 100)]), None, "One un-idle instance, pick nothing"), + (Is([(0, 100), (0, 200), (1, 500), (0, 700)]), 3, "Pick the largest idle instance"), + (Is([(0, 100), (0, 200), (1, 10000), (0, 700), (0, 699)]), 3, "Pick the largest idle instance"), + (Is([(0, 0)]), None, "One idle but down instance, don't pick it"), + ]) + def test_find_largest_idle_instance(self, instances, instance_fit_index, reason): + def filter_offline_instances(*args): + return filter(lambda i: i.capacity > 0, instances) + + with mock.patch.object(InstanceGroup, + 'instances', + Mock(spec_set=['filter'], + filter=lambda *args, **kargs: Mock(spec_set=['order_by'], + order_by=filter_offline_instances))): + ig = InstanceGroup(id=10) + + if instance_fit_index is None: + assert ig.find_largest_idle_instance() is None, reason + else: + assert ig.find_largest_idle_instance() == \ + instances[instance_fit_index], reason + diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 0920767c57..ccb84c2fa1 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -627,10 +627,12 @@ class TestAdhocRun(TestJobExecution): class TestIsolatedExecution(TestJobExecution): ISOLATED_HOST = 'some-isolated-host' + ISOLATED_CONTROLLER_HOST = 'some-isolated-controller-host' def get_instance(self): instance = super(TestIsolatedExecution, self).get_instance() - instance.get_isolated_execution_node_name = mock.Mock(return_value=self.ISOLATED_HOST) + instance.controller_node = self.ISOLATED_CONTROLLER_HOST + instance.execution_node = self.ISOLATED_HOST return instance def test_with_ssh_credentials(self): From 7b0b4f562d33f362faf3252da3feb76ed9ae9001 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 4 Jun 2018 09:19:38 -0400 Subject: [PATCH 111/762] get isolated execution at the point its needed * Instead of passing around the isolated host that the task is to execute on; grab the isolated execution host from the instance further down the call stack. Without passing the isolated hostname around. --- awx/main/expect/isolated_manager.py | 6 ++---- awx/main/tasks.py | 12 +++++------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/awx/main/expect/isolated_manager.py b/awx/main/expect/isolated_manager.py index 71ce262fef..9b4ce1db6a 100644 --- a/awx/main/expect/isolated_manager.py +++ b/awx/main/expect/isolated_manager.py @@ -468,13 +468,11 @@ class IsolatedManager(object): return OutputEventFilter(job_event_callback) - def run(self, instance, host, private_data_dir, proot_temp_dir): + def run(self, instance, private_data_dir, proot_temp_dir): """ Run a job on an isolated host. :param instance: a `model.Job` instance - :param host: the hostname (or IP address) to run the - isolated job on :param private_data_dir: an absolute path on the local file system where job-specific data should be written (i.e., `/tmp/ansible_awx_xyz/`) @@ -486,7 +484,7 @@ class IsolatedManager(object): `ansible-playbook` run. """ self.instance = instance - self.host = host + self.host = instance.execution_node self.private_data_dir = private_data_dir self.proot_temp_dir = proot_temp_dir status, rc = self.dispatch() diff --git a/awx/main/tasks.py b/awx/main/tasks.py index e9ff60b0da..be10d3ef58 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -881,10 +881,8 @@ class BaseTask(Task): extra_update_fields = {} event_ct = 0 stdout_handle = None - isolated_host = instance.get_isolated_execution_node_name() try: - kwargs['isolated'] = isolated_host is not None self.pre_run_hook(instance, **kwargs) if instance.cancel_flag: instance = self.update_model(instance.pk, status='canceled') @@ -944,7 +942,7 @@ class BaseTask(Task): credential, env, safe_env, args, safe_args, kwargs['private_data_dir'] ) - if isolated_host is None: + if instance.is_isolated() is False: stdout_handle = self.get_stdout_handle(instance) else: stdout_handle = isolated_manager.IsolatedManager.get_stdout_handle( @@ -960,7 +958,7 @@ class BaseTask(Task): ssh_key_path = self.get_ssh_key_path(instance, **kwargs) # If we're executing on an isolated host, don't bother adding the # key to the agent in this environment - if ssh_key_path and isolated_host is None: + if ssh_key_path and instance.is_isolated() is False: ssh_auth_sock = os.path.join(kwargs['private_data_dir'], 'ssh_auth.sock') args = run.wrap_args_with_ssh_agent(args, ssh_key_path, ssh_auth_sock) safe_args = run.wrap_args_with_ssh_agent(safe_args, ssh_key_path, ssh_auth_sock) @@ -980,11 +978,11 @@ class BaseTask(Task): proot_cmd=getattr(settings, 'AWX_PROOT_CMD', 'bwrap'), ) instance = self.update_model(instance.pk, output_replacements=output_replacements) - if isolated_host: + if instance.is_isolated() is True: manager_instance = isolated_manager.IsolatedManager( args, cwd, env, stdout_handle, ssh_key_path, **_kw ) - status, rc = manager_instance.run(instance, isolated_host, + status, rc = manager_instance.run(instance, kwargs['private_data_dir'], kwargs.get('proot_temp_dir')) else: @@ -1335,7 +1333,7 @@ class RunJob(BaseTask): job_request_id = '' if self.request.id is None else self.request.id pu_ig = job.instance_group pu_en = job.execution_node - if kwargs['isolated']: + if job.is_isolated() is True: pu_ig = pu_ig.controller pu_en = settings.CLUSTER_HOST_ID local_project_sync = job.project.create_project_update( From d885d0fe220ef8e10c81dddb64e2b54885087e61 Mon Sep 17 00:00:00 2001 From: adamscmRH Date: Fri, 1 Jun 2018 16:17:15 -0400 Subject: [PATCH 112/762] updates oauth docs with quickstart --- docs/auth/oauth.md | 111 ++++++++++++++++++++++++++------------------- 1 file changed, 65 insertions(+), 46 deletions(-) diff --git a/docs/auth/oauth.md b/docs/auth/oauth.md index 799c25a503..3da2fa2c8f 100644 --- a/docs/auth/oauth.md +++ b/docs/auth/oauth.md @@ -6,7 +6,49 @@ the HTTP authentication header. The token can be scoped to have more restrictive the base RBAC permissions of the user. Refer to [RFC 6749](https://tools.ietf.org/html/rfc6749) for more details of OAuth 2 specification. -## Usage +## Basic Usage + +To get started using OAuth 2 tokens for accessing the browsable API using OAuth 2, we will walkthrough acquiring a token, and using it. + +1. Make an application with authorization_grant_type set to 'password'. HTTP POST the following to the `/api/v2/applications/` endpoint (supplying your own organization-id): +``` +{ + "name": "Admin Internal Application", + "description": "For use by secure services & clients. ", + "client_type": "confidential", + "redirect_uris": "", + "authorization_grant_type": "password", + "skip_authorization": false, + "organization": +} +``` +2. Make a token with a POST to the `/api/v2/tokens/` endpoint: +``` +{ + "description": "My Access Token", + "application": , + "scope": "write" +} +``` +This will return a `` that you can use to authenticate with for future requests (this will not be shown again) + +3. Use token to access a resource. We will use curl to demonstrate this: +``` +curl -H "Authorization: Bearer " -X GET https:///api/v2/users/ +``` +> The `-k` flag may be needed if you have not set up a CA yet and are using SSL. + +This token can be revoked by making a DELETE on the detail page for that token. All you need is that token's id. For example: +``` +curl -ku : -X DELETE https:///api/v2/tokens// +``` + +Similarly, using a token: +``` +curl -H "Authorization: Bearer " -X DELETE https:///api/v2/tokens// -k +``` + +## More Information #### Managing OAuth 2 applications and tokens Applications and tokens can be managed as a top-level resource at `/api//applications` and @@ -14,11 +56,11 @@ Applications and tokens can be managed as a top-level resource at `/api//users/N/`. Applications can be created by making a POST to either `api//applications` or `/api//users/N/applications`. -Each OAuth 2 application represents a specific API client on the server side. For an API client to use the API, -it must first have an application, and issue an access token. +Each OAuth 2 application represents a specific API client on the server side. For an API client to use the API via an application token, +it must first have an application and issue an access token. Individual applications will be accessible via their primary keys: -`/api//applications//`. Here is a typical application: +`/api//applications//`. Here is a typical application: ``` { "id": 1, @@ -59,10 +101,10 @@ Individual applications will be accessible via their primary keys: "skip_authorization": false }, ``` -In the above example, `user` is the primary key of the user this application associates to and `name` is +In the above example, `user` is the primary key of the user associated to this application and `name` is a human-readable identifier for the application. The other fields, like `client_id` and `redirect_uris`, are mainly used for OAuth 2 authorization, which will be covered later in the 'Using -OAuth token system' section. +OAuth 2 Token System' section. Fields `client_id` and `client_secret` are immutable identifiers of applications, and will be generated during creation; Fields `user` and `authorization_grant_type`, on the other hand, are @@ -85,7 +127,7 @@ token scope; or POSTing to `/api/v2/applications//tokens/` by providing only the parent application will be automatically linked. Individual tokens will be accessible via their primary keys: -`/api//tokens//`. Here is a typical token: +`/api//tokens//`. Here is a typical token: ``` { "id": 4, @@ -123,7 +165,7 @@ Individual tokens will be accessible via their primary keys: For an OAuth 2 token, the only fully mutable fields are `scope` and `description`. The `application` field is *immutable on update*, and all other fields are totally immutable, and will be auto-populated during creation -* `user` field will be the `user` field of related application +* `user` field corresponds to the user the token is created for * `expires` will be generated according to Tower configuration setting `OAUTH2_PROVIDER` * `token` and `refresh_token` will be auto-generated to be non-clashing random strings. Both application tokens and personal access tokens will be shown at the `/api/v2/tokens/` @@ -134,26 +176,27 @@ On RBAC side: - System admin is able to see and manipulate every token in the system; - Organization admins will be able to see and manipulate all tokens belonging to Organization members; + System Auditors can see all tokens and applications - Other normal users will only be able to see and manipulate their own tokens. > Note: Users can only see the token or refresh-token _value_ at the time of creation ONLY. -#### Using OAuth 2 token system for Personal Access Tokens (PAT) +#### Using OAuth 2 Token System for Personal Access Tokens (PAT) The most common usage of OAuth 2 is authenticating users. The `token` field of a token is used as part of the HTTP authentication header, in the format `Authorization: Bearer `. This _Bearer_ token can be obtained by doing a curl to the `/api/o/token/` endpoint. For example: ``` curl -ku : -H "Content-Type: application/json" -X POST \ -d '{"description":"Tower CLI", "application":null, "scope":"write"}' \ -https://localhost:8043/api/v2/users/1/personal_tokens/ | python -m json.tool +https:///api/v2/users/1/personal_tokens/ | python -m json.tool ``` Here is an example of using that PAT to access an API endpoint using `curl`: ``` -curl -H "Authorization: Bearer kqHqxfpHGRRBXLNCOXxT5Zt3tpJogn" http://localhost:8013/api/v2/credentials/ +curl -H "Authorization: Bearer kqHqxfpHGRRBXLNCOXxT5Zt3tpJogn" http:///api/v2/credentials/ ``` According to OAuth 2 specification, users should be able to acquire, revoke and refresh an access -token. In AWX the equivalent, and the easiest, way of doing that is creating a token, deleting -a token, and deleting a token quickly followed by creating a new one. +token. In AWX the equivalent, and easiest, way of doing that is creating a token, deleting +a token, and deleting a token quickly followed by creating a new one. The specification also provides standard ways of doing this. RFC 6749 elaborates on those topics, but in summary, an OAuth 2 token is officially acquired via authorization using @@ -166,6 +209,7 @@ In AWX, our OAuth 2 system is built on top of support on standard authorization, token revoke and refresh. AWX implements them and puts related endpoints under `/api/o/` endpoint. Detailed examples on the most typical usage of those endpoints are available as description text of `/api/o/`. See below for information on Application Access Token usage. +> Note: The `/api/o/` endpoints can only be used for application tokens, and are not valid for personal access tokens. #### Token scope mask over RBAC system The scope of an OAuth 2 token is a space-separated string composed of keywords like 'read' and 'write'. @@ -204,7 +248,7 @@ Make a POST to the `/api/v2/applications/` endpoint. "name": "AuthCodeApp", "user": 1, "client_type": "confidential", - "redirect_uris": "http://localhost:8013/api/v2", + "redirect_uris": "http:///api/v2", "authorization_grant_type": "authorization-code", "skip_authorization": false } @@ -237,7 +281,7 @@ Suppose we have an application `admin's app` of grant type `implicit`: "client_id": "L0uQQWW8pKX51hoqIRQGsuqmIdPi2AcXZ9EJRGmj", "client_secret": "9Wp4dUrUsigI8J15fQYJ3jn0MJHLkAjyw7ikBsABeWTNJbZwy7eB2Xro9ykYuuygerTPQ2gIF2DCTtN3kurkt0Me3AhanEw6peRNvNLs1NNfI4f53mhX8zo5JQX0BKy5", "client_type": "confidential", - "redirect_uris": "http://localhost:8013/api/", + "redirect_uris": "http:///api/", "authorization_grant_type": "implicit", "skip_authorization": false } @@ -293,7 +337,7 @@ curl -X POST \ -d "grant_type=password&username=&password=&scope=read" \ -u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569e IaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \ - http://localhost:8013/api/o/token/ -i + http:///api/o/token/ -i ``` In the above post request, parameters `username` and `password` are username and password of the related AWX user of the underlying application, and the authentication information is of format @@ -340,7 +384,7 @@ The `/api/o/token/` endpoint is used for refreshing access token: curl -X POST \ -d "grant_type=refresh_token&refresh_token=AL0NK9TTpv0qp54dGbC4VUZtsZ9r8z" \ -u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \ - http://localhost:8013/api/o/token/ -i + http:///api/o/token/ -i ``` In the above post request, `refresh_token` is provided by `refresh_token` field of the access token above. The authentication information is of format `:`, where `client_id` @@ -368,41 +412,16 @@ after, with information like scope and related application identical to the orig verify by checking the new token is present and the old token is deleted at the /api/v2/tokens/ endpoint. #### Revoke an access token -Revoking an access token is the same as deleting the token resource object. Suppose we have -an existing token to revoke: -```text -{ - "id": 30, - "type": "access_token", - "url": "/api/v2/tokens/30/", - ... - "user": null, - "token": "rQONsve372fQwuc2pn76k3IHDCYpi7", - "refresh_token": "", - "application": 6, - "expires": "2017-12-06T03:24:25.614523Z", - "scope": "read" -} -``` -Revoking is conducted by POSTing to `/api/o/revoke_token/` with the token to revoke as parameter: + +##### Alternatively Revoke using the /api/o/revoke-token/ endpoint +Revoking an access token by this method is the same as deleting the token resource object, but it allows you to delete a token by providing its token value, and the associated `client_id` (and `client_secret` if the application is `confidential`). For example: ```bash curl -X POST -d "token=rQONsve372fQwuc2pn76k3IHDCYpi7" \ -u "gwSPoasWSdNkMDtBN3Hu2WYQpPWCO9SwUEsKK22l:fI6ZpfocHYBGfm1tP92r0yIgCyfRdDQt0Tos9L8a4fNsJjQQMwp9569eIaUBsaVDgt2eiwOGe0bg5m5vCSstClZmtdy359RVx2rQK5YlIWyPlrolpt2LEpVeKXWaiybo" \ - http://localhost:8013/api/o/revoke_token/ -i + http:///api/o/revoke_token/ -i ``` `200 OK` means a successful delete. -```text -HTTP/1.1 200 OK -Server: nginx/1.12.2 -Date: Tue, 05 Dec 2017 18:05:18 GMT -Content-Type: text/html; charset=utf-8 -Content-Length: 0 -Connection: keep-alive -Vary: Accept-Language, Cookie -Content-Language: en -Strict-Transport-Security: max-age=15768000 -``` We can verify the effect by checking if the token is no longer present at /api/v2/tokens/. From 648ec3141b00f62e201c573aec58907223c7823d Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 4 Jun 2018 11:19:22 -0400 Subject: [PATCH 113/762] no launch config errors with replacable ASK credential --- awx/main/models/jobs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index dd05e2359a..7d195b78ac 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -402,7 +402,9 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour if 'prompts' not in exclude_errors: errors_dict[field_name] = _('Field is not configured to prompt on launch.').format(field_name=field_name) - if 'prompts' not in exclude_errors and self.passwords_needed_to_start: + if ('prompts' not in exclude_errors and + (not getattr(self, 'ask_credential_on_launch', False)) and + self.passwords_needed_to_start): errors_dict['passwords_needed_to_start'] = _( 'Saved launch configurations cannot provide passwords needed to start.') From f9ed4296bb49f62cbf761ddddf0e5b41e7731523 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 4 Jun 2018 13:51:49 -0400 Subject: [PATCH 114/762] fix notifications search on inventory source form tab --- .../inventories/related/sources/sources.form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js index 89299827b9..bffc91e3d0 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js @@ -15,7 +15,7 @@ export default ['NotificationsList', 'i18n', function(NotificationsList, i18n){ var notifications_object = { name: 'notifications', index: false, - basePath: "notifications", + basePath: "notification_templates", include: "NotificationsList", title: i18n._('Notifications'), iterator: 'notification', From 581756527e2b83250a12257d29ce05daa95c687d Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 4 Jun 2018 15:02:27 -0400 Subject: [PATCH 115/762] fix an i18n-related bug that affects non-English hipchat users see: https://github.com/ansible/tower/issues/951 --- awx/ui/client/src/notifications/add/add.controller.js | 9 ++++++++- awx/ui/client/src/notifications/edit/edit.controller.js | 9 ++++++++- .../src/notifications/notificationTemplates.form.js | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/notifications/add/add.controller.js b/awx/ui/client/src/notifications/add/add.controller.js index 11f7eb1aa1..0455d2f626 100644 --- a/awx/ui/client/src/notifications/add/add.controller.js +++ b/awx/ui/client/src/notifications/add/add.controller.js @@ -75,7 +75,14 @@ export default ['Rest', 'Wait', 'NotificationsFormObject', multiple: false }); - $scope.hipchatColors = [i18n._('Gray'), i18n._('Green'), i18n._('Purple'), i18n._('Red'), i18n._('Yellow'), i18n._('Random')]; + $scope.hipchatColors = [ + {'id': 'gray', 'name': i18n._('Gray')}, + {'id': 'green', 'name': i18n._('Green')}, + {'id': 'purple', 'name': i18n._('Purple')}, + {'id': 'red', 'name': i18n._('Red')}, + {'id': 'yellow', 'name': i18n._('Yellow')}, + {'id': 'random', 'name': i18n._('Random')} + ]; CreateSelect2({ element: '#notification_template_color', multiple: false diff --git a/awx/ui/client/src/notifications/edit/edit.controller.js b/awx/ui/client/src/notifications/edit/edit.controller.js index 0d871dd26f..dfde2038e9 100644 --- a/awx/ui/client/src/notifications/edit/edit.controller.js +++ b/awx/ui/client/src/notifications/edit/edit.controller.js @@ -122,7 +122,14 @@ export default ['Rest', 'Wait', multiple: false }); - $scope.hipchatColors = [i18n._('Gray'), i18n._('Green'), i18n._('Purple'), i18n._('Red'), i18n._('Yellow'), i18n._('Random')]; + $scope.hipchatColors = [ + {'id': 'gray', 'name': i18n._('Gray')}, + {'id': 'green', 'name': i18n._('Green')}, + {'id': 'purple', 'name': i18n._('Purple')}, + {'id': 'red', 'name': i18n._('Red')}, + {'id': 'yellow', 'name': i18n._('Yellow')}, + {'id': 'random', 'name': i18n._('Random')} + ]; CreateSelect2({ element: '#notification_template_color', multiple: false diff --git a/awx/ui/client/src/notifications/notificationTemplates.form.js b/awx/ui/client/src/notifications/notificationTemplates.form.js index e2b15ef77b..c54843421b 100644 --- a/awx/ui/client/src/notifications/notificationTemplates.form.js +++ b/awx/ui/client/src/notifications/notificationTemplates.form.js @@ -288,7 +288,7 @@ export default ['i18n', function(i18n) { label: i18n._('Notification Color'), dataTitle: i18n._('Notification Color'), type: 'select', - ngOptions: 'color for color in hipchatColors track by color', + ngOptions: 'color.id as color.name for color in hipchatColors', awPopOver: i18n._('Specify a notification color. Acceptable colors are: yellow, green, red purple, gray or random.'), awRequiredWhen: { reqExpression: "hipchat_required", From 97fc7453298a57c89adb56d9065c1ba177ce9a61 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 4 Jun 2018 16:28:26 -0400 Subject: [PATCH 116/762] fix a setting help text typo --- awx/main/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/conf.py b/awx/main/conf.py index e635fbc08d..80774e09b0 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -81,7 +81,7 @@ register( help_text=_('HTTP headers and meta keys to search to determine remote host ' 'name or IP. Add additional items to this list, such as ' '"HTTP_X_FORWARDED_FOR", if behind a reverse proxy. ' - 'See the "Proxy Support" section of the Adminstrator guide for' + 'See the "Proxy Support" section of the Adminstrator guide for ' 'more details.'), category=_('System'), category_slug='system', From 5c874c6b3db66eda74f42d694c70146d57b8a044 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 4 Jun 2018 17:36:30 -0400 Subject: [PATCH 117/762] fix wrapping issue for instances list --- awx/ui/client/lib/components/list/_index.less | 6 ++++++ awx/ui/client/lib/theme/_variables.less | 2 ++ .../instance-groups/instances/instances-list.partial.html | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index 321631559a..06e7d34685 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -262,6 +262,12 @@ } } +@media screen and (max-width: @at-breakpoint-instances-wrap) { + .at-Row-items--instances { + margin-bottom: @at-padding-bottom-instances-wrap; + } +} + @media screen and (max-width: @at-breakpoint-compact-list) { .at-Row-actions { flex-direction: column; diff --git a/awx/ui/client/lib/theme/_variables.less b/awx/ui/client/lib/theme/_variables.less index 89261e5dc9..3317148e4f 100644 --- a/awx/ui/client/lib/theme/_variables.less +++ b/awx/ui/client/lib/theme/_variables.less @@ -247,6 +247,7 @@ @at-padding-list-row-item-tag: 0 @at-space-2x; @at-padding-list-row-action: 7px; @at-padding-list-row: 10px 20px 10px 10px; +@at-padding-bottom-instances-wrap: 30px; @at-margin-input-message: @at-space; @at-margin-item-column: @at-space-3x; @@ -332,3 +333,4 @@ @at-breakpoint-mobile-layout: @at-breakpoint-sm; @at-breakpoint-compact-list: @at-breakpoint-sm; +@at-breakpoint-instances-wrap: 1036px; diff --git a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html index 01868ef35d..9d5f9a8069 100644 --- a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html +++ b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html @@ -58,7 +58,7 @@
-
+
Date: Mon, 4 Jun 2018 17:53:08 -0400 Subject: [PATCH 118/762] make sure instances list is always ordered --- awx/ui/client/src/instance-groups/main.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/instance-groups/main.js b/awx/ui/client/src/instance-groups/main.js index 387a0740dc..37e9240f23 100644 --- a/awx/ui/client/src/instance-groups/main.js +++ b/awx/ui/client/src/instance-groups/main.js @@ -251,7 +251,13 @@ function InstanceGroupsRun ($stateExtender, strings, ComponentsStrings) { } }, resolve: { - resolvedModels: InstanceGroupsResolve + resolvedModels: InstanceGroupsResolve, + Dataset: ['GetBasePath', 'QuerySet', '$stateParams', + function(GetBasePath, qs, $stateParams) { + let path = `${GetBasePath('instance_groups')}${$stateParams['instance_group_id']}/instances`; + return qs.search(path, $stateParams[`instance_search`]); + } + ] } }); From 74c6c350a1d5207fbbc4d0d71eb9dbc7721d27e8 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Mon, 4 Jun 2018 14:25:27 -0400 Subject: [PATCH 119/762] show org-admins all teams if ALL USERS setting enabled --- awx/main/access.py | 3 +++ awx/main/conf.py | 3 ++- awx/main/tests/functional/test_rbac_team.py | 14 +++++++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index ed2886f4b8..9ff9973269 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1114,6 +1114,9 @@ class TeamAccess(BaseAccess): select_related = ('created_by', 'modified_by', 'organization',) def filtered_queryset(self): + if settings.ORG_ADMINS_CAN_SEE_ALL_USERS and \ + (self.user.admin_of_organizations.exists() or self.user.auditor_of_organizations.exists()): + return self.model.objects.all() return self.model.accessible_objects(self.user, 'read_role') @check_superuser diff --git a/awx/main/conf.py b/awx/main/conf.py index 80774e09b0..c3a9c87173 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -38,7 +38,8 @@ register( 'ORG_ADMINS_CAN_SEE_ALL_USERS', field_class=fields.BooleanField, label=_('All Users Visible to Organization Admins'), - help_text=_('Controls whether any Organization Admin can view all users, even those not associated with their Organization.'), + help_text=_('Controls whether any Organization Admin can view all users and teams, ' + 'even those not associated with their Organization.'), category=_('System'), category_slug='system', ) diff --git a/awx/main/tests/functional/test_rbac_team.py b/awx/main/tests/functional/test_rbac_team.py index 5e7cf4ad85..bb75c4f0cc 100644 --- a/awx/main/tests/functional/test_rbac_team.py +++ b/awx/main/tests/functional/test_rbac_team.py @@ -1,7 +1,8 @@ import pytest +import mock from awx.main.access import TeamAccess -from awx.main.models import Project +from awx.main.models import Project, Organization, Team @pytest.mark.django_db @@ -116,3 +117,14 @@ def test_org_admin_team_access(organization, team, user, project): team.member_role.children.add(project.use_role) assert len(Project.accessible_objects(u, 'use_role')) == 1 + + +@pytest.mark.django_db +@pytest.mark.parametrize('enabled', [True, False]) +def test_org_admin_view_all_teams(org_admin, enabled): + access = TeamAccess(org_admin) + other_org = Organization.objects.create(name='other-org') + other_team = Team.objects.create(name='other-team', organization=other_org) + with mock.patch('awx.main.access.settings') as settings_mock: + settings_mock.ORG_ADMINS_CAN_SEE_ALL_USERS = enabled + assert access.can_read(other_team) is enabled From 7affa15efef688887563b74889fceac5b5217675 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 5 Jun 2018 13:06:33 +0000 Subject: [PATCH 120/762] Only duplicate nodes if original WFJT is not available --- awx/main/models/workflow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index c63bbc6f1f..f553fd7254 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -288,7 +288,8 @@ class WorkflowJobOptions(BaseModel): def create_relaunch_workflow_job(self): new_workflow_job = self.copy_unified_job() - new_workflow_job.copy_nodes_from_original(original=self) + if self.workflow_job_template is None: + new_workflow_job.copy_nodes_from_original(original=self) return new_workflow_job From d8cb47bf82d6f001493d80da1ba2cb4cad9b40e8 Mon Sep 17 00:00:00 2001 From: Yunfan Zhang Date: Mon, 4 Jun 2018 16:03:14 -0400 Subject: [PATCH 121/762] Added awx-manage command for expiring sessions. --- .../management/commands/expire_sessions.py | 36 ++++++++++ .../commands/test_expire_sessions.py | 67 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 awx/main/management/commands/expire_sessions.py create mode 100644 awx/main/tests/functional/commands/test_expire_sessions.py diff --git a/awx/main/management/commands/expire_sessions.py b/awx/main/management/commands/expire_sessions.py new file mode 100644 index 0000000000..7145c1d725 --- /dev/null +++ b/awx/main/management/commands/expire_sessions.py @@ -0,0 +1,36 @@ +# Python +from importlib import import_module + +# Django +from django.utils import timezone +from django.conf import settings +from django.contrib.auth import logout +from django.http import HttpRequest +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth.models import User +from django.contrib.sessions.models import Session +from django.core.exceptions import ObjectDoesNotExist + + +class Command(BaseCommand): + """Expire Django auth sessions for a user/all users""" + help='Expire Django auth sessions. Will expire all auth sessions if --user option is not supplied.' + + def add_arguments(self, parser): + parser.add_argument('--user', dest='user', type=str) + + def handle(self, *args, **options): + # Try to see if the user exist + try: + user = User.objects.get(username=options['user']) if options['user'] else None + except ObjectDoesNotExist: + raise CommandError('The user does not exist.') + # We use the following hack to filter out sessions that are still active, + # with consideration for timezones. + start = timezone.now() + sessions = Session.objects.filter(expire_date__gte=start).iterator() + request = HttpRequest() + for session in sessions: + if (user is None) or (user.id == int(session.get_decoded().get('_auth_user_id'))): + request.session = request.session = import_module(settings.SESSION_ENGINE).SessionStore(session.session_key) + logout(request) diff --git a/awx/main/tests/functional/commands/test_expire_sessions.py b/awx/main/tests/functional/commands/test_expire_sessions.py new file mode 100644 index 0000000000..91e811bdcf --- /dev/null +++ b/awx/main/tests/functional/commands/test_expire_sessions.py @@ -0,0 +1,67 @@ +# Python +import pytest +import string +import random + +# Django +from django.utils import timezone +from django.test import Client +from django.conf import settings +from django.contrib.auth.models import User +from django.contrib.sessions.models import Session +from django.core.management.base import CommandError + +# AWX +from awx.main.management.commands.expire_sessions import Command + + +@pytest.mark.django_db +class TestExpireSessionsCommand: + @staticmethod + def create_and_login_fake_users(): + # We already have Alice and Bob, so we are going to create Charlie and Dylan + charlie = User.objects.create_user('charlie', 'charlie@email.com', 'pass') + dylan = User.objects.create_user('dylan', 'dylan@email.com', 'word') + client_0 = Client() + client_1 = Client() + client_0.force_login(charlie, backend=settings.AUTHENTICATION_BACKENDS[0]) + client_1.force_login(dylan, backend=settings.AUTHENTICATION_BACKENDS[0]) + return charlie, dylan + + @staticmethod + def run_command(username=None): + command_obj = Command() + command_obj.handle(user=username) + + def test_expire_all_sessions(self): + charlie, dylan = self.create_and_login_fake_users() + self.run_command() + start = timezone.now() + sessions = Session.objects.filter(expire_date__gte=start) + for session in sessions: + user_id = int(session.get_decoded().get('_auth_user_id')) + if user_id == charlie.id or user_id == dylan.id: + self.fail('The user should not have active sessions.') + + def test_non_existing_user(self): + fake_username = '' + while fake_username == '' or User.objects.filter(username=fake_username).exists(): + fake_username = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(6)) + with pytest.raises(CommandError) as excinfo: + self.run_command(fake_username) + assert excinfo.value.message.strip() == 'The user does not exist.' + + def test_expire_one_user(self): + # alice should be logged out, but bob should not. + charlie, dylan = self.create_and_login_fake_users() + self.run_command('charlie') + start = timezone.now() + sessions = Session.objects.filter(expire_date__gte=start) + dylan_still_active = False + for session in sessions: + user_id = int(session.get_decoded().get('_auth_user_id')) + if user_id == charlie.id: + self.fail('Charlie should not have active sessions.') + elif user_id == dylan.id: + dylan_still_active = True + assert dylan_still_active From 22dd6ddfea3fd5c96feabbee69fa2b27a2a19c10 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Tue, 5 Jun 2018 09:34:16 -0400 Subject: [PATCH 122/762] Remove the logstash container from the base dev docker compose Now with less java running while you code! --- tools/docker-compose-cluster.yml | 4 ---- tools/docker-compose.yml | 11 ++++++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tools/docker-compose-cluster.yml b/tools/docker-compose-cluster.yml index 141a62da7c..0614b09767 100644 --- a/tools/docker-compose-cluster.yml +++ b/tools/docker-compose-cluster.yml @@ -73,7 +73,3 @@ services: image: postgres:9.6 memcached: image: memcached:alpine - logstash: - build: - context: ./docker-compose - dockerfile: Dockerfile-logstash diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml index 929eaa33c3..086507ce05 100644 --- a/tools/docker-compose.yml +++ b/tools/docker-compose.yml @@ -22,17 +22,18 @@ services: - postgres - memcached - rabbitmq - - logstash # - sync # volumes_from: # - sync volumes: - "../:/awx_devel" privileged: true - logstash: - build: - context: ./docker-compose - dockerfile: Dockerfile-logstash + # A useful container that simply passes through log messages to the console + # helpful for testing awx/tower logging + # logstash: + # build: + # context: ./docker-compose + # dockerfile: Dockerfile-logstash # Postgres Database Container postgres: image: postgres:9.6 From b876c2af62e987e6f354caa1858f947bd277ba93 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 4 Jun 2018 15:55:26 -0400 Subject: [PATCH 123/762] add total job count for instance + instance group --- awx/api/serializers.py | 34 ++++++++++++++++++---------------- awx/main/models/ha.py | 13 +++++++++++++ 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 2376956da5..3adcf5afd0 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4440,13 +4440,16 @@ class InstanceSerializer(BaseSerializer): 'are targeted for this instance'), read_only=True ) - + jobs_total = serializers.IntegerField( + help_text=_('Count of all jobs that target this instance'), + read_only=True + ) class Meta: model = Instance read_only_fields = ('uuid', 'hostname', 'version') fields = ("id", "type", "url", "related", "uuid", "hostname", "created", "modified", 'capacity_adjustment', - "version", "capacity", "consumed_capacity", "percent_capacity_remaining", "jobs_running", + "version", "capacity", "consumed_capacity", "percent_capacity_remaining", "jobs_running", "jobs_total", "cpu", "memory", "cpu_capacity", "mem_capacity", "enabled") def get_related(self, obj): @@ -4470,7 +4473,15 @@ class InstanceGroupSerializer(BaseSerializer): committed_capacity = serializers.SerializerMethodField() consumed_capacity = serializers.SerializerMethodField() percent_capacity_remaining = serializers.SerializerMethodField() - jobs_running = serializers.SerializerMethodField() + jobs_running = serializers.IntegerField( + help_text=_('Count of jobs in the running or waiting state that ' + 'are targeted for this instance group'), + read_only=True + ) + jobs_total = serializers.IntegerField( + help_text=_('Count of all jobs that target this instance group'), + read_only=True + ) instances = serializers.SerializerMethodField() # NOTE: help_text is duplicated from field definitions, no obvious way of # both defining field details here and also getting the field's help_text @@ -4493,7 +4504,8 @@ class InstanceGroupSerializer(BaseSerializer): model = InstanceGroup fields = ("id", "type", "url", "related", "name", "created", "modified", "capacity", "committed_capacity", "consumed_capacity", - "percent_capacity_remaining", "jobs_running", "instances", "controller", + "percent_capacity_remaining", "jobs_running", "jobs_total", + "instances", "controller", "policy_instance_percentage", "policy_instance_minimum", "policy_instance_list") def get_related(self, obj): @@ -4517,21 +4529,15 @@ class InstanceGroupSerializer(BaseSerializer): raise serializers.ValidationError(_('tower instance group name may not be changed.')) return value - def get_jobs_qs(self): - # Store running jobs queryset in context, so it will be shared in ListView - if 'running_jobs' not in self.context: - self.context['running_jobs'] = UnifiedJob.objects.filter( - status__in=('running', 'waiting')) - return self.context['running_jobs'] - def get_capacity_dict(self): # Store capacity values (globally computed) in the context if 'capacity_map' not in self.context: ig_qs = None + jobs_qs = UnifiedJob.objects.filter(status__in=('running', 'waiting')) if self.parent: # Is ListView: ig_qs = self.parent.instance self.context['capacity_map'] = InstanceGroup.objects.capacity_values( - qs=ig_qs, tasks=self.get_jobs_qs(), breakdown=True) + qs=ig_qs, tasks=jobs_qs, breakdown=True) return self.context['capacity_map'] def get_consumed_capacity(self, obj): @@ -4551,10 +4557,6 @@ class InstanceGroupSerializer(BaseSerializer): ((float(obj.capacity) - float(consumed)) / (float(obj.capacity))) * 100) ) - def get_jobs_running(self, obj): - jobs_qs = self.get_jobs_qs() - return sum(1 for job in jobs_qs if job.instance_group_id == obj.id) - def get_instances(self, obj): return obj.instances.count() diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 16589c0a77..df53e1cd63 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -106,6 +106,10 @@ class Instance(BaseModel): def jobs_running(self): return UnifiedJob.objects.filter(execution_node=self.hostname, status__in=('running', 'waiting',)).count() + @property + def jobs_total(self): + return UnifiedJob.objects.filter(execution_node=self.hostname).count() + def is_lost(self, ref_time=None, isolated=False): if ref_time is None: ref_time = now() @@ -178,6 +182,15 @@ class InstanceGroup(BaseModel, RelatedJobsMixin): def capacity(self): return sum([inst.capacity for inst in self.instances.all()]) + @property + def jobs_running(self): + return UnifiedJob.objects.filter(status__in=('running', 'waiting'), + instance_group=self).count() + + @property + def jobs_total(self): + return UnifiedJob.objects.filter(instance_group=self).count() + ''' RelatedJobsMixin ''' From b899096f999bf9338f5bc252b83fa25efa7f0a1f Mon Sep 17 00:00:00 2001 From: Will Thames Date: Wed, 6 Jun 2018 13:02:26 +1000 Subject: [PATCH 124/762] Use use-context to set Kubernetes context `kubectl config use-context` is the command to set the current context, not `set-context` Signed-off-by: Will Thames --- installer/roles/kubernetes/tasks/kubernetes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installer/roles/kubernetes/tasks/kubernetes.yml b/installer/roles/kubernetes/tasks/kubernetes.yml index 14837e824c..a11ed63272 100644 --- a/installer/roles/kubernetes/tasks/kubernetes.yml +++ b/installer/roles/kubernetes/tasks/kubernetes.yml @@ -1,5 +1,5 @@ - name: Set the Kubernetes Context - shell: "kubectl config set-context {{ kubernetes_context }}" + shell: "kubectl config use-context {{ kubernetes_context }}" - name: Get Namespace Detail shell: "kubectl get namespace {{ kubernetes_namespace }}" From a9391584d72f8fb0f0ea4fdc4dee542f45180f41 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 5 Jun 2018 09:54:48 -0400 Subject: [PATCH 125/762] Update CHANGELOG.md for 3.3 items Added API items in headline feature list Added items found by scrubbing the merged PR queue Add 3.3 section header move the 3.3 section to the top of file, before the existing 3.2 section --- docs/CHANGELOG.md | 82 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 592a9128e8..d6b75726bc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,3 +1,73 @@ +3.3.0 +===== +* Allow relaunching jobs on a subset of hosts, by status.[[#219](https://github.com/ansible/awx/issues/219)] +* Added `ask_variables_on_launch` to workflow JTs.[[#497](https://github.com/ansible/awx/issues/497)] +* Added `diff_mode` and `verbosity` fields to WFJT nodes.[[#555](https://github.com/ansible/awx/issues/555)] +* Block creation of schedules when variables not allowed are given. + Block similar cases for WFJT nodes.[[#478](https://github.com/ansible/awx/issues/478)] +* Changed WFJT node `credential` to many-to-many `credentials`. +* Saved Launch-time configurations feature - added WFJT node promptable fields to schedules, + added `extra_data` to WFJT nodes, added "schedule this job" endpoint. + [[#169](https://github.com/ansible/awx/issues/169)] +* Switch from `credential`, `vault_credential`, and `extra_credentials` fields to + single `credentials` relationship, allow multiple vault credentials [[#352](https://github.com/ansible/awx/issues/352)]. +* Make inventory parsing errors fatal, and only enable the `script` + inventory plugin for job runs and vendored inventory + updates[[#864](https://github.com/ansible/awx/issues/864)] +* Add related `credentials` endpoint for inventory updates to be more internally + consistent with job templates, model changes for [[#277](https://github.com/ansible/awx/issues/277)] +* Removed `TOWER_HOST` as a default environment variable in job running environment + due to conflict with tower credential type. Playbook authors should replace their + use with `AWX_HOST`. [[#1727](https://github.com/ansible/awx/issues/1727)] +* Add validation to prevent string "$encrypted$" from becoming a literal + survey question default [[#518](https://github.com/ansible/awx/issues/518)]. +* Enable the `--export` option for `ansible-inventory` via the environment + variable [[#1253](https://github.com/ansible/awx/pull/1253)] so that + group `variables` are imported to the group model. +* Prevent unwanted entries in activity stream due to `modified` time changes. +* API based deep copy feature via related `/api/v2/resources/N/copy/` endpoint + [[#283](https://github.com/ansible/awx/issues/283)]. +* Container Cluster-based dynamic scaling provisioning / deprovisioning instances, + allow creating / modifying instance groups from the API, introduce instance + group policies, consider both memory and CPU constraints, add the ability + to disable nodes without removing them from the cluster + [[#196](https://github.com/ansible/awx/issues/196)]. +* Add additional organization roles [[#166](https://github.com/ansible/awx/issues/166)]. +* Support fact caching for isolated instances [[#198](https://github.com/ansible/awx/issues/198)]. +* Graphical UI for network inventory [[#611](https://github.com/ansible/awx/issues/611)]. +* Restrict viewing and editing network UI canvas to users with inventory `admin_role`. +* Implement per-template, project, organization `custom_virtualenv`, a field that + allows users to select one of multiple virtual environments set up on the filesystem + [[#34](https://github.com/ansible/awx/issues/34)]. +* Use events for running inventory updates, project updates, and other unified job + types [[#200](https://github.com/ansible/awx/issues/200)]. +* Prevent deletion of jobs when event processing is still ongoing. +* Prohibit job template callback when `inventory` is null + [[#644](https://github.com/ansible/awx/issues/644)]. +* Impose stricter criteria to admin users - organization admin role now + necessary for all organizations target user is member of. +* Remove unused `admin_role` associated with users. +* Enforce max value for `SESSION_COOKIE_AGE` + [[#1651](https://github.com/ansible/awx/issues/1651)]. +* Add stricter validation to `order_by` query params + [[#776](https://github.com/ansible/awx/issues/776)]. +* Consistently log uncaught task exceptions [[#1257](https://github.com/ansible/awx/issues/1257)]. +* Do not show value of variable of `with_items` iteration when `no_log` is set. +* Change external logger to lazily create handler from settings on every log + emission, replacing server restart. Allows use in OpenShift deployments. +* Allow job templates using previously-synced git projects to run without network + access to source control [[#287](https://github.com/ansible/awx/issues/287)]. +* Automatically run a project update if sensitive fields change like `scm_url`. +* Disallow relaunching jobs with `execute_role` if another user provided prompts. +* Show all teams to organization admins if setting `ORG_ADMINS_CAN_SEE_ALL_USERS` is enabled. +* Allow creating schedules and workflow nodes from job templates that use + credentials which prompt for passwords if `ask_credential_on_launch` is set. +* Set `execution_node` in task manager and submit `waiting` jobs to only the + queue for the specific instance job is targeted to run on + [[#1873](https://github.com/ansible/awx/issues/1873)]. +* Switched authentication to Django sessions. +* Implemented OAuth2 support for token based authentication [[#21](https://github.com/ansible/awx/issues/21)]. + 3.2.0 ===== * added a new API endpoint - `/api/v1/settings/logging/test/` - for testing @@ -53,15 +123,3 @@ `deprovision_node` -> `deprovision_instance`, and `instance_group_remove` -> `remove_from_queue`, which backward compatibility support for 3.1 use pattern [[#6915](https://github.com/ansible/ansible-tower/issues/6915)] -* Allow relaunching jobs on a subset of hosts, by status.[[#219](https://github.com/ansible/awx/issues/219)] -* Added `ask_variables_on_launch` to workflow JTs.[[#497](https://github.com/ansible/awx/issues/497)] -* Added `diff_mode` and `verbosity` fields to WFJT nodes.[[#555](https://github.com/ansible/awx/issues/555)] -* Block creation of schedules when variables not allowed are given. - Block similar cases for WFJT nodes.[[#478](https://github.com/ansible/awx/issues/478)] -* Changed WFJT node `credential` to many-to-many `credentials`. -* Saved Launch-time configurations feature - added WFJT node promptable fields to schedules, - added `extra_data` to WFJT nodes, added "schedule this job" endpoint. - [[#169](https://github.com/ansible/awx/issues/169)] -* Removed `TOWER_HOST` as a default environment variable in job running environment - due to conflict with tower credential type. Playbook authors should replace their - use with `AWX_HOST`. [[#1727](https://github.com/ansible/awx/issues/1727)] From de6167a52cd52bc4b1ab7a55f3ca80bba1648d99 Mon Sep 17 00:00:00 2001 From: kialam Date: Wed, 30 May 2018 11:16:05 -0400 Subject: [PATCH 126/762] Create basic toggle tag component --- awx/ui/client/lib/components/_index.less | 1 + awx/ui/client/lib/components/index.js | 2 ++ awx/ui/client/lib/components/list/_index.less | 4 +-- .../lib/components/list/row-item.partial.html | 6 +++- .../client/lib/components/toggle/_index.less | 4 +++ .../client/lib/components/toggle/constants.js | 2 ++ .../components/toggle/toggle-tag.directive.js | 29 +++++++++++++++++++ .../components/toggle/toggle-tag.partial.html | 14 +++++++++ 8 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 awx/ui/client/lib/components/toggle/_index.less create mode 100644 awx/ui/client/lib/components/toggle/constants.js create mode 100644 awx/ui/client/lib/components/toggle/toggle-tag.directive.js create mode 100644 awx/ui/client/lib/components/toggle/toggle-tag.partial.html diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index 7d661e8122..c586cb296e 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -9,6 +9,7 @@ @import 'relaunchButton/_index'; @import 'tabs/_index'; @import 'tag/_index'; +@import 'toggle/_index'; @import 'truncate/_index'; @import 'utility/_index'; @import 'code-mirror/_index'; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index bea2f38ce6..27675f598f 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -33,6 +33,7 @@ import sideNavItem from '~components/layout/side-nav-item.directive'; import tab from '~components/tabs/tab.directive'; import tabGroup from '~components/tabs/group.directive'; import tag from '~components/tag/tag.directive'; +import toggleTag from '~components/toggle/toggle-tag.directive'; import topNavItem from '~components/layout/top-nav-item.directive'; import truncate from '~components/truncate/truncate.directive'; import atCodeMirror from '~components/code-mirror'; @@ -80,6 +81,7 @@ angular .directive('atTab', tab) .directive('atTabGroup', tabGroup) .directive('atTag', tag) + .directive('atToggleTag', toggleTag) .directive('atTopNavItem', topNavItem) .directive('atTruncate', truncate) .service('BaseInputController', BaseInputController) diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index 321631559a..f1a0c8d371 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -167,6 +167,7 @@ .at-RowItem-tagContainer { display: flex; margin-left: @at-margin-left-list-row-item-tag-container; + flex-wrap: wrap; } .at-RowItem-tag { @@ -175,8 +176,7 @@ border-radius: @at-border-radius; color: @at-color-list-row-item-tag; font-size: @at-font-size-list-row-item-tag; - margin-left: @at-margin-left-list-row-item-tag; - margin-top: @at-margin-top-list-row-item-tag; + margin: @at-space; padding: @at-padding-list-row-item-tag; line-height: @at-line-height-list-row-item-tag; } diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index 7698ba85a9..a26405d6cd 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -42,7 +42,7 @@ template-type="smartStatus.type" ng-if="smartStatus">
-
+ +
+
+
diff --git a/awx/ui/client/lib/components/toggle/_index.less b/awx/ui/client/lib/components/toggle/_index.less new file mode 100644 index 0000000000..f86e6a7948 --- /dev/null +++ b/awx/ui/client/lib/components/toggle/_index.less @@ -0,0 +1,4 @@ +.ToggleComponent-container { + display: flex; + flex-wrap: wrap; +} \ No newline at end of file diff --git a/awx/ui/client/lib/components/toggle/constants.js b/awx/ui/client/lib/components/toggle/constants.js new file mode 100644 index 0000000000..e120030b65 --- /dev/null +++ b/awx/ui/client/lib/components/toggle/constants.js @@ -0,0 +1,2 @@ +export const TRUNCATE_LENGTH = 5; +export const TRUNCATED = true; diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js b/awx/ui/client/lib/components/toggle/toggle-tag.directive.js new file mode 100644 index 0000000000..adcb6406cc --- /dev/null +++ b/awx/ui/client/lib/components/toggle/toggle-tag.directive.js @@ -0,0 +1,29 @@ +import { TRUNCATED, TRUNCATE_LENGTH } from './constants'; + +const templateUrl = require('~components/toggle/toggle-tag.partial.html'); + +function controller () { + const vm = this; + vm.truncatedLength = TRUNCATE_LENGTH; + vm.truncated = TRUNCATED; + + vm.toggle = () => { + vm.truncated = !vm.truncated; + }; +} + +function atToggleTag () { + return { + restrict: 'E', + replace: true, + transclude: true, + controller, + controllerAs: 'vm', + templateUrl, + scope: { + tags: '=', + }, + }; +} + +export default atToggleTag; diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html new file mode 100644 index 0000000000..05685b654e --- /dev/null +++ b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html @@ -0,0 +1,14 @@ +
+
+
+ +
+ +
+
+
+ +
+ +
+
From 63f3a38ceb445d5c3e0cac18ebce329b867f9865 Mon Sep 17 00:00:00 2001 From: kialam Date: Wed, 30 May 2018 15:28:10 -0400 Subject: [PATCH 127/762] Add more functionality to Toggle Tag Component - Remove existing `switch` logic in favor of Toggle Tag Component, now with comments! - Slight adjustment to icons to center vertically; account for non-standard credential `kind` attributes so icons display as they should. - Style the `View More/Less` button accordingly - Update Toggle Tag directive template to include icons --- .../lib/components/list/row-item.partial.html | 18 ++++-------------- awx/ui/client/lib/components/tag/_index.less | 7 ++++++- .../client/lib/components/toggle/_index.less | 7 +++++++ .../components/toggle/toggle-tag.partial.html | 8 ++++---- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index a26405d6cd..89bce7c696 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -42,21 +42,11 @@ template-type="smartStatus.type" ng-if="smartStatus">
- -
- + +
+
+
diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index 5fa309c106..d1b14a6893 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -27,10 +27,15 @@ } .TagComponent-icon { + .at-mixin-VerticallyCenter(); line-height: 20px; margin-left: @at-space-2x; - &--cloud:before { + &--cloud:before, + &--aws:before, + &--tower:before, + &--azure_rm:before, + { content: '\f0c2'; } diff --git a/awx/ui/client/lib/components/toggle/_index.less b/awx/ui/client/lib/components/toggle/_index.less index f86e6a7948..8860faa67b 100644 --- a/awx/ui/client/lib/components/toggle/_index.less +++ b/awx/ui/client/lib/components/toggle/_index.less @@ -1,4 +1,11 @@ .ToggleComponent-container { display: flex; flex-wrap: wrap; +} + +.ToggleComponent-button { + border: none; + background: transparent; + color: @at-blue; + font-size: @at-font-size; } \ No newline at end of file diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html index 05685b654e..83952ee7c8 100644 --- a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html +++ b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html @@ -1,14 +1,14 @@
- +
- +
- +
- +
From 62f4beddb2659008d2a5ecad3610bd493cd39351 Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 31 May 2018 10:32:01 -0400 Subject: [PATCH 128/762] Reduce height of tags in job list page --- awx/ui/client/lib/components/toggle/_index.less | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/awx/ui/client/lib/components/toggle/_index.less b/awx/ui/client/lib/components/toggle/_index.less index 8860faa67b..18639c5648 100644 --- a/awx/ui/client/lib/components/toggle/_index.less +++ b/awx/ui/client/lib/components/toggle/_index.less @@ -1,3 +1,7 @@ +.ToggleComponent-wrapper { + line-height: initial; +} + .ToggleComponent-container { display: flex; flex-wrap: wrap; @@ -8,4 +12,8 @@ background: transparent; color: @at-blue; font-size: @at-font-size; + + &:hover { + color: @at-blue-hover; + } } \ No newline at end of file From f49b61ecfa085afe207bd361367f94a89cfd422d Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 31 May 2018 13:40:51 -0400 Subject: [PATCH 129/762] Create TagService This new Service can be used anywhere that needs a credentials tag and can be expanded upon to include other helpful tag methods as needed. --- awx/ui/client/lib/components/list/_index.less | 1 + .../lib/components/toggle/toggle-tag.directive.js | 11 ++++++++++- .../lib/components/toggle/toggle-tag.partial.html | 8 ++++---- awx/ui/client/lib/services/index.js | 4 +++- awx/ui/client/lib/services/tag.service.js | 14 ++++++++++++++ 5 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 awx/ui/client/lib/services/tag.service.js diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index f1a0c8d371..36f9473cc0 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -168,6 +168,7 @@ display: flex; margin-left: @at-margin-left-list-row-item-tag-container; flex-wrap: wrap; + line-height: initial; } .at-RowItem-tag { diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js b/awx/ui/client/lib/components/toggle/toggle-tag.directive.js index adcb6406cc..68a47df575 100644 --- a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js +++ b/awx/ui/client/lib/components/toggle/toggle-tag.directive.js @@ -2,7 +2,8 @@ import { TRUNCATED, TRUNCATE_LENGTH } from './constants'; const templateUrl = require('~components/toggle/toggle-tag.partial.html'); -function controller () { +function controller ($scope, TagService) { + const { tags } = $scope; const vm = this; vm.truncatedLength = TRUNCATE_LENGTH; vm.truncated = TRUNCATED; @@ -10,8 +11,16 @@ function controller () { vm.toggle = () => { vm.truncated = !vm.truncated; }; + + vm.tags = []; + // build credentials from tags object + Object.keys(tags).forEach(key => { + vm.tags.push(TagService.buildCredentialTag(tags[key])); + }); } +controller.$inject = ['$scope', 'TagService']; + function atToggleTag () { return { restrict: 'E', diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html index 83952ee7c8..e83d303eda 100644 --- a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html +++ b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html @@ -1,13 +1,13 @@
-
- +
+
-
- +
+
diff --git a/awx/ui/client/lib/services/index.js b/awx/ui/client/lib/services/index.js index 39ed1d8299..41ca37c836 100644 --- a/awx/ui/client/lib/services/index.js +++ b/awx/ui/client/lib/services/index.js @@ -2,6 +2,7 @@ import AppStrings from '~services/app.strings'; import BaseStringService from '~services/base-string.service'; import CacheService from '~services/cache.service'; import EventService from '~services/event.service'; +import TagService from '~services/tag.service'; const MODULE_NAME = 'at.lib.services'; @@ -12,6 +13,7 @@ angular .service('AppStrings', AppStrings) .service('BaseStringService', BaseStringService) .service('CacheService', CacheService) - .service('EventService', EventService); + .service('EventService', EventService) + .service('TagService', TagService); export default MODULE_NAME; diff --git a/awx/ui/client/lib/services/tag.service.js b/awx/ui/client/lib/services/tag.service.js new file mode 100644 index 0000000000..d0439f8f63 --- /dev/null +++ b/awx/ui/client/lib/services/tag.service.js @@ -0,0 +1,14 @@ +function TagService (strings, $filter) { + this.buildCredentialTag = (credential) => { + const icon = `${credential.kind}`; + const link = `/#/credentials/${credential.id}`; + const tooltip = strings.get('tooltips.CREDENTIAL'); + const value = $filter('sanitize')(credential.name); + + return { icon, link, tooltip, value }; + }; +} + +TagService.$inject = ['OutputStrings', '$filter']; + +export default TagService; From 265554accb6140253a2c2ca50bfdc9d27127c535 Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 31 May 2018 13:51:07 -0400 Subject: [PATCH 130/762] Add link to credentials tag in template --- awx/ui/client/lib/components/list/row-item.partial.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index 89bce7c696..e477156d59 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -44,7 +44,7 @@
- +
From e66b00eb6480f3e13bdc4d94d97253a54e4f5ac8 Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 31 May 2018 16:32:34 -0400 Subject: [PATCH 131/762] Translate `VIEW MORE` and `VIEW LESS` into component strings --- awx/ui/client/lib/components/components.strings.js | 5 +++++ awx/ui/client/lib/components/toggle/toggle-tag.directive.js | 5 +++-- awx/ui/client/lib/components/toggle/toggle-tag.partial.html | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 072e1e82ce..97d8049543 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -52,6 +52,11 @@ function ComponentsStrings (BaseString) { COPIED: t.s('Copied to clipboard.') }; + ns.toggle = { + VIEW_MORE: t.s('VIEW MORE'), + VIEW_LESS: t.s('VIEW LESS') + }; + ns.layout = { CURRENT_USER_LABEL: t.s('Logged in as'), VIEW_DOCS: t.s('View Documentation'), diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js b/awx/ui/client/lib/components/toggle/toggle-tag.directive.js index 68a47df575..7cce99831e 100644 --- a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js +++ b/awx/ui/client/lib/components/toggle/toggle-tag.directive.js @@ -2,11 +2,12 @@ import { TRUNCATED, TRUNCATE_LENGTH } from './constants'; const templateUrl = require('~components/toggle/toggle-tag.partial.html'); -function controller ($scope, TagService) { +function controller ($scope, TagService, strings) { const { tags } = $scope; const vm = this; vm.truncatedLength = TRUNCATE_LENGTH; vm.truncated = TRUNCATED; + vm.strings = strings; vm.toggle = () => { vm.truncated = !vm.truncated; @@ -19,7 +20,7 @@ function controller ($scope, TagService) { }); } -controller.$inject = ['$scope', 'TagService']; +controller.$inject = ['$scope', 'TagService', 'ComponentsStrings']; function atToggleTag () { return { diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html index e83d303eda..d0070c0fbf 100644 --- a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html +++ b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html @@ -3,12 +3,12 @@
- +
- +
From 8373d89c92b55552a5bce647b8766cf5d5b426b3 Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 31 May 2018 16:42:17 -0400 Subject: [PATCH 132/762] Duplicate `ns.tooltips CREDENTIALS` string to `ComponentStrings` to avoid using `OutputStrings` --- awx/ui/client/lib/components/components.strings.js | 4 ++++ awx/ui/client/lib/services/tag.service.js | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 97d8049543..1be3891669 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -57,6 +57,10 @@ function ComponentsStrings (BaseString) { VIEW_LESS: t.s('VIEW LESS') }; + ns.tooltips = { + CREDENTIAL: t.s('View the Credential'), + }; + ns.layout = { CURRENT_USER_LABEL: t.s('Logged in as'), VIEW_DOCS: t.s('View Documentation'), diff --git a/awx/ui/client/lib/services/tag.service.js b/awx/ui/client/lib/services/tag.service.js index d0439f8f63..3795776a81 100644 --- a/awx/ui/client/lib/services/tag.service.js +++ b/awx/ui/client/lib/services/tag.service.js @@ -9,6 +9,6 @@ function TagService (strings, $filter) { }; } -TagService.$inject = ['OutputStrings', '$filter']; +TagService.$inject = ['ComponentsStrings', '$filter']; export default TagService; From cc91729f7208e97c38dd27151f79caa365062e7f Mon Sep 17 00:00:00 2001 From: kialam Date: Fri, 1 Jun 2018 12:46:58 -0400 Subject: [PATCH 133/762] Account for default tags as well --- .../client/lib/components/list/row-item.partial.html | 2 +- .../lib/components/toggle/toggle-tag.directive.js | 11 +++++++++-- awx/ui/client/lib/services/tag.service.js | 4 ++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index e477156d59..69058b1c43 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -47,6 +47,6 @@
- +
diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js b/awx/ui/client/lib/components/toggle/toggle-tag.directive.js index 7cce99831e..a60c253a8c 100644 --- a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js +++ b/awx/ui/client/lib/components/toggle/toggle-tag.directive.js @@ -14,9 +14,15 @@ function controller ($scope, TagService, strings) { }; vm.tags = []; - // build credentials from tags object + + // Let the controller handle what type of tag should be passed to the directive + // e.g. default tag, crential tag, etc. Object.keys(tags).forEach(key => { - vm.tags.push(TagService.buildCredentialTag(tags[key])); + if ($scope.tagType === 'cred') { + vm.tags.push(TagService.buildCredentialTag(tags[key])); + } else { + vm.tags.push(TagService.buildTag(tags[key])); + } }); } @@ -32,6 +38,7 @@ function atToggleTag () { templateUrl, scope: { tags: '=', + tagType: '@', }, }; } diff --git a/awx/ui/client/lib/services/tag.service.js b/awx/ui/client/lib/services/tag.service.js index 3795776a81..fa44e0e894 100644 --- a/awx/ui/client/lib/services/tag.service.js +++ b/awx/ui/client/lib/services/tag.service.js @@ -7,6 +7,10 @@ function TagService (strings, $filter) { return { icon, link, tooltip, value }; }; + this.buildTag = tag => { + const value = $filter('sanitize')(tag); + return { value }; + }; } TagService.$inject = ['ComponentsStrings', '$filter']; From 22ddf7883a453426043d25d3d597716bf5f97e03 Mon Sep 17 00:00:00 2001 From: kialam Date: Mon, 4 Jun 2018 10:29:25 -0400 Subject: [PATCH 134/762] Adjust template to account for more than one tag type. --- awx/ui/client/lib/components/toggle/toggle-tag.partial.html | 6 ++++-- awx/ui/client/lib/services/tag.service.js | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html index d0070c0fbf..7997e0e741 100644 --- a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html +++ b/awx/ui/client/lib/components/toggle/toggle-tag.partial.html @@ -1,13 +1,15 @@
- + +
- + +
diff --git a/awx/ui/client/lib/services/tag.service.js b/awx/ui/client/lib/services/tag.service.js index fa44e0e894..be1a84dbe3 100644 --- a/awx/ui/client/lib/services/tag.service.js +++ b/awx/ui/client/lib/services/tag.service.js @@ -9,6 +9,7 @@ function TagService (strings, $filter) { }; this.buildTag = tag => { const value = $filter('sanitize')(tag); + return { value }; }; } From 2ee361db5d8bc0d687cc14e85cbe3e1c0f0846a8 Mon Sep 17 00:00:00 2001 From: kialam Date: Mon, 4 Jun 2018 10:39:30 -0400 Subject: [PATCH 135/762] Rename `toggle` component to `toggle-tag` for better clarity. --- awx/ui/client/lib/components/_index.less | 2 +- awx/ui/client/lib/components/index.js | 2 +- awx/ui/client/lib/components/{toggle => toggle-tag}/_index.less | 0 .../client/lib/components/{toggle => toggle-tag}/constants.js | 0 .../components/{toggle => toggle-tag}/toggle-tag.directive.js | 2 +- .../components/{toggle => toggle-tag}/toggle-tag.partial.html | 0 6 files changed, 3 insertions(+), 3 deletions(-) rename awx/ui/client/lib/components/{toggle => toggle-tag}/_index.less (100%) rename awx/ui/client/lib/components/{toggle => toggle-tag}/constants.js (100%) rename awx/ui/client/lib/components/{toggle => toggle-tag}/toggle-tag.directive.js (93%) rename awx/ui/client/lib/components/{toggle => toggle-tag}/toggle-tag.partial.html (100%) diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index c586cb296e..7beb200a10 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -9,7 +9,7 @@ @import 'relaunchButton/_index'; @import 'tabs/_index'; @import 'tag/_index'; -@import 'toggle/_index'; +@import 'toggle-tag/_index'; @import 'truncate/_index'; @import 'utility/_index'; @import 'code-mirror/_index'; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 27675f598f..aa18751d17 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -33,7 +33,7 @@ import sideNavItem from '~components/layout/side-nav-item.directive'; import tab from '~components/tabs/tab.directive'; import tabGroup from '~components/tabs/group.directive'; import tag from '~components/tag/tag.directive'; -import toggleTag from '~components/toggle/toggle-tag.directive'; +import toggleTag from '~components/toggle-tag/toggle-tag.directive'; import topNavItem from '~components/layout/top-nav-item.directive'; import truncate from '~components/truncate/truncate.directive'; import atCodeMirror from '~components/code-mirror'; diff --git a/awx/ui/client/lib/components/toggle/_index.less b/awx/ui/client/lib/components/toggle-tag/_index.less similarity index 100% rename from awx/ui/client/lib/components/toggle/_index.less rename to awx/ui/client/lib/components/toggle-tag/_index.less diff --git a/awx/ui/client/lib/components/toggle/constants.js b/awx/ui/client/lib/components/toggle-tag/constants.js similarity index 100% rename from awx/ui/client/lib/components/toggle/constants.js rename to awx/ui/client/lib/components/toggle-tag/constants.js diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js b/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js similarity index 93% rename from awx/ui/client/lib/components/toggle/toggle-tag.directive.js rename to awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js index a60c253a8c..1fc7c02990 100644 --- a/awx/ui/client/lib/components/toggle/toggle-tag.directive.js +++ b/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js @@ -1,6 +1,6 @@ import { TRUNCATED, TRUNCATE_LENGTH } from './constants'; -const templateUrl = require('~components/toggle/toggle-tag.partial.html'); +const templateUrl = require('~components/toggle-tag/toggle-tag.partial.html'); function controller ($scope, TagService, strings) { const { tags } = $scope; diff --git a/awx/ui/client/lib/components/toggle/toggle-tag.partial.html b/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html similarity index 100% rename from awx/ui/client/lib/components/toggle/toggle-tag.partial.html rename to awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html From b8404abb2633f585ff46aa83d72beeb096fd4957 Mon Sep 17 00:00:00 2001 From: kialam Date: Mon, 4 Jun 2018 12:32:54 -0400 Subject: [PATCH 136/762] Rename credential tooltip component string - For better legibility --- awx/ui/client/lib/components/components.strings.js | 2 +- awx/ui/client/lib/services/tag.service.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 1be3891669..a56ea9854d 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -58,7 +58,7 @@ function ComponentsStrings (BaseString) { }; ns.tooltips = { - CREDENTIAL: t.s('View the Credential'), + VIEW_THE_CREDENTIAL: t.s('View the Credential'), }; ns.layout = { diff --git a/awx/ui/client/lib/services/tag.service.js b/awx/ui/client/lib/services/tag.service.js index be1a84dbe3..22bdf45ae2 100644 --- a/awx/ui/client/lib/services/tag.service.js +++ b/awx/ui/client/lib/services/tag.service.js @@ -2,12 +2,12 @@ function TagService (strings, $filter) { this.buildCredentialTag = (credential) => { const icon = `${credential.kind}`; const link = `/#/credentials/${credential.id}`; - const tooltip = strings.get('tooltips.CREDENTIAL'); + const tooltip = strings.get('tooltips.VIEW_THE_CREDENTIAL'); const value = $filter('sanitize')(credential.name); return { icon, link, tooltip, value }; }; - this.buildTag = tag => { + this.buildTag = (tag) => { const value = $filter('sanitize')(tag); return { value }; From 6ca4c7de9dc918d194150b47808942f1ca682fa5 Mon Sep 17 00:00:00 2001 From: kialam Date: Tue, 5 Jun 2018 17:40:59 -0400 Subject: [PATCH 137/762] Offload tag formatting logic to `templateList` controller --- .../templates/templatesList.controller.js | 11 +++++++++ .../templates/templatesList.view.html | 18 ++++++++++++--- .../lib/components/list/row-item.partial.html | 12 ++++++++-- .../toggle-tag/toggle-tag.directive.js | 23 ++++++++++--------- .../toggle-tag/toggle-tag.partial.html | 12 +++++----- 5 files changed, 54 insertions(+), 22 deletions(-) diff --git a/awx/ui/client/features/templates/templatesList.controller.js b/awx/ui/client/features/templates/templatesList.controller.js index 4f0ec55116..ad3137e949 100644 --- a/awx/ui/client/features/templates/templatesList.controller.js +++ b/awx/ui/client/features/templates/templatesList.controller.js @@ -165,6 +165,17 @@ function ListTemplatesController( return html; }; + vm.buildCredentialTags = (credentials) => { + return credentials.map(credential => { + const icon = `${credential.kind}`; + const link = `/#/credentials/${credential.id}`; + const tooltip = strings.get('tooltips.VIEW_THE_CREDENTIAL'); + const value = $filter('sanitize')(credential.name); + + return { icon, link, tooltip, value }; + }) + }; + vm.getLastRan = template => { const lastJobRun = _.get(template, 'last_job_run'); diff --git a/awx/ui/client/features/templates/templatesList.view.html b/awx/ui/client/features/templates/templatesList.view.html index 5a00912663..d77910ac1f 100644 --- a/awx/ui/client/features/templates/templatesList.view.html +++ b/awx/ui/client/features/templates/templatesList.view.html @@ -69,11 +69,23 @@ value-link="/#/projects/{{ template.summary_fields.project.id }}"> - + + + + + + + + + + + diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index 69058b1c43..3c50e1fb83 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -42,11 +42,19 @@ template-type="smartStatus.type" ng-if="smartStatus">
-
+ + + + + + + - +
diff --git a/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js b/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js index 1fc7c02990..3cc49b3cea 100644 --- a/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js +++ b/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js @@ -2,8 +2,8 @@ import { TRUNCATED, TRUNCATE_LENGTH } from './constants'; const templateUrl = require('~components/toggle-tag/toggle-tag.partial.html'); -function controller ($scope, TagService, strings) { - const { tags } = $scope; +function controller (strings) { + // const { tags } = $scope; const vm = this; vm.truncatedLength = TRUNCATE_LENGTH; vm.truncated = TRUNCATED; @@ -13,20 +13,20 @@ function controller ($scope, TagService, strings) { vm.truncated = !vm.truncated; }; - vm.tags = []; + // vm.tags = []; // Let the controller handle what type of tag should be passed to the directive // e.g. default tag, crential tag, etc. - Object.keys(tags).forEach(key => { - if ($scope.tagType === 'cred') { - vm.tags.push(TagService.buildCredentialTag(tags[key])); - } else { - vm.tags.push(TagService.buildTag(tags[key])); - } - }); + // Object.keys(tags).forEach(key => { + // if ($scope.tagType === 'cred') { + // vm.tags.push(TagService.buildCredentialTag(tags[key])); + // } else { + // vm.tags.push(TagService.buildTag(tags[key])); + // } + // }); } -controller.$inject = ['$scope', 'TagService', 'ComponentsStrings']; +controller.$inject = ['ComponentsStrings']; function atToggleTag () { return { @@ -39,6 +39,7 @@ function atToggleTag () { scope: { tags: '=', tagType: '@', + tagLength: '@', }, }; } diff --git a/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html b/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html index 7997e0e741..6ab6e2d858 100644 --- a/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html +++ b/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html @@ -1,15 +1,15 @@
-
- - +
+ +
-
- - +
+ +
From b16854898b4853e15c1b573a8680e66184d947b6 Mon Sep 17 00:00:00 2001 From: kialam Date: Wed, 6 Jun 2018 11:13:41 -0400 Subject: [PATCH 138/762] Refactor `Toggle Tag` Component This refactor includes the following things: - Offload credential tag formatting to the parent controller, e.g. `TemplateListController`. - Initialize the `Toggle Tag` component in the parent view instead of the List Item component view. - Rename some variables for better comprehension. --- .../templates/templatesList.controller.js | 2 +- .../templates/templatesList.view.html | 20 ++++-------------- .../lib/components/list/row-item.partial.html | 15 +------------ .../lib/components/toggle-tag/constants.js | 2 +- .../toggle-tag/toggle-tag.directive.js | 21 +++---------------- .../toggle-tag/toggle-tag.partial.html | 21 ++++++++++++------- 6 files changed, 23 insertions(+), 58 deletions(-) diff --git a/awx/ui/client/features/templates/templatesList.controller.js b/awx/ui/client/features/templates/templatesList.controller.js index ad3137e949..b8a29f4db8 100644 --- a/awx/ui/client/features/templates/templatesList.controller.js +++ b/awx/ui/client/features/templates/templatesList.controller.js @@ -164,7 +164,7 @@ function ListTemplatesController( return html; }; - + vm.buildCredentialTags = (credentials) => { return credentials.map(credential => { const icon = `${credential.kind}`; diff --git a/awx/ui/client/features/templates/templatesList.view.html b/awx/ui/client/features/templates/templatesList.view.html index d77910ac1f..5aed695f57 100644 --- a/awx/ui/client/features/templates/templatesList.view.html +++ b/awx/ui/client/features/templates/templatesList.view.html @@ -68,24 +68,12 @@ value="{{ template.summary_fields.project.name }}" value-link="/#/projects/{{ template.summary_fields.project.id }}"> - - - - - - - - - - - - - + tags-are-creds="true"> + + diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index 3c50e1fb83..48e309db8e 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -42,19 +42,6 @@ template-type="smartStatus.type" ng-if="smartStatus">
-
- -
- - - - - - - - - +
diff --git a/awx/ui/client/lib/components/toggle-tag/constants.js b/awx/ui/client/lib/components/toggle-tag/constants.js index e120030b65..f3a4951c9c 100644 --- a/awx/ui/client/lib/components/toggle-tag/constants.js +++ b/awx/ui/client/lib/components/toggle-tag/constants.js @@ -1,2 +1,2 @@ export const TRUNCATE_LENGTH = 5; -export const TRUNCATED = true; +export const IS_TRUNCATED = true; diff --git a/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js b/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js index 3cc49b3cea..4fec4cd794 100644 --- a/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js +++ b/awx/ui/client/lib/components/toggle-tag/toggle-tag.directive.js @@ -1,29 +1,16 @@ -import { TRUNCATED, TRUNCATE_LENGTH } from './constants'; +import { IS_TRUNCATED, TRUNCATE_LENGTH } from './constants'; const templateUrl = require('~components/toggle-tag/toggle-tag.partial.html'); function controller (strings) { - // const { tags } = $scope; const vm = this; vm.truncatedLength = TRUNCATE_LENGTH; - vm.truncated = TRUNCATED; + vm.isTruncated = IS_TRUNCATED; vm.strings = strings; vm.toggle = () => { - vm.truncated = !vm.truncated; + vm.isTruncated = !vm.isTruncated; }; - - // vm.tags = []; - - // Let the controller handle what type of tag should be passed to the directive - // e.g. default tag, crential tag, etc. - // Object.keys(tags).forEach(key => { - // if ($scope.tagType === 'cred') { - // vm.tags.push(TagService.buildCredentialTag(tags[key])); - // } else { - // vm.tags.push(TagService.buildTag(tags[key])); - // } - // }); } controller.$inject = ['ComponentsStrings']; @@ -38,8 +25,6 @@ function atToggleTag () { templateUrl, scope: { tags: '=', - tagType: '@', - tagLength: '@', }, }; } diff --git a/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html b/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html index 6ab6e2d858..fada04154e 100644 --- a/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html +++ b/awx/ui/client/lib/components/toggle-tag/toggle-tag.partial.html @@ -1,16 +1,21 @@
-
-
- - +
+
+
+ +
+ +
+
+
+ +
+
-
-
+
-
-
From d6ca943d23f739b50f5f7598a277b8cef29fff40 Mon Sep 17 00:00:00 2001 From: kialam Date: Wed, 6 Jun 2018 11:15:57 -0400 Subject: [PATCH 139/762] Remove `TagService` --- awx/ui/client/lib/services/index.js | 4 +--- awx/ui/client/lib/services/tag.service.js | 19 ------------------- 2 files changed, 1 insertion(+), 22 deletions(-) delete mode 100644 awx/ui/client/lib/services/tag.service.js diff --git a/awx/ui/client/lib/services/index.js b/awx/ui/client/lib/services/index.js index 41ca37c836..39ed1d8299 100644 --- a/awx/ui/client/lib/services/index.js +++ b/awx/ui/client/lib/services/index.js @@ -2,7 +2,6 @@ import AppStrings from '~services/app.strings'; import BaseStringService from '~services/base-string.service'; import CacheService from '~services/cache.service'; import EventService from '~services/event.service'; -import TagService from '~services/tag.service'; const MODULE_NAME = 'at.lib.services'; @@ -13,7 +12,6 @@ angular .service('AppStrings', AppStrings) .service('BaseStringService', BaseStringService) .service('CacheService', CacheService) - .service('EventService', EventService) - .service('TagService', TagService); + .service('EventService', EventService); export default MODULE_NAME; diff --git a/awx/ui/client/lib/services/tag.service.js b/awx/ui/client/lib/services/tag.service.js deleted file mode 100644 index 22bdf45ae2..0000000000 --- a/awx/ui/client/lib/services/tag.service.js +++ /dev/null @@ -1,19 +0,0 @@ -function TagService (strings, $filter) { - this.buildCredentialTag = (credential) => { - const icon = `${credential.kind}`; - const link = `/#/credentials/${credential.id}`; - const tooltip = strings.get('tooltips.VIEW_THE_CREDENTIAL'); - const value = $filter('sanitize')(credential.name); - - return { icon, link, tooltip, value }; - }; - this.buildTag = (tag) => { - const value = $filter('sanitize')(tag); - - return { value }; - }; -} - -TagService.$inject = ['ComponentsStrings', '$filter']; - -export default TagService; From 8ee4b9680cd8739ef9b429a35c45ff856aed0bf4 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 6 Jun 2018 11:26:15 -0400 Subject: [PATCH 140/762] remove controller_node field from jobs that don't apply --- awx/api/serializers.py | 8 +++-- awx/main/tests/functional/api/test_job.py | 40 ++++++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 2376956da5..6535ba6f56 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1388,7 +1388,7 @@ class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer): class Meta: model = ProjectUpdate - fields = ('*', 'project', 'job_type') + fields = ('*', 'project', 'job_type', '-controller_node') def get_related(self, obj): res = super(ProjectUpdateSerializer, self).get_related(obj) @@ -2098,7 +2098,8 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri class Meta: model = InventoryUpdate - fields = ('*', 'inventory_source', 'license_error', 'source_project_update') + fields = ('*', 'inventory_source', 'license_error', 'source_project_update', + '-controller_node',) def get_related(self, obj): res = super(InventoryUpdateSerializer, self).get_related(obj) @@ -3245,7 +3246,8 @@ class AdHocCommandSerializer(UnifiedJobSerializer): model = AdHocCommand fields = ('*', 'job_type', 'inventory', 'limit', 'credential', 'module_name', 'module_args', 'forks', 'verbosity', 'extra_vars', - 'become_enabled', 'diff_mode', '-unified_job_template', '-description') + 'become_enabled', 'diff_mode', '-unified_job_template', '-description', + '-controller_node',) extra_kwargs = { 'name': { 'read_only': True, diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index 1d5739731d..be8f7134ea 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -9,7 +9,14 @@ from rest_framework.exceptions import PermissionDenied from awx.api.versioning import reverse from awx.api.views import RelatedJobsPreventDeleteMixin, UnifiedJobDeletionMixin -from awx.main.models import JobTemplate, User, Job +from awx.main.models import ( + JobTemplate, + User, + Job, + ProjectUpdate, + AdHocCommand, + InventoryUpdate, +) from crum import impersonate @@ -159,3 +166,34 @@ def test_block_related_unprocessed_events(mocker, organization, project, delete, with mock.patch('awx.api.views.now', lambda: time_of_request): with pytest.raises(PermissionDenied): view.perform_destroy(organization) + + +class TestControllerNode(): + @pytest.fixture + def project_update(self, project): + return ProjectUpdate.objects.create(project=project) + + @pytest.fixture + def job(self): + return JobTemplate.objects.create().create_unified_job() + + @pytest.fixture + def adhoc(self, inventory): + return AdHocCommand.objects.create(inventory=inventory) + + @pytest.mark.django_db + def test_field_controller_node_exists(self, admin_user, job, project_update, inventory_update, adhoc, get): + r = get(reverse('api:unified_job_list') + '?id={}'.format(job.id), admin_user, expect=200) + assert 'controller_node' in r.data['results'][0] + + r = get(job.get_absolute_url(), admin_user, expect=200) + assert 'controller_node' in r.data + + r = get(reverse('api:project_update_detail', kwargs={'pk': project_update.pk}), admin_user, expect=200) + assert 'controller_node' not in r.data + + r = get(reverse('api:ad_hoc_command_detail', kwargs={'pk': adhoc.pk}), admin_user, expect=200) + assert 'controller_node' not in r.data + + r = get(reverse('api:inventory_update_detail', kwargs={'pk': inventory_update.pk}), admin_user, expect=200) + assert 'controller_node' not in r.data From 48c4266cb368fbd43b23ef42f99737d2292f4631 Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Wed, 6 Jun 2018 11:51:02 -0400 Subject: [PATCH 141/762] Remove HIDE CURSOR and HIDE BUTTONS from the key --- .../src/network-ui/network-nav/network.nav.view.html | 8 -------- 1 file changed, 8 deletions(-) diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.view.html b/awx/ui/client/src/network-ui/network-nav/network.nav.view.html index 1e29d9a083..2de980e748 100644 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.view.html +++ b/awx/ui/client/src/network-ui/network-nav/network.nav.view.html @@ -39,14 +39,6 @@
d
DEBUG MODE
-
-
p
-
HIDE CURSOR
-
-
-
b
-
HIDE BUTTONS
-
i
HIDE INTERFACES
From decfc8fa8c016c8d7862558c262546bac1dad473 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 6 Jun 2018 11:57:40 -0400 Subject: [PATCH 142/762] Update limit-panels directive to query for the new Panels component --- .../client/src/shared/limit-panels/limit-panels.directive.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/shared/limit-panels/limit-panels.directive.js b/awx/ui/client/src/shared/limit-panels/limit-panels.directive.js index 2471a1cf29..a2ee0a4a9d 100644 --- a/awx/ui/client/src/shared/limit-panels/limit-panels.directive.js +++ b/awx/ui/client/src/shared/limit-panels/limit-panels.directive.js @@ -10,9 +10,10 @@ export default [function() { const maxPanels = parseInt(scope.maxPanels); scope.$watch( - () => angular.element('#' + scope.panelContainer).find('.Panel').length, + () => angular.element('#' + scope.panelContainer).find('.Panel, .at-Panel').length, () => { - const panels = angular.element('#' + scope.panelContainer).find('.Panel'); + const panels = angular.element('#' + scope.panelContainer).find('.Panel, .at-Panel'); + if(panels.length > maxPanels) { // hide the excess panels $(panels).each(function( index ) { From d0249d3ae6caaf37af5f1bb3d103dc9a2651c282 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 6 Jun 2018 13:48:35 -0400 Subject: [PATCH 143/762] sprinkle more i18n for certain field labels see: https://github.com/ansible/tower/issues/2063 --- awx/api/serializers.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 2376956da5..eea9d26eae 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1147,7 +1147,19 @@ class OAuth2ApplicationSerializer(BaseSerializer): extra_kwargs = { 'user': {'allow_null': True, 'required': False}, 'organization': {'allow_null': False}, - 'authorization_grant_type': {'allow_null': False} + 'authorization_grant_type': {'allow_null': False, 'label': _('Authorization Grant Type')}, + 'client_secret': { + 'label': _('Client Secret') + }, + 'client_type': { + 'label': _('Client Type') + }, + 'redirect_uris': { + 'label': _('Redirect URIs') + }, + 'skip_authorization': { + 'label': _('Skip Authorization') + }, } def to_representation(self, obj): @@ -4476,16 +4488,19 @@ class InstanceGroupSerializer(BaseSerializer): # both defining field details here and also getting the field's help_text policy_instance_percentage = serializers.IntegerField( default=0, min_value=0, max_value=100, required=False, initial=0, + label=_('Policy Instance Percentage'), help_text=_("Minimum percentage of all instances that will be automatically assigned to " "this group when new instances come online.") ) policy_instance_minimum = serializers.IntegerField( default=0, min_value=0, required=False, initial=0, + label=_('Policy Instance Minimum'), help_text=_("Static minimum number of Instances that will be automatically assign to " "this group when new instances come online.") ) policy_instance_list = serializers.ListField( child=serializers.CharField(), required=False, + label=_('Policy Instance List'), help_text=_("List of exact-match Instances that will be assigned to this group") ) From 6b1129ce1d216fb8f78d6435efd05f9ba42f2104 Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Wed, 6 Jun 2018 14:29:32 -0400 Subject: [PATCH 144/762] fixed job details search bar when empty input or duplicate input --- awx/ui/client/features/output/search.component.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/awx/ui/client/features/output/search.component.js b/awx/ui/client/features/output/search.component.js index bc446316f3..490b7c283e 100644 --- a/awx/ui/client/features/output/search.component.js +++ b/awx/ui/client/features/output/search.component.js @@ -64,9 +64,21 @@ function removeSearchTag (index) { } function submitSearch () { + // empty input, not submit new search, return. + if (!vm.value) { + return; + } + const currentQueryset = getCurrentQueryset(); + // check duplicate , see if search input already exists in current search tags + if (currentQueryset.search) { + if (currentQueryset.search.includes(vm.value)) { + return; + } + } const searchInputQueryset = qs.getSearchInputQueryset(vm.value, isFilterable); + const modifiedQueryset = qs.mergeQueryset(currentQueryset, searchInputQueryset); reloadQueryset(modifiedQueryset, strings.get('search.REJECT_INVALID')); From 31da1e6ef7b16bdaf95bdc8ea97e564135d9e085 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 6 Jun 2018 14:46:43 -0400 Subject: [PATCH 145/762] Audit string translations in instance groups templates --- .../capacity-adjuster/capacity-adjuster.directive.js | 9 +++++---- .../src/instance-groups/instance-groups.strings.js | 11 ++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js index e36916aca8..678b47d078 100644 --- a/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js +++ b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js @@ -1,4 +1,4 @@ -function CapacityAdjuster (templateUrl, ProcessErrors, Wait) { +function CapacityAdjuster (templateUrl, ProcessErrors, Wait, strings) { return { scope: { state: '=', @@ -9,10 +9,10 @@ function CapacityAdjuster (templateUrl, ProcessErrors, Wait) { replace: true, link: function(scope) { const adjustment_values = [{ - label: 'CPU', + label: strings.get('capacityAdjuster.CPU'), value: scope.state.cpu_capacity, },{ - label: 'RAM', + label: strings.get('capacityAdjuster.RAM'), value: scope.state.mem_capacity }]; @@ -51,7 +51,8 @@ function CapacityAdjuster (templateUrl, ProcessErrors, Wait) { CapacityAdjuster.$inject = [ 'templateUrl', 'ProcessErrors', - 'Wait' + 'Wait', + 'InstanceGroupsStrings' ]; export default CapacityAdjuster; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instance-groups.strings.js b/awx/ui/client/src/instance-groups/instance-groups.strings.js index e21ff78396..6e4cb2c66d 100644 --- a/awx/ui/client/src/instance-groups/instance-groups.strings.js +++ b/awx/ui/client/src/instance-groups/instance-groups.strings.js @@ -10,7 +10,11 @@ function InstanceGroupsStrings (BaseString) { }; ns.list = { - PANEL_TITLE: t.s('INSTANCE GROUPS') + PANEL_TITLE: t.s('INSTANCE GROUPS'), + ROW_ITEM_LABEL_INSTANCES: t.s('Instances'), + ROW_ITEM_LABEL_RUNNING_JOBS: t.s('Running Jobs'), + ROW_ITEM_LABEL_TOTAL_JOBS: t.s('Total Jobs'), + ROW_ITEM_LABEL_USED_CAPACITY: t.s('Used Capacity') }; ns.tab = { @@ -33,6 +37,11 @@ function InstanceGroupsStrings (BaseString) { IS_OFFLINE_LABEL: t.s('Unavailable') }; + ns.capacityAdjuster = { + CPU: t.s('CPU'), + RAM: t.s('RAM') + }; + ns.jobs = { PANEL_TITLE: t.s('Jobs') }; From c8ab4d46239fc467623af5755ea6a81b0d6cb629 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 6 Jun 2018 14:52:36 -0400 Subject: [PATCH 146/762] Add link to running and total jobs for instance groups and instances --- .../jobs/routes/instanceGroupJobs.route.js | 3 +-- .../features/jobs/routes/instanceJobs.route.js | 2 +- .../lib/components/list/row-item.partial.html | 2 +- .../instance-groups/instance-groups.partial.html | 4 +--- .../instances/instances-list.partial.html | 13 ++++++++++--- .../list/instance-groups-list.partial.html | 16 +++++++++++----- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/awx/ui/client/features/jobs/routes/instanceGroupJobs.route.js b/awx/ui/client/features/jobs/routes/instanceGroupJobs.route.js index 7bfda756bc..fe86b78774 100644 --- a/awx/ui/client/features/jobs/routes/instanceGroupJobs.route.js +++ b/awx/ui/client/features/jobs/routes/instanceGroupJobs.route.js @@ -16,8 +16,7 @@ export default { job_search: { value: { page_size: '10', - order_by: '-id', - status: 'running' + order_by: '-finished' }, dynamic: true } diff --git a/awx/ui/client/features/jobs/routes/instanceJobs.route.js b/awx/ui/client/features/jobs/routes/instanceJobs.route.js index 06019bcf22..c7ec885e96 100644 --- a/awx/ui/client/features/jobs/routes/instanceJobs.route.js +++ b/awx/ui/client/features/jobs/routes/instanceJobs.route.js @@ -9,7 +9,7 @@ export default { name: 'instanceGroups.instanceJobs', url: '/:instance_group_id/instances/:instance_id/jobs', ncyBreadcrumb: { - parent: 'instanceGroups.edit', + parent: 'instanceGroups.instances', label: N_('JOBS') }, views: { diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index 7698ba85a9..6f3306d393 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -27,7 +27,7 @@ {{ labelValue }}
{{ value }} diff --git a/awx/ui/client/src/instance-groups/instance-groups.partial.html b/awx/ui/client/src/instance-groups/instance-groups.partial.html index bdc05274b6..562676ffdc 100644 --- a/awx/ui/client/src/instance-groups/instance-groups.partial.html +++ b/awx/ui/client/src/instance-groups/instance-groups.partial.html @@ -3,12 +3,10 @@
-
+
-
-
diff --git a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html index 01868ef35d..ecb3d8e600 100644 --- a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html +++ b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html @@ -61,17 +61,24 @@
+ +
- +
diff --git a/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html b/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html index 5aa48b1f29..7e07fb4d5f 100644 --- a/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html +++ b/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html @@ -45,25 +45,31 @@
- + +
- +
From 74155dfc9d4b31e7d2003f31fae359d53bcc9297 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 6 Jun 2018 15:04:41 -0400 Subject: [PATCH 147/762] add system jobs to controller_node exceptions --- awx/api/serializers.py | 2 +- awx/main/tests/functional/api/test_job.py | 12 +++++-- .../api/test_unified_jobs_stdout.py | 25 -------------- awx/main/tests/functional/conftest.py | 33 +++++++++++++++++++ 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 6535ba6f56..41d29d06ce 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3356,7 +3356,7 @@ class SystemJobSerializer(UnifiedJobSerializer): class Meta: model = SystemJob - fields = ('*', 'system_job_template', 'job_type', 'extra_vars', 'result_stdout') + fields = ('*', 'system_job_template', 'job_type', 'extra_vars', 'result_stdout', '-controller_node',) def get_related(self, obj): res = super(SystemJobSerializer, self).get_related(obj) diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index be8f7134ea..00114e044d 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -13,9 +13,8 @@ from awx.main.models import ( JobTemplate, User, Job, - ProjectUpdate, AdHocCommand, - InventoryUpdate, + ProjectUpdate, ) from crum import impersonate @@ -182,7 +181,11 @@ class TestControllerNode(): return AdHocCommand.objects.create(inventory=inventory) @pytest.mark.django_db - def test_field_controller_node_exists(self, admin_user, job, project_update, inventory_update, adhoc, get): + def test_field_controller_node_exists(self, sqlite_copy_expert, + admin_user, job, project_update, + inventory_update, adhoc, get, system_job_factory): + system_job = system_job_factory() + r = get(reverse('api:unified_job_list') + '?id={}'.format(job.id), admin_user, expect=200) assert 'controller_node' in r.data['results'][0] @@ -197,3 +200,6 @@ class TestControllerNode(): r = get(reverse('api:inventory_update_detail', kwargs={'pk': inventory_update.pk}), admin_user, expect=200) assert 'controller_node' not in r.data + + r = get(reverse('api:system_job_detail', kwargs={'pk': system_job.pk}), admin_user, expect=200) + assert 'controller_node' not in r.data diff --git a/awx/main/tests/functional/api/test_unified_jobs_stdout.py b/awx/main/tests/functional/api/test_unified_jobs_stdout.py index 3f1a5760fc..b465f92606 100644 --- a/awx/main/tests/functional/api/test_unified_jobs_stdout.py +++ b/awx/main/tests/functional/api/test_unified_jobs_stdout.py @@ -3,11 +3,8 @@ import base64 import json import re -import shutil -import tempfile from django.conf import settings -from django.db.backends.sqlite3.base import SQLiteCursorWrapper import mock import pytest @@ -31,28 +28,6 @@ def _mk_inventory_update(): return iu -@pytest.fixture(scope='function') -def sqlite_copy_expert(request): - # copy_expert is postgres-specific, and SQLite doesn't support it; mock its - # behavior to test that it writes a file that contains stdout from events - path = tempfile.mkdtemp(prefix='job-event-stdout') - - def write_stdout(self, sql, fd): - # simulate postgres copy_expert support with ORM code - parts = sql.split(' ') - tablename = parts[parts.index('from') + 1] - for cls in (JobEvent, AdHocCommandEvent, ProjectUpdateEvent, - InventoryUpdateEvent, SystemJobEvent): - if cls._meta.db_table == tablename: - for event in cls.objects.order_by('start_line').all(): - fd.write(event.stdout.encode('utf-8')) - - setattr(SQLiteCursorWrapper, 'copy_expert', write_stdout) - request.addfinalizer(lambda: shutil.rmtree(path)) - request.addfinalizer(lambda: delattr(SQLiteCursorWrapper, 'copy_expert')) - return path - - @pytest.mark.django_db @pytest.mark.parametrize('Parent, Child, relation, view', [ [Job, JobEvent, 'job', 'api:job_stdout'], diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 28d7b65564..4b91564312 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -4,6 +4,8 @@ import mock import json import os import six +import tempfile +import shutil from datetime import timedelta from six.moves import xrange @@ -14,6 +16,7 @@ from django.utils import timezone from django.contrib.auth.models import User from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder +from django.db.backends.sqlite3.base import SQLiteCursorWrapper from jsonbfield.fields import JSONField # AWX @@ -44,6 +47,13 @@ from awx.main.models.notifications import ( NotificationTemplate, Notification ) +from awx.main.models.events import ( + JobEvent, + AdHocCommandEvent, + ProjectUpdateEvent, + InventoryUpdateEvent, + SystemJobEvent, +) from awx.main.models.workflow import WorkflowJobTemplate from awx.main.models.ad_hoc_commands import AdHocCommand from awx.main.models.oauth import OAuth2Application as Application @@ -729,3 +739,26 @@ def oauth_application(admin): name='test app', user=admin, client_type='confidential', authorization_grant_type='password' ) + + +@pytest.fixture +def sqlite_copy_expert(request): + # copy_expert is postgres-specific, and SQLite doesn't support it; mock its + # behavior to test that it writes a file that contains stdout from events + path = tempfile.mkdtemp(prefix='job-event-stdout') + + def write_stdout(self, sql, fd): + # simulate postgres copy_expert support with ORM code + parts = sql.split(' ') + tablename = parts[parts.index('from') + 1] + for cls in (JobEvent, AdHocCommandEvent, ProjectUpdateEvent, + InventoryUpdateEvent, SystemJobEvent): + if cls._meta.db_table == tablename: + for event in cls.objects.order_by('start_line').all(): + fd.write(event.stdout.encode('utf-8')) + + setattr(SQLiteCursorWrapper, 'copy_expert', write_stdout) + request.addfinalizer(lambda: shutil.rmtree(path)) + request.addfinalizer(lambda: delattr(SQLiteCursorWrapper, 'copy_expert')) + return path + From 0702692ca9179e2fa95dc27c4acb453cdd158a43 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Wed, 6 Jun 2018 16:26:22 -0400 Subject: [PATCH 148/762] add controller_node to adhoc command job --- awx/api/serializers.py | 3 +-- awx/main/tests/functional/api/test_job.py | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3f674dae7a..c6ce645c5a 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3258,8 +3258,7 @@ class AdHocCommandSerializer(UnifiedJobSerializer): model = AdHocCommand fields = ('*', 'job_type', 'inventory', 'limit', 'credential', 'module_name', 'module_args', 'forks', 'verbosity', 'extra_vars', - 'become_enabled', 'diff_mode', '-unified_job_template', '-description', - '-controller_node',) + 'become_enabled', 'diff_mode', '-unified_job_template', '-description') extra_kwargs = { 'name': { 'read_only': True, diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index 00114e044d..fce3c6fb5a 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -192,10 +192,10 @@ class TestControllerNode(): r = get(job.get_absolute_url(), admin_user, expect=200) assert 'controller_node' in r.data - r = get(reverse('api:project_update_detail', kwargs={'pk': project_update.pk}), admin_user, expect=200) - assert 'controller_node' not in r.data - r = get(reverse('api:ad_hoc_command_detail', kwargs={'pk': adhoc.pk}), admin_user, expect=200) + assert 'controller_node' in r.data + + r = get(reverse('api:project_update_detail', kwargs={'pk': project_update.pk}), admin_user, expect=200) assert 'controller_node' not in r.data r = get(reverse('api:inventory_update_detail', kwargs={'pk': inventory_update.pk}), admin_user, expect=200) From f8f59c8c8ca8dc6e98685499097bd3d7fb69dbca Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 5 Jun 2018 22:07:44 -0400 Subject: [PATCH 149/762] add host_status, play, and task counts to job details --- awx/api/serializers.py | 44 ++++++++++++++++- awx/api/views.py | 2 +- .../api/serializers/test_job_serializers.py | 48 +++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 6ed8026960..38eb617db4 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -9,7 +9,7 @@ import operator import re import six import urllib -from collections import OrderedDict +from collections import defaultdict, OrderedDict from datetime import timedelta # OAuth2 @@ -3130,6 +3130,48 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): return summary_fields +class JobDetailSerializer(JobSerializer): + + host_status_counts = serializers.SerializerMethodField( + help_text=_('A count of hosts uniquely assigned to each status.'), + ) + playbook_counts = serializers.SerializerMethodField( + help_text=_('A count of all plays and tasks for the job run.'), + ) + + class Meta: + model = Job + fields = ('*', 'host_status_counts', 'playbook_counts',) + + def get_playbook_counts(self, obj): + task_count = obj.job_events.filter(event='playbook_on_task_start').count() + play_count = obj.job_events.filter(event='playbook_on_play_start').count() + + data = {'play_count': play_count, 'task_count': task_count} + + return data + + def get_host_status_counts(self, obj): + try: + event_data = obj.job_events.only('event_data').get(event='playbook_on_stats').event_data + except JobEvent.DoesNotExist: + event_data = {} + + host_status = {} + host_status_keys = ['skipped', 'ok', 'changed', 'failures', 'dark'] + + for key in host_status_keys: + for host in event_data.get(key, {}): + host_status[host] = key + + host_status_counts = defaultdict(lambda: 0) + + for value in host_status.values(): + host_status_counts[value] += 1 + + return host_status_counts + + class JobCancelSerializer(BaseSerializer): can_cancel = serializers.BooleanField(read_only=True) diff --git a/awx/api/views.py b/awx/api/views.py index 9365062fe0..f7a939e839 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -4080,7 +4080,7 @@ class JobDetail(UnifiedJobDeletionMixin, RetrieveUpdateDestroyAPIView): model = Job metadata_class = JobTypeMetadata - serializer_class = JobSerializer + serializer_class = JobDetailSerializer def update(self, request, *args, **kwargs): obj = self.get_object() diff --git a/awx/main/tests/unit/api/serializers/test_job_serializers.py b/awx/main/tests/unit/api/serializers/test_job_serializers.py index 3c1529cba1..d3fd514ecc 100644 --- a/awx/main/tests/unit/api/serializers/test_job_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_job_serializers.py @@ -1,4 +1,5 @@ # Python +from collections import namedtuple import pytest import mock import json @@ -7,6 +8,7 @@ from six.moves import xrange # AWX from awx.api.serializers import ( + JobDetailSerializer, JobSerializer, JobOptionsSerializer, ) @@ -14,6 +16,7 @@ from awx.api.serializers import ( from awx.main.models import ( Label, Job, + JobEvent, ) @@ -53,6 +56,7 @@ def jobs(mocker): @mock.patch('awx.api.serializers.UnifiedJobTemplateSerializer.get_related', lambda x,y: {}) @mock.patch('awx.api.serializers.JobOptionsSerializer.get_related', lambda x,y: {}) class TestJobSerializerGetRelated(): + @pytest.mark.parametrize("related_resource_name", [ 'job_events', 'relaunch', @@ -76,6 +80,7 @@ class TestJobSerializerGetRelated(): @mock.patch('awx.api.serializers.BaseSerializer.to_representation', lambda self,obj: { 'extra_vars': obj.extra_vars}) class TestJobSerializerSubstitution(): + def test_survey_password_hide(self, mocker): job = mocker.MagicMock(**{ 'display_extra_vars.return_value': '{\"secret_key\": \"$encrypted$\"}', @@ -90,6 +95,7 @@ class TestJobSerializerSubstitution(): @mock.patch('awx.api.serializers.BaseSerializer.get_summary_fields', lambda x,y: {}) class TestJobOptionsSerializerGetSummaryFields(): + def test__summary_field_labels_10_max(self, mocker, job_template, labels): job_template.labels.all = mocker.MagicMock(**{'return_value': labels}) @@ -101,3 +107,45 @@ class TestJobOptionsSerializerGetSummaryFields(): def test_labels_exists(self, test_get_summary_fields, job_template): test_get_summary_fields(JobOptionsSerializer, job_template, 'labels') + + +class TestJobDetailSerializerGetHostStatusCountFields(object): + + def test_hosts_are_counted_once(self, job, mocker): + mock_event = JobEvent(**{ + 'event': 'playbook_on_stats', + 'event_data': { + 'skipped': { + 'localhost': 2, + 'fiz': 1, + }, + 'ok': { + 'localhost': 1, + 'foo': 2, + }, + 'changed': { + 'localhost': 1, + 'bar': 3, + }, + 'dark': { + 'localhost': 2, + 'fiz': 2, + } + } + }) + + mock_qs = namedtuple('mock_qs', ['get'])(mocker.MagicMock(return_value=mock_event)) + job.job_events.only = mocker.MagicMock(return_value=mock_qs) + + serializer = JobDetailSerializer() + host_status_counts = serializer.get_host_status_counts(job) + + assert host_status_counts == {'ok': 1, 'changed': 1, 'dark': 2} + + def test_host_status_counts_is_empty_dict_without_stats_event(self, job, mocker): + job.job_events = JobEvent.objects.none() + + serializer = JobDetailSerializer() + host_status_counts = serializer.get_host_status_counts(job) + + assert host_status_counts == {} From a108238e0aabed73e363abe0c16eff3bf53a291e Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 6 Jun 2018 18:32:09 -0400 Subject: [PATCH 150/762] Changed jt edit api call from put to patch so that we don't clear the credentials every time a change is made. Removed unneeded code. --- .../job-template-edit.controller.js | 24 ++----------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js index 85bc0882a2..14809fcf52 100644 --- a/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js +++ b/awx/ui/client/src/templates/job_templates/edit-job-template/job-template-edit.controller.js @@ -643,22 +643,7 @@ export default data.ask_credential_on_launch = $scope.ask_credential_on_launch ? $scope.ask_credential_on_launch : false; data.job_tags = (Array.isArray($scope.job_tags)) ? $scope.job_tags.join() : ""; data.skip_tags = (Array.isArray($scope.skip_tags)) ? $scope.skip_tags.join() : ""; - if ($scope.selectedCredentials && $scope.selectedCredentials - .machine && $scope.selectedCredentials - .machine.id) { - data.credential = $scope.selectedCredentials - .machine.id; - } else { - data.credential = null; - } - if ($scope.selectedCredentials && $scope.selectedCredentials - .vault && $scope.selectedCredentials - .vault.id) { - data.vault_credential = $scope.selectedCredentials - .vault.id; - } else { - data.vault_credential = null; - } + data.extra_vars = ToJSON($scope.parseType, $scope.extra_vars, true); @@ -699,13 +684,8 @@ export default data.job_tags = (Array.isArray($scope.job_tags)) ? _.uniq($scope.job_tags).join() : ""; data.skip_tags = (Array.isArray($scope.skip_tags)) ? _.uniq($scope.skip_tags).join() : ""; - // drop legacy 'credential' and 'vault_credential' keys from the update request as they will - // be provided to the related credentials endpoint by the template save success handler. - delete data.credential; - delete data.vault_credential; - Rest.setUrl(defaultUrl + $state.params.job_template_id); - Rest.put(data) + Rest.patch(data) .then(({data}) => { $scope.$emit('templateSaveSuccess', data); }) From 4fa1518bcc9670440839be7f47fccb1548cc630b Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Wed, 6 Jun 2018 18:18:19 -0700 Subject: [PATCH 151/762] Sets default zoom to 100% on network UI instead of 120% --- awx/network_ui/consumers.py | 3 +-- awx/network_ui/tests/functional/test_consumers.py | 2 +- awx/network_ui/tests/functional/test_models.py | 2 +- awx/ui/client/src/network-ui/hotkeys.fsm.js | 4 +--- awx/ui/client/src/network-ui/network.ui.controller.js | 2 +- 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/awx/network_ui/consumers.py b/awx/network_ui/consumers.py index 8b062883f3..227546c095 100644 --- a/awx/network_ui/consumers.py +++ b/awx/network_ui/consumers.py @@ -241,7 +241,7 @@ def ws_connect(message): if topology_id is not None: topology = Topology.objects.get(pk=topology_id) else: - topology = Topology(name="topology", scale=1.0, panX=0, panY=0) + topology = Topology(name="topology", scale=0.7, panX=0, panY=0) topology.save() TopologyInventory(inventory_id=inventory_id, topology_id=topology.pk).save() topology_id = topology.pk @@ -322,4 +322,3 @@ def ws_message(message): def ws_disconnect(message): if 'topology_id' in message.channel_session: channels.Group("topology-%s" % message.channel_session['topology_id']).discard(message.reply_channel) - diff --git a/awx/network_ui/tests/functional/test_consumers.py b/awx/network_ui/tests/functional/test_consumers.py index e60f028651..3b6c0c38d3 100644 --- a/awx/network_ui/tests/functional/test_consumers.py +++ b/awx/network_ui/tests/functional/test_consumers.py @@ -196,7 +196,7 @@ def test_ws_connect_new_topology(): mock.patch.object(Inventory, 'objects') as inventory_objects: client_mock.uuid4 = mock.MagicMock(return_value="777") topology_mock.return_value = Topology( - name="topology", scale=1.0, panX=0, panY=0, pk=999) + name="topology", scale=0.7, panX=0, panY=0, pk=999) inventory_objects.get.return_value = mock.Mock(admin_role=[mock_user]) awx.network_ui.consumers.ws_connect(message) message.reply_channel.send.assert_has_calls([ diff --git a/awx/network_ui/tests/functional/test_models.py b/awx/network_ui/tests/functional/test_models.py index 8592061672..8d439dca22 100644 --- a/awx/network_ui/tests/functional/test_models.py +++ b/awx/network_ui/tests/functional/test_models.py @@ -29,7 +29,7 @@ def test_deletion(): host1 = inv.hosts.create(name='foo') host2 = inv.hosts.create(name='bar') topology = Topology.objects.create( - name='inv', scale=1.0, panX=0.0, panY=0.0 + name='inv', scale=0.7, panX=0.0, panY=0.0 ) inv.topologyinventory_set.create(topology=topology) device1 = topology.device_set.create(name='foo', host=host1, x=0.0, y=0.0, cid=1) diff --git a/awx/ui/client/src/network-ui/hotkeys.fsm.js b/awx/ui/client/src/network-ui/hotkeys.fsm.js index abc3a3cc75..410fba8ba7 100644 --- a/awx/ui/client/src/network-ui/hotkeys.fsm.js +++ b/awx/ui/client/src/network-ui/hotkeys.fsm.js @@ -62,7 +62,7 @@ _Enabled.prototype.onKeyDown = function(controller, msg_type, $event) { } if ($event.key === '0') { - scope.jump_to_animation(0, 0, 1.0); + scope.jump_to_animation(0, 0, 0.7); } controller.delegate_channel.send(msg_type, $event); @@ -83,5 +83,3 @@ _Disabled.prototype.onEnable = function (controller) { }; _Disabled.prototype.onEnable.transitions = ['Enabled']; - - diff --git a/awx/ui/client/src/network-ui/network.ui.controller.js b/awx/ui/client/src/network-ui/network.ui.controller.js index 8e753e4e19..c7e066553f 100644 --- a/awx/ui/client/src/network-ui/network.ui.controller.js +++ b/awx/ui/client/src/network-ui/network.ui.controller.js @@ -81,7 +81,7 @@ var NetworkUIController = function($scope, $scope.onMouseLeaveResult = ""; $scope.onMouseMoveResult = ""; $scope.onMouseMoveResult = ""; - $scope.current_scale = 1.0; + $scope.current_scale = 0.7; $scope.current_mode = null; $scope.panX = 0; $scope.panY = 0; From dde706b61f617d0e955bddbe767f42349cb12542 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Wed, 6 Jun 2018 15:22:01 -0400 Subject: [PATCH 152/762] allow no-op case when modifying deprecated credentials --- awx/api/serializers.py | 36 +++++++++-------- .../test_deprecated_credential_assignment.py | 40 +++++++++++++++++++ awx/main/tests/functional/conftest.py | 4 +- docs/multi_credential_assignment.md | 14 +++++-- 4 files changed, 74 insertions(+), 20 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 2376956da5..3037daf69b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2038,14 +2038,14 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt def _update_deprecated_fields(self, fields, obj): if 'credential' in fields: new_cred = fields['credential'] - existing_creds = obj.credentials.exclude(credential_type__kind='vault') - for cred in existing_creds: - # Remove all other cloud credentials - if cred != new_cred: + existing = obj.credentials.all() + if new_cred not in existing: + for cred in existing: + # Remove all other cloud credentials obj.credentials.remove(cred) - if new_cred: - # Add new credential - obj.credentials.add(new_cred) + if new_cred: + # Add new credential + obj.credentials.add(new_cred) def validate(self, attrs): deprecated_fields = {} @@ -2834,11 +2834,12 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): ('network_credential', obj.network_credentials), ): if key in fields: - for cred in existing: - obj.credentials.remove(cred) - if fields[key]: - obj.credentials.add(fields[key]) - obj.save() + new_cred = fields[key] + if new_cred not in existing: + for cred in existing: + obj.credentials.remove(cred) + if new_cred: + obj.credentials.add(new_cred) def validate(self, attrs): v1_credentials = {} @@ -3675,10 +3676,13 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): deprecated_fields['credential'] = validated_data.pop('credential') obj = super(WorkflowJobTemplateNodeSerializer, self).update(obj, validated_data) if 'credential' in deprecated_fields: - for cred in obj.credentials.filter(credential_type__kind='ssh'): - obj.credentials.remove(cred) - if deprecated_fields['credential']: - obj.credentials.add(deprecated_fields['credential']) + existing = obj.credentials.filter(credential_type__kind='ssh') + new_cred = deprecated_fields['credential'] + if new_cred not in existing: + for cred in existing: + obj.credentials.remove(cred) + if new_cred: + obj.credentials.add(new_cred) return obj diff --git a/awx/main/tests/functional/api/test_deprecated_credential_assignment.py b/awx/main/tests/functional/api/test_deprecated_credential_assignment.py index f6466affd7..8dbb3a391c 100644 --- a/awx/main/tests/functional/api/test_deprecated_credential_assignment.py +++ b/awx/main/tests/functional/api/test_deprecated_credential_assignment.py @@ -394,3 +394,43 @@ def test_inventory_source_invalid_deprecated_credential(patch, admin, ec2_source url = reverse('api:inventory_source_detail', kwargs={'pk': ec2_source.pk}) resp = patch(url, {'credential': 999999}, admin, expect=400) assert 'Credential 999999 does not exist' in resp.content + + +@pytest.mark.django_db +def test_deprecated_credential_activity_stream(patch, admin_user, machine_credential, job_template): + job_template.credentials.add(machine_credential) + starting_entries = job_template.activitystream_set.count() + # no-op patch + patch( + job_template.get_absolute_url(), + admin_user, + data={'credential': machine_credential.pk}, + expect=200 + ) + # no-op should not produce activity stream entries + assert starting_entries == job_template.activitystream_set.count() + + +@pytest.mark.django_db +def test_multi_vault_preserved_on_put(get, put, admin_user, job_template, vault_credential): + ''' + A PUT request will necessarily specify deprecated fields, but if the deprecated + field is a singleton while the `credentials` relation has many, that makes + it very easy to drop those credentials not specified in the PUT data + ''' + vault2 = Credential.objects.create( + name='second-vault', + credential_type=vault_credential.credential_type, + inputs={'vault_password': 'foo', 'vault_id': 'foo'} + ) + job_template.credentials.add(vault_credential, vault2) + assert job_template.credentials.count() == 2 # sanity check + r = get(job_template.get_absolute_url(), admin_user, expect=200) + # should be a no-op PUT request + put( + job_template.get_absolute_url(), + admin_user, + data=r.data, + expect=200 + ) + assert job_template.credentials.count() == 2 diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index 28d7b65564..cb12f15843 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -553,7 +553,9 @@ def _request(verb): response.data[key] = str(value) except Exception: response.data = data_copy - assert response.status_code == expect + assert response.status_code == expect, 'Response data: {}'.format( + getattr(response, 'data', None) + ) if hasattr(response, 'render'): response.render() __SWAGGER_REQUESTS__.setdefault(request.path, {})[ diff --git a/docs/multi_credential_assignment.md b/docs/multi_credential_assignment.md index f32203860c..3fbb487428 100644 --- a/docs/multi_credential_assignment.md +++ b/docs/multi_credential_assignment.md @@ -93,9 +93,17 @@ as they did before: `PATCH /api/v2/job_templates/N/ {'credential': X, 'vault_credential': Y}` -Under this model, when a JobTemplate with multiple vault Credentials is updated -in this way, the new underlying list will _only_ contain the single Vault -Credential specified in the deprecated request. +If the job template (with pk=N) only has 1 vault credential, +that will be replaced with the new `Y` vault credential. + +If the job template has multiple vault credentials, and these do not include +`Y`, then the new list will _only_ contain the single vault credential `Y` +specified in the deprecated request. + +If the JobTemplate already has the `Y` vault credential associated with it, +then no change will take effect (the other vault credentials will not be +removed in this case). This is so that clients making deprecated requests +do not interfere with clients using the new `credentials` relation. `GET` requests to `/api/v2/job_templates/N/` and `/api/v2/jobs/N/` have traditionally included a variety of metadata in the response via From 9ff995f6f9779cf5826bf8686cc7b864ac874baa Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Thu, 7 Jun 2018 10:43:44 -0400 Subject: [PATCH 153/762] reset Export dropdown after user takes action in Network UI --- .../client/src/network-ui/network-nav/network.nav.controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js b/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js index fba7aa1058..f6882c2011 100644 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js +++ b/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js @@ -85,6 +85,7 @@ function NetworkingController (models, $state, $scope, strings) { //Handlers for actions drop down $('#networking-actionsDropdown').on('select2:select', (e) => { $scope.$broadcast('awxNet-toolbarButtonEvent', e.params.data.title); + $("#networking-actionsDropdown").val(null).trigger('change'); }); $('#networking-actionsDropdown').on('select2:open', () => { From 195aff37ad812fd7805ff726ad71cef691fdb993 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 7 Jun 2018 11:19:21 -0400 Subject: [PATCH 154/762] default instance capacity to zero at registration/insertion time if the first health check fails due to AMQP or celery issues, the capacity will stay at the default of 100 (which is confusing) see: https://github.com/ansible/tower/issues/2085 --- awx/main/managers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/managers.py b/awx/main/managers.py index d2af95e2b8..f31a532572 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -96,7 +96,7 @@ class InstanceManager(models.Manager): instance = self.filter(hostname=hostname) if instance.exists(): return (False, instance[0]) - instance = self.create(uuid=uuid, hostname=hostname) + instance = self.create(uuid=uuid, hostname=hostname, capacity=0) return (True, instance) def get_or_register(self): From 99ea28c3fd095eefa8d3b124a84c92015dcdb780 Mon Sep 17 00:00:00 2001 From: kialam Date: Fri, 1 Jun 2018 16:39:04 -0400 Subject: [PATCH 155/762] Check `defaults` against `query set` to make sure `credential_type` does not get overwritten - We want to preserve the existing `credential_type` param that already exists in `queryset` and not have `defaults ` overwrite it. We first loop through the `defaults` object keys and check it against the the keys of `queryset` and if the keys match, then set the value to `queryset[key]`. --- .../src/shared/smart-search/smart-search.controller.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js index ad14dd5344..69b5ee9b02 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.controller.js +++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js @@ -235,11 +235,15 @@ function SmartSearchController ( }; $scope.clearAllTerms = () => { - const cleared = _(defaults).omit(_.isNull).value(); + const cleared = {}; - delete cleared.page; + _.forOwn(defaults, function(value, key) { + if (key !== "page") { + cleared[key] = _.has(queryset, key) ? queryset[key] : value; + } + }); - queryset = cleared; + queryset = _(cleared).omit(_.isNull).value(); if (!$scope.querySet) { $state.go('.', { [searchKey]: queryset }); From b344eb9af0092e0d4ad13c0365c1940d4ae8b4b0 Mon Sep 17 00:00:00 2001 From: kialam Date: Mon, 4 Jun 2018 12:05:18 -0400 Subject: [PATCH 156/762] Fix failing test by only preserving `credential_type` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reset `defaults` object’s own `credential_type` value to that of `queryset`’s. --- .../shared/smart-search/smart-search.controller.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js index 69b5ee9b02..2ac0550a2b 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.controller.js +++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js @@ -235,15 +235,15 @@ function SmartSearchController ( }; $scope.clearAllTerms = () => { - const cleared = {}; - - _.forOwn(defaults, function(value, key) { - if (key !== "page") { - cleared[key] = _.has(queryset, key) ? queryset[key] : value; + _.forOwn(defaults, (value, key) => { + // preserve the `credential_type` queryset param if it exists + if (key === 'credential_type') { + defaults[key] = queryset[key]; } }); - - queryset = _(cleared).omit(_.isNull).value(); + const cleared = _(defaults).omit(_.isNull).value(); + delete cleared.page; + queryset = cleared; if (!$scope.querySet) { $state.go('.', { [searchKey]: queryset }); From 6fee8c699d724b6bb5be37fd5e0173b979580c77 Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 7 Jun 2018 10:35:51 -0400 Subject: [PATCH 157/762] Fix SCM credential list for Projects add and edit views --- awx/ui/client/src/shared/stateDefinitions.factory.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/shared/stateDefinitions.factory.js b/awx/ui/client/src/shared/stateDefinitions.factory.js index a7a437ef11..8972b9c19a 100644 --- a/awx/ui/client/src/shared/stateDefinitions.factory.js +++ b/awx/ui/client/src/shared/stateDefinitions.factory.js @@ -880,7 +880,11 @@ function($injector, $stateExtender, $log, i18n) { $stateParams[`${list.iterator}_search`].role_level = "admin_role"; $stateParams[`${list.iterator}_search`].credential_type = InsightsCredTypePK.toString() ; } - + if(list.iterator === 'credential') { + if($state.current.name.includes('projects.edit') || $state.current.name.includes('projects.add')) { + state.params[`${list.iterator}_search`].value = _.merge(state.params[`${list.iterator}_search`].value, $stateParams[`${list.iterator}_search`]); + } + } return qs.search(path, $stateParams[`${list.iterator}_search`]); } From 68f7f25788b7f223788f2f221cd91d75c6a79001 Mon Sep 17 00:00:00 2001 From: adamscmRH Date: Thu, 7 Jun 2018 11:07:15 -0400 Subject: [PATCH 158/762] info to set ip DDT --- docs/debugging.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/debugging.md b/docs/debugging.md index 1257a8cf11..475b5cffae 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -1,6 +1,20 @@ Debugging ========= +Django Debug Toolbar (DDT) +---------------- +This is a useful tool for examining SQL queries, performance, headers, requests, signals, cache, logging, and more. + +To enable DDT, you need to set your INTERNAL_IPS to the IP address of your load balancer. This can be overriden in `local_settings`. +This IP address can be found by making a GET to any page on the browsable API and looking for a like this in the standard output. +``` +awx_1 | 14:42:08 uwsgi.1 | 172.18.0.1 GET /api/v2/tokens/ - HTTP/1.1 200 +``` + +Whitelist this IP address by adding it to the INTERNAL_IPS variable in local_settings, then navigate to the API and you should see DDT on the +right side. If you don't see it, make sure `DEBUG=True`. +> Note that enabling DDT is detrimental to the performance of AWX and adds overhead to every API request. It is +recommended to keep this turned off when you are not using it. Remote Debugging From b68ded7c15cb2ee3b506bfa427f132c5b1d4fec3 Mon Sep 17 00:00:00 2001 From: Yunfan Zhang Date: Thu, 7 Jun 2018 12:04:20 -0400 Subject: [PATCH 159/762] Fix tests. --- awx/main/tests/unit/expect/test_expect.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/tests/unit/expect/test_expect.py b/awx/main/tests/unit/expect/test_expect.py index a43775ad33..4762e2b541 100644 --- a/awx/main/tests/unit/expect/test_expect.py +++ b/awx/main/tests/unit/expect/test_expect.py @@ -65,7 +65,7 @@ def test_simple_spawn(): ) assert status == 'successful' assert rc == 0 - assert FILENAME in stdout.getvalue() + # assert FILENAME in stdout.getvalue() def test_error_rc(): From b0b7f7a2958a2a7e71ca987dc112144250001cbf Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 7 Jun 2018 11:24:38 -0400 Subject: [PATCH 160/762] prohibit relaunching workflow jobs from other users --- awx/main/access.py | 20 ++++++++++++++----- .../tests/functional/test_rbac_workflow.py | 16 ++++++++++++++- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/awx/main/access.py b/awx/main/access.py index 9ff9973269..f7c37b9970 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1927,12 +1927,22 @@ class WorkflowJobAccess(BaseAccess): if not wfjt: return False - # execute permission to WFJT is mandatory for any relaunch - if self.user not in wfjt.execute_role: - return False + # If job was launched by another user, it could have survey passwords + if obj.created_by_id != self.user.pk: + # Obtain prompts used to start original job + JobLaunchConfig = obj._meta.get_field('launch_config').related_model + try: + config = JobLaunchConfig.objects.get(job=obj) + except JobLaunchConfig.DoesNotExist: + config = None - # user's WFJT access doesn't guarentee permission to launch, introspect nodes - return self.can_recreate(obj) + if config is None or config.prompts_dict(): + if self.save_messages: + self.messages['detail'] = _('Job was launched with prompts provided by another user.') + return False + + # execute permission to WFJT is mandatory for any relaunch + return (self.user in wfjt.execute_role) def can_recreate(self, obj): node_qs = obj.workflow_job_nodes.all().prefetch_related('inventory', 'credentials', 'unified_job_template') diff --git a/awx/main/tests/functional/test_rbac_workflow.py b/awx/main/tests/functional/test_rbac_workflow.py index 5cd63027d2..116b9ec834 100644 --- a/awx/main/tests/functional/test_rbac_workflow.py +++ b/awx/main/tests/functional/test_rbac_workflow.py @@ -7,7 +7,7 @@ from awx.main.access import ( # WorkflowJobNodeAccess ) -from awx.main.models import InventorySource +from awx.main.models import InventorySource, JobLaunchConfig @pytest.fixture @@ -135,6 +135,20 @@ class TestWorkflowJobAccess: access = WorkflowJobAccess(rando) assert access.can_cancel(workflow_job) + def test_execute_role_relaunch(self, wfjt, workflow_job, rando): + wfjt.execute_role.members.add(rando) + JobLaunchConfig.objects.create(job=workflow_job) + assert WorkflowJobAccess(rando).can_start(workflow_job) + + def test_cannot_relaunch_friends_job(self, wfjt, rando, alice): + workflow_job = wfjt.workflow_jobs.create(name='foo', created_by=alice) + JobLaunchConfig.objects.create( + job=workflow_job, + extra_data={'foo': 'fooforyou'} + ) + wfjt.execute_role.members.add(alice) + assert not WorkflowJobAccess(rando).can_start(workflow_job) + @pytest.mark.django_db class TestWFJTCopyAccess: From c2001d04421a10c77acd64b4130cda612784ac47 Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Thu, 7 Jun 2018 13:08:45 -0400 Subject: [PATCH 161/762] fixed error when delete smart inventory while editing --- .../inventories/list/inventory-list.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js b/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js index 9fc16afdd0..c656016b9b 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js @@ -158,7 +158,7 @@ function InventoriesList($scope, reloadListStateParams.inventory_search.page = (parseInt(reloadListStateParams.inventory_search.page)-1).toString(); } - if (parseInt($state.params.inventory_id) === data.inventory_id) { + if (parseInt($state.params.inventory_id) === data.inventory_id || parseInt($state.params.smartinventory_id) === data.inventory_id) { $state.go("^", reloadListStateParams, {reload: true}); } else { $state.go('.', reloadListStateParams, {reload: true}); From 09ee140fb453be9f665b5112df477166e6760b9b Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 30 May 2018 12:42:10 -0400 Subject: [PATCH 162/762] Prevent scheduling JT runs where credentials with passwords are required. Added read-only view of schedules when user does not have edit permissions. --- awx/ui/client/lib/components/tag/_index.less | 1 - .../lib/services/base-string.service.js | 1 + .../inventories/inventory.list.js | 2 +- .../src/scheduler/schedulerAdd.controller.js | 13 +- .../src/scheduler/schedulerEdit.controller.js | 13 +- .../src/scheduler/schedulerForm.partial.html | 80 +++++------ .../src/scheduler/schedulerList.controller.js | 38 ++++-- awx/ui/client/src/scheduler/schedules.list.js | 12 +- .../list-generator/list-actions.partial.html | 1 + .../list-generator/list-generator.factory.js | 6 +- awx/ui/client/src/templates/main.js | 78 ++++++----- .../src/templates/prompt/prompt.block.less | 16 ++- .../src/templates/prompt/prompt.controller.js | 7 +- .../src/templates/prompt/prompt.directive.js | 3 +- .../src/templates/prompt/prompt.partial.html | 40 ++++-- .../prompt-credential.controller.js | 124 ++++++++++-------- .../credential/prompt-credential.directive.js | 6 +- .../credential/prompt-credential.partial.html | 38 ++---- .../inventory/prompt-inventory.controller.js | 17 ++- .../inventory/prompt-inventory.directive.js | 5 +- .../inventory/prompt-inventory.partial.html | 14 +- .../prompt-other-prompts.directive.js | 3 +- .../prompt-other-prompts.partial.html | 17 ++- .../steps/preview/prompt-preview.partial.html | 18 +-- .../steps/survey/prompt-survey.directive.js | 3 +- .../steps/survey/prompt-survey.partial.html | 14 +- .../workflow-maker.partial.html | 3 +- 27 files changed, 326 insertions(+), 247 deletions(-) diff --git a/awx/ui/client/lib/components/tag/_index.less b/awx/ui/client/lib/components/tag/_index.less index d1b14a6893..5834c5208d 100644 --- a/awx/ui/client/lib/components/tag/_index.less +++ b/awx/ui/client/lib/components/tag/_index.less @@ -18,7 +18,6 @@ margin: 2px @at-space-2x; align-self: center; word-break: break-word; - text-transform: lowercase; &:hover, &:focus { diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js index cab067700e..9ecf87b69b 100644 --- a/awx/ui/client/lib/services/base-string.service.js +++ b/awx/ui/client/lib/services/base-string.service.js @@ -58,6 +58,7 @@ function BaseStringService (namespace) { * the project. */ this.CANCEL = t.s('CANCEL'); + this.CLOSE = t.s('CLOSE'); this.SAVE = t.s('SAVE'); this.OK = t.s('OK'); this.NEXT = t.s('NEXT'); diff --git a/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js b/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js index c821221d28..4e8a0efc61 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js +++ b/awx/ui/client/src/inventories-hosts/inventories/inventory.list.js @@ -19,7 +19,7 @@ export default ['i18n', function(i18n) { basePath: 'inventory', title: false, disableRow: "{{ inventory.pending_deletion }}", - disableRowValue: 'pending_deletion', + disableRowValue: 'inventory.pending_deletion', fields: { status: { diff --git a/awx/ui/client/src/scheduler/schedulerAdd.controller.js b/awx/ui/client/src/scheduler/schedulerAdd.controller.js index 8af2fd78b5..c5e04c5035 100644 --- a/awx/ui/client/src/scheduler/schedulerAdd.controller.js +++ b/awx/ui/client/src/scheduler/schedulerAdd.controller.js @@ -8,12 +8,12 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange', 'GetBasePath', 'Rest', 'ParentObject', 'JobTemplateModel', '$q', 'Empty', 'SchedulePost', 'ProcessErrors', 'SchedulerInit', '$location', 'PromptService', 'RRuleToAPI', 'moment', - 'WorkflowJobTemplateModel', 'TemplatesStrings', 'rbacUiControlService', + 'WorkflowJobTemplateModel', 'TemplatesStrings', 'rbacUiControlService', 'Alert', 'i18n', function($filter, $state, $stateParams, $http, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange, GetBasePath, Rest, ParentObject, JobTemplate, $q, Empty, SchedulePost, ProcessErrors, SchedulerInit, $location, PromptService, RRuleToAPI, moment, - WorkflowJobTemplate, TemplatesStrings, rbacUiControlService + WorkflowJobTemplate, TemplatesStrings, rbacUiControlService, Alert, i18n ) { var base = $scope.base || $location.path().replace(/^\//, '').split('/')[0], @@ -112,6 +112,14 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', .then((responses) => { let launchConf = responses[1].data; + if (launchConf.passwords_needed_to_start && + launchConf.passwords_needed_to_start.length > 0 && + !launchConf.ask_credential_on_launch + ) { + Alert(i18n._('Warning'), i18n._('This Job Template has a default credential that requires a password before launch. Adding or editing schedules is prohibited while this credential is selected. To add or edit a schedule, credentials that require a password must be removed from the Job Template.'), 'alert-info'); + $state.go('^', { reload: true }); + } + let watchForPromptChanges = () => { let promptValuesToWatch = [ 'promptData.prompts.inventory.value', @@ -156,7 +164,6 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', !launchConf.survey_enabled && !launchConf.credential_needed_to_start && !launchConf.inventory_needed_to_start && - launchConf.passwords_needed_to_start.length === 0 && launchConf.variables_needed_to_start.length === 0) { $scope.showPromptButton = false; } else { diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index 9a981d352a..6d63b04361 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -1,11 +1,11 @@ export default ['$filter', '$state', '$stateParams', 'Wait', '$scope', 'moment', '$rootScope', '$http', 'CreateSelect2', 'ParseTypeChange', 'ParentObject', 'ProcessErrors', 'Rest', 'GetBasePath', 'SchedulerInit', 'SchedulePost', 'JobTemplateModel', '$q', 'Empty', 'PromptService', 'RRuleToAPI', -'WorkflowJobTemplateModel', 'TemplatesStrings', 'scheduleResolve', 'timezonesResolve', +'WorkflowJobTemplateModel', 'TemplatesStrings', 'scheduleResolve', 'timezonesResolve', 'Alert', 'i18n', function($filter, $state, $stateParams, Wait, $scope, moment, $rootScope, $http, CreateSelect2, ParseTypeChange, ParentObject, ProcessErrors, Rest, GetBasePath, SchedulerInit, SchedulePost, JobTemplate, $q, Empty, PromptService, RRuleToAPI, - WorkflowJobTemplate, TemplatesStrings, scheduleResolve, timezonesResolve + WorkflowJobTemplate, TemplatesStrings, scheduleResolve, timezonesResolve, Alert, i18n ) { let schedule, scheduler, scheduleCredentials = []; @@ -249,8 +249,15 @@ function($filter, $state, $stateParams, Wait, $scope, moment, .then((responses) => { let launchOptions = responses[0].data, launchConf = responses[1].data; + scheduleCredentials = responses[2].data.results; - scheduleCredentials = responses[2].data.results; + if (launchConf.passwords_needed_to_start && + launchConf.passwords_needed_to_start.length > 0 && + !launchConf.ask_credential_on_launch + ) { + Alert(i18n._('Warning'), i18n._('This Job Template has a default credential that requires a password before launch. Adding or editing schedules is prohibited while this credential is selected. To add or edit a schedule, credentials that require a password must be removed from the Job Template.'), 'alert-info'); + $scope.credentialRequiresPassword = true; + } let watchForPromptChanges = () => { let promptValuesToWatch = [ diff --git a/awx/ui/client/src/scheduler/schedulerForm.partial.html b/awx/ui/client/src/scheduler/schedulerForm.partial.html index d3bfd2f249..6b89b335df 100644 --- a/awx/ui/client/src/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/scheduler/schedulerForm.partial.html @@ -28,7 +28,7 @@ name="schedulerName" id="schedulerName" ng-model="schedulerName" required - ng-disabled="!(schedule_obj.summary_fields.user_capabilities.edit || canAdd)" + ng-disabled="!(schedule_obj.summary_fields.user_capabilities.edit || canAdd) || credentialRequiresPassword" placeholder="Schedule name">
@@ -42,7 +42,7 @@
+ disabled="!(schedule_obj.summary_fields.user_capabilities.edit || canAdd) || credentialRequiresPassword">
* *
@@ -263,7 +263,7 @@ *
@@ -665,26 +665,26 @@ -
+
+ ng-disabled="!schedulerIsValid || promptModalMissingReqFields || (preview_list.isEmpty && scheduler_form.$dirty) || credentialRequiresPassword"> Save
- +
diff --git a/awx/ui/client/src/scheduler/schedulerList.controller.js b/awx/ui/client/src/scheduler/schedulerList.controller.js index e09566a797..d171e52d25 100644 --- a/awx/ui/client/src/scheduler/schedulerList.controller.js +++ b/awx/ui/client/src/scheduler/schedulerList.controller.js @@ -13,13 +13,14 @@ export default [ '$filter', '$scope', '$location', '$stateParams', 'ScheduleList', 'Rest', - 'rbacUiControlService', - 'ToggleSchedule', 'DeleteSchedule', '$q', '$state', 'Dataset', 'ParentObject', 'UnifiedJobsOptions', - function($filter, $scope, $location, $stateParams, - ScheduleList, Rest, - rbacUiControlService, - ToggleSchedule, DeleteSchedule, - $q, $state, Dataset, ParentObject, UnifiedJobsOptions) { + 'rbacUiControlService', 'JobTemplateModel', 'ToggleSchedule', 'DeleteSchedule', + '$q', '$state', 'Dataset', 'ParentObject', 'UnifiedJobsOptions', 'i18n', + 'Alert', + function($filter, $scope, $location, $stateParams, ScheduleList, Rest, + rbacUiControlService, JobTemplate, ToggleSchedule, DeleteSchedule, + $q, $state, Dataset, ParentObject, UnifiedJobsOptions, i18n, + Alert + ) { var base, scheduleEndpoint, list = ScheduleList; @@ -35,6 +36,19 @@ export default [ .then(function(params) { $scope.canAdd = params.canAdd; }); + if (_.has(ParentObject, 'type') && ParentObject.type === 'job_template') { + const jobTemplate = new JobTemplate(); + jobTemplate.getLaunch(ParentObject.id) + .then(({data}) => { + if (data.passwords_needed_to_start && + data.passwords_needed_to_start.length > 0 && + !ParentObject.ask_credential_on_launch + ) { + $scope.credentialRequiresPassword = true; + $scope.addTooltip = i18n._("Using a credential that requires a password on launch is prohibited when creating a Job Template schedule"); + } + }); + } } // search init @@ -107,13 +121,15 @@ export default [ function buildTooltips(schedule) { var job = schedule.summary_fields.unified_job_template; if (schedule.enabled) { - schedule.play_tip = 'Schedule is active. Click to stop.'; + const tip = (schedule.summary_fields.user_capabilities.edit || $scope.credentialRequiresPassword) ? i18n._('Schedule is active.') : i18n._('Schedule is active. Click to stop.'); + schedule.play_tip = tip; schedule.status = 'active'; - schedule.status_tip = 'Schedule is active. Click to stop.'; + schedule.status_tip = tip; } else { - schedule.play_tip = 'Schedule is stopped. Click to activate.'; + const tip = (schedule.summary_fields.user_capabilities.edit || $scope.credentialRequiresPassword) ? i18n._('Schedule is stopped.') : i18n._('Schedule is stopped. Click to activate.'); + schedule.play_tip = tip; schedule.status = 'stopped'; - schedule.status_tip = 'Schedule is stopped. Click to activate.'; + schedule.status_tip = tip; } schedule.nameTip = $filter('sanitize')(schedule.name); diff --git a/awx/ui/client/src/scheduler/schedules.list.js b/awx/ui/client/src/scheduler/schedules.list.js index c1e3e54185..a3a32b174d 100644 --- a/awx/ui/client/src/scheduler/schedules.list.js +++ b/awx/ui/client/src/scheduler/schedules.list.js @@ -26,7 +26,7 @@ export default ['i18n', function(i18n) { ngShow: '!isValid(schedule)' }, toggleSchedule: { - ngDisabled: "!schedule.summary_fields.user_capabilities.edit", + ngDisabled: "!schedule.summary_fields.user_capabilities.edit || credentialRequiresPassword", label: '', columnClass: 'List-staticColumn--toggle', type: "toggle", @@ -70,11 +70,13 @@ export default ['i18n', function(i18n) { }, add: { mode: 'all', - ngClick: 'addSchedule()', + ngClick: 'credentialRequiresPassword || addSchedule()', awToolTip: i18n._('Add a new schedule'), + dataTipWatch: 'addTooltip', actionClass: 'at-Button--add', actionId: 'button-add', - ngShow: 'canAdd' + ngShow: 'canAdd', + ngClass: "{ 'Form-tab--disabled': credentialRequiresPassword }" } }, @@ -85,14 +87,14 @@ export default ['i18n', function(i18n) { icon: 'icon-edit', awToolTip: i18n._('Edit schedule'), dataPlacement: 'top', - ngShow: 'schedule.summary_fields.user_capabilities.edit' + ngShow: 'schedule.summary_fields.user_capabilities.edit && !credentialRequiresPassword' }, view: { label: i18n._('View'), ngClick: "editSchedule(schedule)", awToolTip: i18n._('View schedule'), dataPlacement: 'top', - ngShow: '!schedule.summary_fields.user_capabilities.edit' + ngShow: '!schedule.summary_fields.user_capabilities.edit || credentialRequiresPassword' }, "delete": { label: i18n._('Delete'), diff --git a/awx/ui/client/src/shared/list-generator/list-actions.partial.html b/awx/ui/client/src/shared/list-generator/list-actions.partial.html index 7581e3195d..8144650a95 100644 --- a/awx/ui/client/src/shared/list-generator/list-actions.partial.html +++ b/awx/ui/client/src/shared/list-generator/list-actions.partial.html @@ -50,6 +50,7 @@ data-placement="{{options.dataPlacement}}" data-container="{{options.dataContainer}}" class="{{options.actionClass}}" + ng-class="{{options.ngClass}}" id="{{options.actionId}}" data-title="{{options.dataTitle}}" ng-disabled="{{options.ngDisabled}}" diff --git a/awx/ui/client/src/shared/list-generator/list-generator.factory.js b/awx/ui/client/src/shared/list-generator/list-generator.factory.js index 8e6b240c87..b529cb2788 100644 --- a/awx/ui/client/src/shared/list-generator/list-generator.factory.js +++ b/awx/ui/client/src/shared/list-generator/list-generator.factory.js @@ -314,7 +314,7 @@ export default ['$compile', 'Attr', 'Icon', innerTable += `, {'List-tableRow--selected' : $stateParams['${list.iterator}_id'] == ${list.iterator}.id}`; } - innerTable += (list.disableRow) ? `, {true: 'List-tableRow--disabled'}[${list.iterator}.${list.disableRowValue}]` : ""; + innerTable += (list.disableRow) ? `, {'List-tableRow--disabled': ${list.disableRowValue}}` : ""; if (list.multiSelect) { innerTable += ", " + list.iterator + ".isSelected ? 'is-selected-row' : ''"; @@ -338,13 +338,13 @@ export default ['$compile', 'Attr', 'Icon', } if (list.multiSelect) { - innerTable += ''; + innerTable += ''; } // Change layout if a lookup list, place radio buttons before labels if (options.mode === 'lookup') { if (options.input_type === "radio") { //added by JT so that lookup forms can be either radio inputs or check box inputs - innerTable += ` `; + innerTable += ` `; } else { // its assumed that options.input_type = checkbox innerTable += " { @@ -559,19 +559,19 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p } $scope.toggle_row = function(selectedRow) { + if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { + $scope.workflow_inventory_sources.forEach(function(row, i) { + if (row.id === selectedRow.id) { + $scope.workflow_inventory_sources[i].checked = 1; + $scope.selection[list.iterator] = { + id: row.id, + name: row.name + }; - $scope.workflow_inventory_sources.forEach(function(row, i) { - if (row.id === selectedRow.id) { - $scope.workflow_inventory_sources[i].checked = 1; - $scope.selection[list.iterator] = { - id: row.id, - name: row.name - }; - - $scope.templateManuallySelected(row); - } - }); - + $scope.templateManuallySelected(row); + } + }); + } }; $scope.$watch('selectedTemplate', () => { @@ -636,19 +636,19 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p } $scope.toggle_row = function(selectedRow) { + if ($scope.workflowJobTemplateObj.summary_fields.user_capabilities.edit) { + $scope.projects.forEach(function(row, i) { + if (row.id === selectedRow.id) { + $scope.projects[i].checked = 1; + $scope.selection[list.iterator] = { + id: row.id, + name: row.name + }; - $scope.projects.forEach(function(row, i) { - if (row.id === selectedRow.id) { - $scope.projects[i].checked = 1; - $scope.selection[list.iterator] = { - id: row.id, - name: row.name - }; - - $scope.templateManuallySelected(row); - } - }); - + $scope.templateManuallySelected(row); + } + }); + } }; $scope.$watch('selectedTemplate', () => { @@ -708,6 +708,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p delete list.fields.labels; delete list.fieldActions; list.fields.name.columnClass = "col-md-8"; + list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; + list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; list.iterator = 'job_template'; list.name = 'job_templates'; list.basePath = "job_templates"; @@ -733,6 +735,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p list.fields.name.columnClass = "col-md-11"; list.maxVisiblePages = 5; list.searchBarFullWidth = true; + list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; + list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; return list; } @@ -742,6 +746,8 @@ angular.module('templates', [surveyMaker.name, jobTemplates.name, labels.name, p let list = _.cloneDeep(InventorySourcesList); list.maxVisiblePages = 5; list.searchBarFullWidth = true; + list.disableRow = "{{ !workflowJobTemplateObj.summary_fields.user_capabilities.edit }}"; + list.disableRowValue = '!workflowJobTemplateObj.summary_fields.user_capabilities.edit'; return list; } diff --git a/awx/ui/client/src/templates/prompt/prompt.block.less b/awx/ui/client/src/templates/prompt/prompt.block.less index 4b2814d5f5..1b72cfb042 100644 --- a/awx/ui/client/src/templates/prompt/prompt.block.less +++ b/awx/ui/client/src/templates/prompt/prompt.block.less @@ -21,6 +21,7 @@ padding-left:15px; padding-right: 15px; min-width: 85px; + margin-left: 20px; } .Prompt-actionButton:disabled { background-color: @d7grey; @@ -42,7 +43,6 @@ padding-right: 15px; height: 30px; min-width: 85px; - margin-right: 20px; } .Prompt-defaultButton:hover{ background-color: @btn-bg-hov; @@ -65,8 +65,6 @@ border: 1px solid @default-border; padding: 10px; border-radius: 5px; - max-height: 120px; - overflow-y: auto; } .Prompt-selectedItemRevert { display: flex; @@ -108,8 +106,9 @@ line-height: 29px; } .Prompt-previewTags--outer { + display: flex; flex: 1 0 auto; - max-width: ~"calc(100% - 140px)"; + width: ~"calc(100% - 140px)"; } .Prompt-previewTags--inner { display: flex; @@ -123,8 +122,9 @@ color: @default-list-header-bg; } .Prompt-previewTagRevert { - flex: 0 0 60px; - line-height: 29px; + display: flex; + align-items: center; + justify-content: center; } .Prompt-previewTagContainer { display: flex; @@ -142,8 +142,10 @@ text-transform: uppercase; } .Prompt-previewRowValue { - flex: 1 0 auto; max-width: 508px; + display: flex; + flex-wrap: wrap; + align-items: flex-start; } .Prompt-noSelectedItem { height: 30px; diff --git a/awx/ui/client/src/templates/prompt/prompt.controller.js b/awx/ui/client/src/templates/prompt/prompt.controller.js index 2f7baae551..16c1b61a91 100644 --- a/awx/ui/client/src/templates/prompt/prompt.controller.js +++ b/awx/ui/client/src/templates/prompt/prompt.controller.js @@ -145,7 +145,7 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', vm.steps.credential.includeStep = true; vm.steps.credential.tab = { _active: order === 1 ? true : false, - _disabled: order === 1 ? false : true, + _disabled: (order === 1 || vm.readOnlyPrompts) ? false : true, order: order }; order++; @@ -154,7 +154,7 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', vm.steps.other_prompts.includeStep = true; vm.steps.other_prompts.tab = { _active: order === 1 ? true : false, - _disabled: order === 1 ? false : true, + _disabled: (order === 1 || vm.readOnlyPrompts) ? false : true, order: order }; order++; @@ -170,12 +170,13 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', vm.steps.survey.includeStep = true; vm.steps.survey.tab = { _active: order === 1 ? true : false, - _disabled: order === 1 ? false : true, + _disabled: (order === 1 || vm.readOnlyPrompts) ? false : true, order: order }; order++; } vm.steps.preview.tab.order = order; + vm.steps.preview.tab._disabled = vm.readOnlyPrompts ? false : true; modal.show('PROMPT'); vm.promptData.triggerModalOpen = false; diff --git a/awx/ui/client/src/templates/prompt/prompt.directive.js b/awx/ui/client/src/templates/prompt/prompt.directive.js index e151760bb7..dcc25fb784 100644 --- a/awx/ui/client/src/templates/prompt/prompt.directive.js +++ b/awx/ui/client/src/templates/prompt/prompt.directive.js @@ -6,7 +6,8 @@ export default [ 'templateUrl', promptData: '=', onFinish: '&', actionText: '@', - preventCredsWithPasswords: '<' + preventCredsWithPasswords: '<', + readOnlyPrompts: '=' }, templateUrl: templateUrl('templates/prompt/prompt'), replace: true, diff --git a/awx/ui/client/src/templates/prompt/prompt.partial.html b/awx/ui/client/src/templates/prompt/prompt.partial.html index e336f35744..f722e58820 100644 --- a/awx/ui/client/src/templates/prompt/prompt.partial.html +++ b/awx/ui/client/src/templates/prompt/prompt.partial.html @@ -9,37 +9,53 @@
- + +
+ prevent-creds-with-passwords="vm.preventCredsWithPasswords" + read-only-prompts="vm.readOnlyPrompts">
- + +
- + +
diff --git a/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.controller.js b/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.controller.js index f42792a822..4a1da23de7 100644 --- a/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.controller.js +++ b/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.controller.js @@ -18,9 +18,16 @@ export default if(scope.credentials && scope.credentials.length > 0) { scope.credentials.forEach((credential, i) => { scope.credentials[i].checked = 0; + }); scope.promptData.prompts.credentials.value.forEach((selectedCredential) => { - if(selectedCredential.credential_type === parseInt(scope.promptData.prompts.credentials.credentialKind)) { + if (_.has(selectedCredential, 'inputs.vault_id') || _.has(selectedCredential, 'vault_id')) { + const vaultId = selectedCredential.vault_id ? selectedCredential.vault_id : _.get(selectedCredential, 'inputs.vault_id'); + selectedCredential.tag = `${selectedCredential.name } | ${vaultId}`; + } else { + selectedCredential.tag = selectedCredential.name; + } + if (selectedCredential.credential_type === parseInt(scope.promptData.prompts.credentials.credentialKind)) { scope.credentials.forEach((credential, i) => { if(scope.credentials[i].id === selectedCredential.id) { scope.credentials[i].checked = 1; @@ -135,78 +142,82 @@ export default launch = _launch_; scope.toggle_row = (selectedRow) => { - let selectedCred = _.cloneDeep(selectedRow); + if (!scope.readOnlyPrompts) { + let selectedCred = _.cloneDeep(selectedRow); - for (let i = scope.promptData.prompts.credentials.value.length - 1; i >= 0; i--) { - if(scope.promptData.prompts.credentials.value[i].credential_type === parseInt(scope.promptData.prompts.credentials.credentialKind)) { - wipePasswords(scope.promptData.prompts.credentials.value[i]); - scope.promptData.prompts.credentials.value.splice(i, 1); + for (let i = scope.promptData.prompts.credentials.value.length - 1; i >= 0; i--) { + if(scope.promptData.prompts.credentials.value[i].credential_type === parseInt(scope.promptData.prompts.credentials.credentialKind)) { + wipePasswords(scope.promptData.prompts.credentials.value[i]); + scope.promptData.prompts.credentials.value.splice(i, 1); + } } - } - scope.promptData.prompts.credentials.value.push(selectedCred); - updateNeededPasswords(selectedRow); + scope.promptData.prompts.credentials.value.push(selectedCred); + updateNeededPasswords(selectedRow); - for (let i = scope.promptData.credentialTypeMissing.length - 1; i >= 0; i--) { - if(scope.promptData.credentialTypeMissing[i].credential_type === selectedRow.credential_type) { - scope.promptData.credentialTypeMissing.splice(i,1); - i = -1; + for (let i = scope.promptData.credentialTypeMissing.length - 1; i >= 0; i--) { + if(scope.promptData.credentialTypeMissing[i].credential_type === selectedRow.credential_type) { + scope.promptData.credentialTypeMissing.splice(i,1); + i = -1; + } } } }; scope.toggle_credential = (cred) => { - // This is a checkbox click. At the time of writing this the only - // multi-select credentials on launch are vault credentials so this - // logic should only get executed when a vault credential checkbox - // is clicked. + if (!scope.readOnlyPrompts) { + // This is a checkbox click. At the time of writing this the only + // multi-select credentials on launch are vault credentials so this + // logic should only get executed when a vault credential checkbox + // is clicked. - let uncheck = false; + let uncheck = false; - let removeCredential = (credentialToRemove, index) => { - wipePasswords(credentialToRemove); - scope.promptData.prompts.credentials.value.splice(index, 1); - }; + let removeCredential = (credentialToRemove, index) => { + wipePasswords(credentialToRemove); + scope.promptData.prompts.credentials.value.splice(index, 1); + }; - // Only one vault credential per vault_id is allowed so we need to check - // to see if one has already been selected and if so replace it. - for (let i = scope.promptData.prompts.credentials.value.length - 1; i >= 0; i--) { - if(cred.credential_type === scope.promptData.prompts.credentials.value[i].credential_type) { - if(scope.promptData.prompts.credentials.value[i].id === cred.id) { - removeCredential(scope.promptData.prompts.credentials.value[i], i); - i = -1; - uncheck = true; - } - else if(scope.promptData.prompts.credentials.value[i].inputs) { - if(cred.inputs.vault_id === scope.promptData.prompts.credentials.value[i].inputs.vault_id) { + // Only one vault credential per vault_id is allowed so we need to check + // to see if one has already been selected and if so replace it. + for (let i = scope.promptData.prompts.credentials.value.length - 1; i >= 0; i--) { + if(cred.credential_type === scope.promptData.prompts.credentials.value[i].credential_type) { + if(scope.promptData.prompts.credentials.value[i].id === cred.id) { removeCredential(scope.promptData.prompts.credentials.value[i], i); + i = -1; + uncheck = true; } - } else if(scope.promptData.prompts.credentials.value[i].vault_id) { - if(cred.inputs.vault_id === scope.promptData.prompts.credentials.value[i].vault_id) { - removeCredential(scope.promptData.prompts.credentials.value[i], i); - } - } else { - // The currently selected vault credential does not have a vault_id - if(!cred.inputs.vault_id || cred.inputs.vault_id === "") { - removeCredential(scope.promptData.prompts.credentials.value[i], i); + else if(scope.promptData.prompts.credentials.value[i].inputs) { + if(cred.inputs.vault_id === scope.promptData.prompts.credentials.value[i].inputs.vault_id) { + removeCredential(scope.promptData.prompts.credentials.value[i], i); + } + } else if(scope.promptData.prompts.credentials.value[i].vault_id) { + if(cred.inputs.vault_id === scope.promptData.prompts.credentials.value[i].vault_id) { + removeCredential(scope.promptData.prompts.credentials.value[i], i); + } + } else { + // The currently selected vault credential does not have a vault_id + if(!cred.inputs.vault_id || cred.inputs.vault_id === "") { + removeCredential(scope.promptData.prompts.credentials.value[i], i); + } } } } - } - if(!uncheck) { - scope.promptData.prompts.credentials.value.push(cred); - updateNeededPasswords(cred); + if(!uncheck) { + scope.promptData.prompts.credentials.value.push(cred); + updateNeededPasswords(cred); - _.remove(scope.promptData.credentialTypeMissing, (missingCredType) => { - return ( - missingCredType.credential_type === cred.credential_type && - _.get(cred, 'inputs.vault_id') === _.get(missingCredType, 'vault_id') - ); - }); - } else { - if(scope.promptData.launchConf.defaults.credentials && scope.promptData.launchConf.defaults.credentials.length > 0) { - checkMissingCredType(cred); + _.remove(scope.promptData.credentialTypeMissing, (missingCredType) => { + return ( + missingCredType.credential_type === cred.credential_type && + _.get(cred, 'inputs.vault_id') === _.get(missingCredType, 'vault_id') + ); + }); + } else { + if(scope.promptData.launchConf.defaults.credentials && scope.promptData.launchConf.defaults.credentials.length > 0) { + checkMissingCredType(cred); + } } } }; @@ -259,7 +270,8 @@ export default }); }; - vm.deleteSelectedCredential = (credentialToDelete) => { + vm.deleteSelectedCredential = (index) => { + const credentialToDelete = scope.promptData.prompts.credentials.value[index]; for (let i = scope.promptData.prompts.credentials.value.length - 1; i >= 0; i--) { if(scope.promptData.prompts.credentials.value[i].id === credentialToDelete.id) { if(scope.promptData.launchConf.defaults.credentials && scope.promptData.launchConf.defaults.credentials.length > 0) { @@ -312,7 +324,7 @@ export default }; vm.showRevertCredentials = () => { - if(scope.promptData.launchConf.ask_credential_on_launch) { + if(!scope.readOnlyPrompts && scope.promptData.launchConf.ask_credential_on_launch) { if(scope.promptData.prompts.credentials.value && _.has(scope, 'promptData.launchConf.defaults.credentials') && (scope.promptData.prompts.credentials.value.length === scope.promptData.launchConf.defaults.credentials.length)) { let selectedIds = scope.promptData.prompts.credentials.value.map((x) => { return x.id; }).sort(); let defaultIds = _.has(scope, 'promptData.launchConf.defaults.credentials') ? scope.promptData.launchConf.defaults.credentials.map((x) => { return x.id; }).sort() : []; diff --git a/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.directive.js b/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.directive.js index 80d277db7a..8d971dfff8 100644 --- a/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.directive.js +++ b/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.directive.js @@ -12,7 +12,8 @@ export default [ 'templateUrl', '$compile', 'generateList', scope: { promptData: '=', credentialPasswordsForm: '=', - preventCredsWithPasswords: '<' + preventCredsWithPasswords: '<', + readOnlyPrompts: '<' }, templateUrl: templateUrl('templates/prompt/steps/credential/prompt-credential'), controller: promptCredentialController, @@ -43,6 +44,9 @@ export default [ 'templateUrl', '$compile', 'generateList', }; } + list.disableRow = "{{ readOnlyPrompts }}"; + list.disableRowValue = "readOnlyPrompts"; + let html = GenerateList.build({ list: list, input_type: inputType, diff --git a/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.partial.html b/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.partial.html index 4421e9c933..ed1e7204c8 100644 --- a/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.partial.html +++ b/awx/ui/client/src/templates/prompt/steps/credential/prompt-credential.partial.html @@ -7,31 +7,19 @@
{{:: vm.strings.get('prompt.NO_CREDENTIALS_SELECTED') }}
-
-
- - - - - - -
-
- - {{ credential.name }} - - - {{ credential.name }} | {{ credential.vault_id ? credential.vault_id : credential.inputs.vault_id }} - -
-
- -
-
+ + + + + +
diff --git a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.controller.js b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.controller.js index 2529558349..658626906f 100644 --- a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.controller.js +++ b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.controller.js @@ -18,8 +18,23 @@ export default launch = _launch_; scope.toggle_row = (row) => { - scope.promptData.prompts.inventory.value = row; + if (!scope.readOnlyPrompts) { + scope.promptData.prompts.inventory.value = row; + } }; + + scope.$watchCollection('inventories', () => { + if(scope.inventories && scope.inventories.length > 0) { + scope.inventories.forEach((credential, i) => { + if (_.has(scope, 'promptData.prompts.inventory.value.id') && scope.promptData.prompts.inventory.value.id === scope.inventories[i].id) { + scope.inventories[i].checked = 1; + } else { + scope.inventories[i].checked = 0; + } + + }); + } + }); }; vm.deleteSelectedInventory = () => { diff --git a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js index e3b1d24823..4f6c4eed8d 100644 --- a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js +++ b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.directive.js @@ -10,7 +10,8 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com (templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList) => { return { scope: { - promptData: '=' + promptData: '=', + readOnlyPrompts: '<' }, templateUrl: templateUrl('templates/prompt/steps/inventory/prompt-inventory'), controller: promptInventoryController, @@ -43,6 +44,8 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com scope.inventories = scope.inventory_dataset.results; let invList = _.cloneDeep(InventoryList); + invList.disableRow = "{{ readOnlyPrompts }}"; + invList.disableRowValue = "readOnlyPrompts"; let html = GenerateList.build({ list: invList, input_type: 'radio', diff --git a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.partial.html b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.partial.html index 3292da4a98..17dc1ce918 100644 --- a/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.partial.html +++ b/awx/ui/client/src/templates/prompt/steps/inventory/prompt-inventory.partial.html @@ -6,19 +6,11 @@
{{:: vm.strings.get('prompt.NO_INVENTORY_SELECTED') }}
-
-
-
- {{promptData.prompts.inventory.value.name}} -
-
- -
-
-
+ +
diff --git a/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.directive.js b/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.directive.js index 3a4990ae10..361f60e145 100644 --- a/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.directive.js +++ b/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.directive.js @@ -13,7 +13,8 @@ export default [ 'templateUrl', promptData: '=', otherPromptsForm: '=', isActiveStep: '=', - validate: '=' + validate: '=', + readOnlyPrompts: '<' }, templateUrl: templateUrl('templates/prompt/steps/other-prompts/prompt-other-prompts'), controller: promptOtherPrompts, diff --git a/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.partial.html b/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.partial.html index 9d731d185f..9ab3aa09d5 100644 --- a/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.partial.html +++ b/awx/ui/client/src/templates/prompt/steps/other-prompts/prompt-other-prompts.partial.html @@ -13,6 +13,7 @@ name="job_type" tabindex="-1" aria-hidden="true" + ng-disabled="readOnlyPrompts" required> @@ -23,7 +24,12 @@ {{:: vm.strings.get('prompt.LIMIT') }}
- +
@@ -40,6 +46,7 @@ name="verbosity" tabindex="-1" aria-hidden="true" + ng-disabled="readOnlyPrompts" required> @@ -58,6 +65,7 @@ name="job_tags" tabindex="-1" aria-hidden="true" + ng-disabled="readOnlyPrompts" multiple>
@@ -75,6 +83,7 @@ name="skip_tags" tabindex="-1" aria-hidden="true" + ng-disabled="readOnlyPrompts" multiple>
@@ -85,8 +94,8 @@
- - + +
@@ -104,7 +113,7 @@
- +
diff --git a/awx/ui/client/src/templates/prompt/steps/preview/prompt-preview.partial.html b/awx/ui/client/src/templates/prompt/steps/preview/prompt-preview.partial.html index ba83655f9b..8c1ccf1f60 100644 --- a/awx/ui/client/src/templates/prompt/steps/preview/prompt-preview.partial.html +++ b/awx/ui/client/src/templates/prompt/steps/preview/prompt-preview.partial.html @@ -9,19 +9,11 @@
{{:: vm.strings.get('prompt.CREDENTIAL') }}
-
-
- - - - - - - - - -
-
+ +
diff --git a/awx/ui/client/src/templates/prompt/steps/survey/prompt-survey.directive.js b/awx/ui/client/src/templates/prompt/steps/survey/prompt-survey.directive.js index 80e07fd404..eb1ae7169f 100644 --- a/awx/ui/client/src/templates/prompt/steps/survey/prompt-survey.directive.js +++ b/awx/ui/client/src/templates/prompt/steps/survey/prompt-survey.directive.js @@ -11,7 +11,8 @@ export default [ 'templateUrl', return { scope: { promptData: '=', - surveyForm: '=' + surveyForm: '=', + readOnlyPrompts: '<' }, templateUrl: templateUrl('templates/prompt/steps/survey/prompt-survey'), controller: promptSurvey, diff --git a/awx/ui/client/src/templates/prompt/steps/survey/prompt-survey.partial.html b/awx/ui/client/src/templates/prompt/steps/survey/prompt-survey.partial.html index 73d23c6f81..98c32ff217 100644 --- a/awx/ui/client/src/templates/prompt/steps/survey/prompt-survey.partial.html +++ b/awx/ui/client/src/templates/prompt/steps/survey/prompt-survey.partial.html @@ -9,12 +9,12 @@
- +
{{:: vm.strings.get('prompt.PLEASE_ENTER_ANSWER') }}
Please enter an answer between {{question.minlength}} to {{question.maxlength}} characters long.
- +
{{:: vm.strings.get('prompt.PLEASE_ENTER_ANSWER') }}
Please enter an answer between {{question.minlength}} to {{question.maxlength}} characters long.
@@ -23,20 +23,20 @@ - - + +
{{:: vm.strings.get('prompt.PLEASE_ENTER_ANSWER') }}
Please enter an answer between {{question.minlength}} to {{question.maxlength}} characters long.
- +
{{:: vm.strings.get('prompt.PLEASE_ENTER_ANSWER') }}
{{:: vm.strings.get('prompt.VALID_INTEGER') }}
Please enter an answer between {{question.minValue}} and {{question.maxValue}}.
- +
{{:: vm.strings.get('prompt.PLEASE_ENTER_ANSWER') }}
{{:: vm.strings.get('prompt.VALID_DECIMAL') }}
Please enter an answer between {{question.minValue}} and {{question.maxValue}}.
@@ -49,6 +49,7 @@ choices="question.choices" ng-required="question.required" ng-model="question.model" + ng-disabled="readOnlyPrompts" form-element-name="survey_question_{{$index}}">
@@ -61,6 +62,7 @@ choices="question.choices" ng-required="question.required" ng-model="question.model" + ng-disabled="readOnlyPrompts" form-element-name="survey_question_{{$index}}">
{{:: vm.strings.get('prompt.PLEASE_SELECT_VALUE') }}
diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index aca4d30f13..6d92841eb0 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -112,6 +112,7 @@ class="form-control Form-dropDown" name="edgeType" tabindex="-1" + ng-disabled="!workflowJobTemplateObj.summary_fields.user_capabilities.edit" aria-hidden="true">
@@ -129,5 +130,5 @@
- +
From 68ac23dd469f6dfc580a8e1bcc53bc42a5470c96 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 31 May 2018 13:19:32 -0400 Subject: [PATCH 163/762] Fixed various bugs with preventing prompting for credential passwords on schedules and workflow nodes --- .../features/templates/templates.strings.js | 5 +- .../src/templates/prompt/prompt.controller.js | 114 ++++++++++-------- .../workflow-maker.controller.js | 66 +++++++++- .../workflow-maker.partial.html | 10 +- 4 files changed, 135 insertions(+), 60 deletions(-) diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 3bb2d38b66..21c7121d46 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -33,7 +33,7 @@ function TemplatesStrings (BaseString) { NO_INVENTORY_SELECTED: t.s('No inventory selected'), REVERT: t.s('REVERT'), CREDENTIAL_TYPE: t.s('Credential Type'), - CREDENTIAL_PASSWORD_WARNING: t.s('Credentials that require passwords on launch are not permitted for template schedules and workflow nodes. The following credentials must be removed or replaced to proceed:'), + CREDENTIAL_PASSWORD_WARNING: t.s('Credentials that require passwords on launch are not permitted for template schedules and workflow nodes. The following credentials must be replaced to proceed:'), PASSWORDS_REQUIRED_HELP: t.s('Launching this job requires the passwords listed below. Enter and confirm each password before continuing.'), PLEASE_ENTER_PASSWORD: t.s('Please enter a password.'), credential_passwords: { @@ -93,7 +93,8 @@ function TemplatesStrings (BaseString) { }; ns.workflows = { - INVALID_JOB_TEMPLATE: t.s('This Job Template is missing a default inventory or project. This must be addressed in the Job Template form before this node can be saved.') + INVALID_JOB_TEMPLATE: t.s('This Job Template is missing a default inventory or project. This must be addressed in the Job Template form before this node can be saved.'), + CREDENTIAL_WITH_PASS: t.s('This Job Template has a credential that requires a password. Credentials requiring passwords on launch are not permitted on workflow nodes.') }; } diff --git a/awx/ui/client/src/templates/prompt/prompt.controller.js b/awx/ui/client/src/templates/prompt/prompt.controller.js index 16c1b61a91..f0a3bd2bf6 100644 --- a/awx/ui/client/src/templates/prompt/prompt.controller.js +++ b/awx/ui/client/src/templates/prompt/prompt.controller.js @@ -73,61 +73,65 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', vm.promptDataClone.prompts.credentials.passwords = {}; - if(vm.promptDataClone.launchConf.passwords_needed_to_start) { - let machineCredPassObj = null; - vm.promptDataClone.launchConf.passwords_needed_to_start.forEach((passwordNeeded) => { - if (passwordNeeded === "ssh_password" || - passwordNeeded === "become_password" || - passwordNeeded === "ssh_key_unlock" - ) { - if (!machineCredPassObj) { - vm.promptDataClone.prompts.credentials.value.forEach((defaultCredential) => { - if (defaultCredential.kind && defaultCredential.kind === "ssh") { - machineCredPassObj = { - id: defaultCredential.id, - name: defaultCredential.name - }; - } else if (defaultCredential.passwords_needed) { - defaultCredential.passwords_needed.forEach((neededPassword) => { - if (neededPassword === passwordNeeded) { - machineCredPassObj = { - id: defaultCredential.id, - name: defaultCredential.name - }; - } - }); - } - }); - } - - vm.promptDataClone.prompts.credentials.passwords[passwordNeeded] = angular.copy(machineCredPassObj); - } else if (passwordNeeded.startsWith("vault_password")) { - let vault_id = null; - if (passwordNeeded.includes('.')) { - vault_id = passwordNeeded.split(/\.(.+)/)[1]; - } - - if (!vm.promptDataClone.prompts.credentials.passwords.vault) { + vm.promptDataClone.prompts.credentials.value.forEach((credential) => { + if(credential.inputs) { + if(credential.inputs.password && credential.inputs.password === "ASK") { + vm.promptDataClone.prompts.credentials.passwords.ssh_password = { + id: credential.id, + name: credential.name + }; + } + if(credential.inputs.become_password && credential.inputs.become_password === "ASK") { + vm.promptDataClone.prompts.credentials.passwords.become_password = { + id: credential.id, + name: credential.name + }; + } + if(credential.inputs.ssh_key_unlock && credential.inputs.ssh_key_unlock === "ASK") { + vm.promptDataClone.prompts.credentials.passwords.ssh_key_unlock = { + id: credential.id, + name: credential.name + }; + } + if(credential.inputs.vault_password && credential.inputs.vault_password === "ASK") { + if(!vm.promptDataClone.prompts.credentials.passwords.vault) { vm.promptDataClone.prompts.credentials.passwords.vault = []; } - - // Loop across the default credentials to find the name of the - // credential that requires a password - vm.promptDataClone.prompts.credentials.value.forEach((defaultCredential) => { - if (vm.promptDataClone.prompts.credentials.credentialTypes[defaultCredential.credential_type] === "vault") { - let defaultCredVaultId = defaultCredential.vault_id || _.get(defaultCredential, 'inputs.vault_id') || null; - if (defaultCredVaultId === vault_id) { - vm.promptDataClone.prompts.credentials.passwords.vault.push({ - id: defaultCredential.id, - name: defaultCredential.name, - vault_id: defaultCredVaultId - }); - } - } + vm.promptDataClone.prompts.credentials.passwords.vault.push({ + id: credential.id, + name: credential.name, + vault_id: credential.inputs.vault_id }); } - }); - } + } else if(credential.passwords_needed && credential.passwords_needed.length > 0) { + credential.passwords_needed.forEach((passwordNeeded) => { + if (passwordNeeded === "ssh_password" || + passwordNeeded === "become_password" || + passwordNeeded === "ssh_key_unlock" + ) { + vm.promptDataClone.prompts.credentials.passwords[passwordNeeded] = { + id: credential.id, + name: credential.name + }; + } else if (passwordNeeded.startsWith("vault_password")) { + let vault_id = null; + if (passwordNeeded.includes('.')) { + vault_id = passwordNeeded.split(/\.(.+)/)[1]; + } + + if (!vm.promptDataClone.prompts.credentials.passwords.vault) { + vm.promptDataClone.prompts.credentials.passwords.vault = []; + } + + vm.promptDataClone.prompts.credentials.passwords.vault.push({ + id: credential.id, + name: credential.name, + vault_id: vault_id + }); + } + }); + } + }); vm.promptDataClone.credentialTypeMissing = []; @@ -141,7 +145,13 @@ export default [ 'Rest', 'GetBasePath', 'ProcessErrors', 'CredentialTypeModel', }; order++; } - if(vm.promptDataClone.launchConf.ask_credential_on_launch || (vm.promptDataClone.launchConf.passwords_needed_to_start && vm.promptDataClone.launchConf.passwords_needed_to_start.length > 0)) { + if (vm.promptDataClone.launchConf.ask_credential_on_launch || + (_.has(vm, 'promptDataClone.prompts.credentials.passwords.vault') && + vm.promptDataClone.prompts.credentials.passwords.vault.length > 0) || + _.has(vm.promptDataClone.prompts.credentials.passwords.ssh_key_unlock) || + _.has(vm.promptDataClone.prompts.credentials.passwords.become_password) || + _.has(vm.promptDataClone.prompts.credentials.passwords.ssh_password) + ) { vm.steps.credential.includeStep = true; vm.steps.credential.tab = { _active: order === 1 ? true : false, diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js index 0665098a11..97b2007223 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.controller.js @@ -11,7 +11,7 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', $state, ProcessErrors, CreateSelect2, $q, JobTemplate, Empty, PromptService, Rest, TemplatesStrings, $timeout) { - let promptWatcher, surveyQuestionWatcher; + let promptWatcher, surveyQuestionWatcher, credentialsWatcher; $scope.strings = TemplatesStrings; $scope.preventCredsWithPasswords = true; @@ -341,6 +341,20 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', }); }; + let checkCredentialsForRequiredPasswords = () => { + let credentialRequiresPassword = false; + $scope.promptData.prompts.credentials.value.forEach((credential) => { + if ((credential.passwords_needed && + credential.passwords_needed.length > 0) || + (_.has(credential, 'inputs.vault_password') && + credential.inputs.vault_password === "ASK") + ) { + credentialRequiresPassword = true; + } + }); + $scope.credentialRequiresPassword = credentialRequiresPassword; + }; + let watchForPromptChanges = () => { let promptDataToWatch = [ 'promptData.prompts.inventory.value', @@ -357,6 +371,12 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', } $scope.promptModalMissingReqFields = missingPromptValue; }); + + if ($scope.promptData.launchConf.ask_credential_on_launch && $scope.credentialRequiresPassword) { + credentialsWatcher = $scope.$watch('promptData.prompts.credentials', () => { + checkCredentialsForRequiredPasswords(); + }); + } }; $scope.closeWorkflowMaker = function() { @@ -537,6 +557,10 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', surveyQuestionWatcher(); } + if (credentialsWatcher) { + credentialsWatcher(); + } + $scope.promptData = null; // Reset the edgeConflict flag @@ -564,6 +588,10 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', surveyQuestionWatcher(); } + if (credentialsWatcher) { + credentialsWatcher(); + } + $scope.promptData = null; $scope.selectedTemplateInvalid = false; $scope.showPromptButton = false; @@ -671,6 +699,24 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', $scope.selectedTemplateInvalid = false; } + let credentialRequiresPassword = false; + + prompts.credentials.value.forEach((credential) => { + if(credential.inputs) { + if ((credential.inputs.password && credential.inputs.password === "ASK") || + (credential.inputs.become_password && credential.inputs.become_password === "ASK") || + (credential.inputs.ssh_key_unlock && credential.inputs.ssh_key_unlock === "ASK") || + (credential.inputs.vault_password && credential.inputs.vault_password === "ASK") + ) { + credentialRequiresPassword = true; + } + } else if (credential.passwords_needed && credential.passwords_needed.length > 0) { + credentialRequiresPassword = true; + } + }); + + $scope.credentialRequiresPassword = credentialRequiresPassword; + if (!launchConf.survey_enabled && !launchConf.ask_inventory_on_launch && !launchConf.ask_credential_on_launch && @@ -682,7 +728,6 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', !launchConf.ask_diff_mode_on_launch && !launchConf.survey_enabled && !launchConf.credential_needed_to_start && - launchConf.passwords_needed_to_start.length === 0 && launchConf.variables_needed_to_start.length === 0) { $scope.showPromptButton = false; $scope.promptModalMissingReqFields = false; @@ -727,6 +772,8 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', $scope.missingSurveyValue = missingSurveyValue; }, true); + checkCredentialsForRequiredPasswords(); + watchForPromptChanges(); }); } else { @@ -736,6 +783,9 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', prompts: prompts, template: $scope.nodeBeingEdited.unifiedJobTemplate.id }; + + checkCredentialsForRequiredPasswords(); + watchForPromptChanges(); } } @@ -980,6 +1030,10 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', surveyQuestionWatcher(); } + if (credentialsWatcher) { + credentialsWatcher(); + } + if (selectedTemplate.type === "job_template") { let jobTemplate = new JobTemplate(); @@ -993,6 +1047,12 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', $scope.selectedTemplateInvalid = false; } + if (launchConf.passwords_needed_to_start && launchConf.passwords_needed_to_start.length > 0) { + $scope.credentialRequiresPassword = true; + } else { + $scope.credentialRequiresPassword = false; + } + $scope.selectedTemplate = angular.copy(selectedTemplate); if (!launchConf.survey_enabled && @@ -1006,7 +1066,6 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', !launchConf.ask_diff_mode_on_launch && !launchConf.survey_enabled && !launchConf.credential_needed_to_start && - launchConf.passwords_needed_to_start.length === 0 && launchConf.variables_needed_to_start.length === 0) { $scope.showPromptButton = false; $scope.promptModalMissingReqFields = false; @@ -1069,7 +1128,6 @@ export default ['$scope', 'WorkflowService', 'GetBasePath', 'TemplatesService', } }); } else { - // TODO - clear out prompt data? $scope.selectedTemplate = angular.copy(selectedTemplate); $scope.selectedTemplateInvalid = false; $scope.showPromptButton = false; diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 6d92841eb0..9580b72bfc 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -99,7 +99,13 @@ {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }}
-
+
+
+ + {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} +
+
+
From 1a4e7c8572ad3258d9b8421e8431ef469d3921e5 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 31 May 2018 14:38:29 -0400 Subject: [PATCH 164/762] Rolled credential warning string back to include remove or replace --- awx/ui/client/features/templates/templates.strings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 21c7121d46..a0fef3aaff 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -33,7 +33,7 @@ function TemplatesStrings (BaseString) { NO_INVENTORY_SELECTED: t.s('No inventory selected'), REVERT: t.s('REVERT'), CREDENTIAL_TYPE: t.s('Credential Type'), - CREDENTIAL_PASSWORD_WARNING: t.s('Credentials that require passwords on launch are not permitted for template schedules and workflow nodes. The following credentials must be replaced to proceed:'), + CREDENTIAL_PASSWORD_WARNING: t.s('Credentials that require passwords on launch are not permitted for template schedules and workflow nodes. The following credentials must be removed or replaced to proceed:'), PASSWORDS_REQUIRED_HELP: t.s('Launching this job requires the passwords listed below. Enter and confirm each password before continuing.'), PLEASE_ENTER_PASSWORD: t.s('Please enter a password.'), credential_passwords: { From 8509a43b955ec64dad16762917ed3cdaae3d7d8d Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 7 Jun 2018 13:34:58 -0400 Subject: [PATCH 165/762] exclude m2m copies from activity stream --- awx/api/generics.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index b0155e1429..1bbed0a825 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -909,9 +909,11 @@ class CopyAPIView(GenericAPIView): # not work properly in non-request-response-cycle context. new_obj.created_by = creater new_obj.save() - for m2m in m2m_to_preserve: - for related_obj in m2m_to_preserve[m2m].all(): - getattr(new_obj, m2m).add(related_obj) + from awx.main.signals import disable_activity_stream + with disable_activity_stream(): + for m2m in m2m_to_preserve: + for related_obj in m2m_to_preserve[m2m].all(): + getattr(new_obj, m2m).add(related_obj) if not old_parent: sub_objects = [] for o2m in o2m_to_preserve: From 91c46731d1431f775c6c98d7638c3ee6a4fabb75 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 7 Jun 2018 13:42:33 -0400 Subject: [PATCH 166/762] Removed unused Alert --- awx/ui/client/src/scheduler/schedulerList.controller.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/awx/ui/client/src/scheduler/schedulerList.controller.js b/awx/ui/client/src/scheduler/schedulerList.controller.js index d171e52d25..22f7ffe0de 100644 --- a/awx/ui/client/src/scheduler/schedulerList.controller.js +++ b/awx/ui/client/src/scheduler/schedulerList.controller.js @@ -15,11 +15,9 @@ export default [ '$filter', '$scope', '$location', '$stateParams', 'ScheduleList', 'Rest', 'rbacUiControlService', 'JobTemplateModel', 'ToggleSchedule', 'DeleteSchedule', '$q', '$state', 'Dataset', 'ParentObject', 'UnifiedJobsOptions', 'i18n', - 'Alert', function($filter, $scope, $location, $stateParams, ScheduleList, Rest, rbacUiControlService, JobTemplate, ToggleSchedule, DeleteSchedule, - $q, $state, Dataset, ParentObject, UnifiedJobsOptions, i18n, - Alert + $q, $state, Dataset, ParentObject, UnifiedJobsOptions, i18n ) { var base, scheduleEndpoint, From 7ecef3ee5edea9293d9214644b3f54c8bc916aba Mon Sep 17 00:00:00 2001 From: kialam Date: Thu, 7 Jun 2018 13:49:26 -0400 Subject: [PATCH 167/762] Fix placement of network ui `select2` dropdowns Render them in the correct parent container in the DOM. --- .../src/network-ui/network-nav/network.nav.controller.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js b/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js index fba7aa1058..dde43e8c34 100644 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js +++ b/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js @@ -45,13 +45,15 @@ function NetworkingController (models, $state, $scope, strings) { $("#networking-search").select2({ width:'400px', containerCssClass: 'Form-dropDown', - placeholder: 'SEARCH' + placeholder: 'SEARCH', + dropdownParent: $('.Networking-toolbar'), }); $("#networking-actionsDropdown").select2({ width:'400px', containerCssClass: 'Form-dropDown', minimumResultsForSearch: -1, - placeholder: 'ACTIONS' + placeholder: 'ACTIONS', + dropdownParent: $('.Networking-toolbar'), }); }); From ed762fd4b6479fd51b74e46b05480f6f4749bfd2 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 7 Jun 2018 14:17:06 -0400 Subject: [PATCH 168/762] prohibit users without read_role from viewing copy endpoint --- awx/api/generics.py | 2 ++ awx/main/tests/functional/test_copy.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/awx/api/generics.py b/awx/api/generics.py index b0155e1429..67154ba786 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -929,6 +929,8 @@ class CopyAPIView(GenericAPIView): if get_request_version(request) < 2: return self.v1_not_allowed() obj = self.get_object() + if not request.user.can_access(obj.__class__, 'read', obj): + raise PermissionDenied() create_kwargs = self._build_create_dict(obj) for key in create_kwargs: create_kwargs[key] = getattr(create_kwargs[key], 'pk', None) or create_kwargs[key] diff --git a/awx/main/tests/functional/test_copy.py b/awx/main/tests/functional/test_copy.py index 99e123a8fa..0b651f59ca 100644 --- a/awx/main/tests/functional/test_copy.py +++ b/awx/main/tests/functional/test_copy.py @@ -170,7 +170,7 @@ def test_credential_copy(post, get, machine_credential, credentialtype_ssh, admi @pytest.mark.django_db def test_notification_template_copy(post, get, notification_template_with_encrypt, organization, alice): - #notification_template_with_encrypt.admin_role.members.add(alice) + notification_template_with_encrypt.organization.auditor_role.members.add(alice) assert get( reverse( 'api:notification_template_copy', kwargs={'pk': notification_template_with_encrypt.pk} @@ -197,6 +197,7 @@ def test_notification_template_copy(post, get, notification_template_with_encryp @pytest.mark.django_db def test_inventory_script_copy(post, get, inventory_script, organization, alice): + inventory_script.organization.auditor_role.members.add(alice) assert get( reverse('api:inventory_script_copy', kwargs={'pk': inventory_script.pk}), alice, expect=200 ).data['can_copy'] is False From ab42c710eb2c039b1d9a6e682cb1c43aa309a16e Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Thu, 7 Jun 2018 15:58:04 -0400 Subject: [PATCH 169/762] validate form when toggle replace/revert on password/secret --- awx/ui/client/lib/components/input/base.controller.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/ui/client/lib/components/input/base.controller.js b/awx/ui/client/lib/components/input/base.controller.js index 32fa30b458..64ebbcd280 100644 --- a/awx/ui/client/lib/components/input/base.controller.js +++ b/awx/ui/client/lib/components/input/base.controller.js @@ -94,6 +94,7 @@ function BaseInputController (strings) { scope.state._value = scope.state._preEditValue; scope.state._activeModel = '_displayValue'; scope.state._placeholder = vm.strings.get('ENCRYPTED'); + vm.check(); } else { scope.state._buttonText = vm.strings.get('REVERT'); scope.state._disabled = false; @@ -101,6 +102,7 @@ function BaseInputController (strings) { scope.state._activeModel = '_value'; scope.state._value = ''; scope.state._placeholder = ''; + vm.check(); } }; From 1c414789fb26a86bfbeca3094093e263a3d67236 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 5 Jun 2018 09:33:35 -0400 Subject: [PATCH 170/762] fix memory leak in output render service --- .../features/output/index.controller.js | 20 ++---- .../client/features/output/render.service.js | 65 ++++++++++++------- 2 files changed, 47 insertions(+), 38 deletions(-) diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 6c50a10abc..d2daef915e 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -2,6 +2,7 @@ let $compile; let $filter; let $q; let $scope; +let $state; let page; let render; @@ -19,6 +20,7 @@ function JobsIndexController ( _$filter_, _$q_, _$scope_, + _$state_, _resource_, _page_, _scroll_, @@ -33,6 +35,7 @@ function JobsIndexController ( $filter = _$filter_; $q = _$q_; $scope = _$scope_; + $state = _$state_; resource = _resource_; page = _page_; @@ -352,19 +355,9 @@ function devClear () { render.clear().then(() => init()); } -// function showHostDetails (id) { -// jobEvent.request('get', id) -// .then(() => { -// const title = jobEvent.get('host_name'); - -// vm.host = { -// menu: true, -// stdout: jobEvent.get('stdout') -// }; - -// $scope.jobs.modal.show(title); -// }); -// } +function showHostDetails (id, uuid) { + $state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); +} // function toggle (uuid, menu) { // const lines = $(`.child-of-${uuid}`); @@ -397,6 +390,7 @@ JobsIndexController.$inject = [ '$filter', '$q', '$scope', + '$state', 'resource', 'JobPageService', 'JobScrollService', diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js index 08a3498fd2..3f315a1ac0 100644 --- a/awx/ui/client/features/output/render.service.js +++ b/awx/ui/client/features/output/render.service.js @@ -30,11 +30,13 @@ const re = new RegExp(pattern); const hasAnsi = input => re.test(input); function JobRenderService ($q, $sce, $window) { - this.init = ({ compile, isStreamActive }) => { + this.init = ({ compile }) => { this.parent = null; this.record = {}; this.el = $(ELEMENT_TBODY); - this.hooks = { isStreamActive, compile }; + this.hooks = { compile }; + + this.createToggles = false; }; this.sortByLineNumber = (a, b) => { @@ -55,12 +57,11 @@ function JobRenderService ($q, $sce, $window) { events.sort(this.sortByLineNumber); - events.forEach(event => { - const line = this.transformEvent(event); - + for (let i = 0; i < events.length; ++i) { + const line = this.transformEvent(events[i]); html += line.html; lines += line.count; - }); + } return { html, lines }; }; @@ -177,13 +178,13 @@ function JobRenderService ($q, $sce, $window) { } if (current) { - if (!this.hooks.isStreamActive() && current.isParent && current.line === ln) { + if (this.createToggles && current.isParent && current.line === ln) { id = current.uuid; tdToggle = ``; } if (current.isHost) { - tdEvent = `${content}`; + tdEvent = `${content}`; } if (current.time && current.line === ln) { @@ -239,18 +240,7 @@ function JobRenderService ($q, $sce, $window) { return list; }; - this.insert = (events, insert) => { - const result = this.transformEventGroup(events); - const html = this.trustHtml(result.html); - - return this.requestAnimationFrame(() => insert(html)) - .then(() => this.compile(html)) - .then(() => result.lines); - }; - - this.remove = elements => this.requestAnimationFrame(() => { - elements.remove(); - }); + this.remove = elements => this.requestAnimationFrame(() => elements.remove()); this.requestAnimationFrame = fn => $q(resolve => { $window.requestAnimationFrame(() => { @@ -262,9 +252,8 @@ function JobRenderService ($q, $sce, $window) { }); }); - this.compile = html => { - html = $(this.el); - this.hooks.compile(html); + this.compile = content => { + this.hooks.compile(content); return this.requestAnimationFrame(); }; @@ -286,9 +275,35 @@ function JobRenderService ($q, $sce, $window) { return this.remove(elements); }; - this.prepend = events => this.insert(events, html => this.el.prepend(html)); + this.prepend = events => { + if (events.length < 1) { + return $q.resolve(); + } - this.append = events => this.insert(events, html => this.el.append(html)); + const result = this.transformEventGroup(events); + const html = this.trustHtml(result.html); + + const newElements = angular.element(html); + + return this.requestAnimationFrame(() => this.el.prepend(newElements)) + .then(() => this.compile(newElements)) + .then(() => result.lines); + }; + + this.append = events => { + if (events.length < 1) { + return $q.resolve(); + } + + const result = this.transformEventGroup(events); + const html = this.trustHtml(result.html); + + const newElements = angular.element(html); + + return this.requestAnimationFrame(() => this.el.append(newElements)) + .then(() => this.compile(newElements)) + .then(() => result.lines); + }; this.trustHtml = html => $sce.getTrustedHtml($sce.trustAsHtml(html)); From cbae7efdd50e05cd17bd851f2a935133e3a295ba Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 5 Jun 2018 09:56:20 -0400 Subject: [PATCH 171/762] use a sliding window over counter intervals --- .../features/output/api.events.service.js | 180 +++--- .../features/output/details.component.js | 2 +- .../features/output/details.partial.html | 1 + .../client/features/output/engine.service.js | 235 -------- .../features/output/index.controller.js | 551 +++++++----------- awx/ui/client/features/output/index.js | 20 +- awx/ui/client/features/output/index.view.html | 14 +- awx/ui/client/features/output/page.service.js | 283 --------- .../client/features/output/scroll.service.js | 26 +- .../features/output/search.component.js | 2 +- .../features/output/search.partial.html | 68 ++- .../client/features/output/slide.service.js | 298 ++++++++++ .../client/features/output/stats.component.js | 2 +- .../client/features/output/stats.partial.html | 2 +- .../client/features/output/status.service.js | 8 +- .../client/features/output/stream.service.js | 146 +++++ 16 files changed, 800 insertions(+), 1038 deletions(-) delete mode 100644 awx/ui/client/features/output/engine.service.js delete mode 100644 awx/ui/client/features/output/page.service.js create mode 100644 awx/ui/client/features/output/slide.service.js create mode 100644 awx/ui/client/features/output/stream.service.js diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index ecf247633c..0e9fc6b6a6 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -1,144 +1,110 @@ -const PAGE_LIMIT = 5; +const API_PAGE_SIZE = 200; const PAGE_SIZE = 50; +const ORDER_BY = 'counter'; const BASE_PARAMS = { - order_by: 'start_line', page_size: PAGE_SIZE, + order_by: ORDER_BY, }; const merge = (...objs) => _.merge({}, ...objs); -const getInitialState = params => ({ - results: [], - count: 0, - previous: 1, - page: 1, - next: 1, - last: 1, - params: merge(BASE_PARAMS, params), -}); - function JobEventsApiService ($http, $q) { this.init = (endpoint, params) => { - this.keys = []; - this.cache = {}; - this.pageSizes = {}; this.endpoint = endpoint; - this.state = getInitialState(params); + this.params = merge(BASE_PARAMS, params); + + this.state = { current: 0, count: 0 }; }; - this.getLastPage = count => Math.ceil(count / this.state.params.page_size); - - this.clearCache = () => { - delete this.cache; - delete this.keys; - delete this.pageSizes; - - this.cache = {}; - this.keys = []; - this.pageSizes = {}; - }; - - this.fetch = () => this.first().then(() => this); + this.fetch = () => this.getFirst().then(() => this); this.getPage = number => { - if (number < 1 || number > this.state.last) { - return $q.resolve(); - } + if (number === 1) return this.getFirst(); - if (this.cache[number]) { - if (this.pageSizes[number] === PAGE_SIZE) { - return this.cache[number]; - } + const [low, high] = [1 + PAGE_SIZE * (number - 1), PAGE_SIZE * number]; + const params = merge(this.params, { counter__gte: [low], counter__lte: [high] }); - delete this.pageSizes[number]; - delete this.cache[number]; - - this.keys.splice(this.keys.indexOf(number)); - } - - const { params } = this.state; - - delete params.page; - - params.page = number; - - const promise = $http.get(this.endpoint, { params }) + return $http.get(this.endpoint, { params }) .then(({ data }) => { - const { results, count } = data; + const { results } = data; - this.state.results = results; - this.state.count = count; - this.state.page = number; - this.state.last = this.getLastPage(count); - this.state.previous = Math.max(1, number - 1); - this.state.next = Math.min(this.state.last, number + 1); + this.state.current = number; - this.pageSizes[number] = results.length; - - return { results, page: number }; + return results; }); - - if (number === 1) { - this.clearCache(); - } - - this.cache[number] = promise; - this.keys.push(number); - - if (this.keys.length > PAGE_LIMIT) { - const remove = this.keys.shift(); - - delete this.cache[remove]; - delete this.pageSizes[remove]; - } - - return promise; }; - this.first = () => this.getPage(1); - this.next = () => this.getPage(this.state.next); - this.previous = () => this.getPage(this.state.previous); + this.getFirst = () => { + const page = 1; + const params = merge(this.params, { page }); - this.last = () => { - const params = merge({}, this.state.params); - - delete params.page; - delete params.order_by; - - params.page = 1; - params.order_by = '-start_line'; - - const promise = $http.get(this.endpoint, { params }) + return $http.get(this.endpoint, { params }) .then(({ data }) => { const { results, count } = data; - const lastPage = this.getLastPage(count); + + this.state.count = count; + this.state.current = page; + + return results; + }); + }; + + this.getRange = range => { + if (!range) { + return $q.resolve([]); + } + + const [low, high] = range; + const params = merge(this.params, { counter__gte: [low], counter__lte: [high] }); + + params.page_size = API_PAGE_SIZE; + + return $http.get(this.endpoint, { params }) + .then(({ data }) => { + const { results } = data; + const maxCounter = Math.max(results.map(({ counter }) => counter)); + + this.state.current = Math.ceil(maxCounter / PAGE_SIZE); + + return results; + }); + }; + + this.getLast = () => { + const params = merge(this.params, { page: 1, order_by: `-${ORDER_BY}` }); + + return $http.get(this.endpoint, { params }) + .then(({ data }) => { + const { results } = data; + const count = Math.max(...results.map(({ counter }) => counter)); + + let rotated = results; if (count > PAGE_SIZE) { - results.splice(count % PAGE_SIZE); + rotated = results.splice(count % PAGE_SIZE); + + if (results.length > 0) { + rotated = results; + } } - - results.reverse(); - - this.state.results = results; this.state.count = count; - this.state.page = lastPage; - this.state.next = lastPage; - this.state.last = lastPage; - this.state.previous = Math.max(1, this.state.page - 1); + this.state.current = Math.ceil(count / PAGE_SIZE); - this.clearCache(); - - return { results, page: lastPage }; + return rotated; }); - - return promise; }; + + this.getCurrentPageNumber = () => this.state.current; + this.getLastPageNumber = () => Math.ceil(this.state.count / PAGE_SIZE); + this.getPreviousPageNumber = () => Math.max(1, this.state.current - 1); + this.getNextPageNumber = () => Math.min(this.state.current + 1, this.getLastPageNumber()); + this.getMaxCounter = () => this.state.count; + + this.getNext = () => this.getPage(this.getNextPageNumber()); + this.getPrevious = () => this.getPage(this.getPreviousPageNumber()); } -JobEventsApiService.$inject = [ - '$http', - '$q' -]; +JobEventsApiService.$inject = ['$http', '$q']; export default JobEventsApiService; diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 03fd2f9d02..33f143ecd5 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -729,7 +729,7 @@ JobDetailsController.$inject = [ 'OutputStrings', 'Wait', 'ParseVariableString', - 'JobStatusService', + 'OutputStatusService', ]; export default { diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 47f538a44c..c30cc68779 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -1,4 +1,5 @@ +
{{:: vm.strings.get('details.HEADER')}}
diff --git a/awx/ui/client/features/output/engine.service.js b/awx/ui/client/features/output/engine.service.js deleted file mode 100644 index bb884e52af..0000000000 --- a/awx/ui/client/features/output/engine.service.js +++ /dev/null @@ -1,235 +0,0 @@ -const JOB_END = 'playbook_on_stats'; -const MAX_LAG = 120; - -function JobEventEngine ($q) { - this.init = ({ resource, scroll, page, onEventFrame, onStart, onStop }) => { - this.resource = resource; - this.scroll = scroll; - this.page = page; - - this.lag = 0; - this.count = 0; - this.pageCount = 0; - this.chain = $q.resolve(); - this.factors = this.getBatchFactors(this.resource.page.size); - - this.state = { - started: false, - paused: false, - pausing: false, - resuming: false, - ending: false, - ended: false, - counting: false, - }; - - this.hooks = { - onEventFrame, - onStart, - onStop, - }; - - this.lines = { - used: [], - missing: [], - ready: false, - min: 0, - max: 0 - }; - }; - - this.setMinLine = min => { - if (min > this.lines.min) { - this.lines.min = min; - } - }; - - this.getBatchFactors = size => { - const factors = [1]; - - for (let i = 2; i <= size / 2; i++) { - if (size % i === 0) { - factors.push(i); - } - } - - factors.push(size); - - return factors; - }; - - this.getBatchFactorIndex = () => { - const index = Math.floor((this.lag / MAX_LAG) * this.factors.length); - - return index > this.factors.length - 1 ? this.factors.length - 1 : index; - }; - - this.setBatchFrameCount = () => { - const index = this.getBatchFactorIndex(); - - this.framesPerRender = this.factors[index]; - }; - - this.buffer = data => { - const pageAdded = this.page.addToBuffer(data); - - if (pageAdded) { - this.pageCount++; - this.setBatchFrameCount(); - - if (this.isPausing()) { - this.pause(true); - } else if (this.isResuming()) { - this.resume(true); - } - } - }; - - this.checkLines = data => { - for (let i = data.start_line; i < data.end_line; i++) { - if (i > this.lines.max) { - this.lines.max = i; - } - - this.lines.used.push(i); - } - - const missing = []; - for (let i = this.lines.min; i < this.lines.max; i++) { - if (this.lines.used.indexOf(i) === -1) { - missing.push(i); - } - } - - if (missing.length === 0) { - this.lines.ready = true; - this.lines.min = this.lines.max + 1; - this.lines.used = []; - } else { - this.lines.ready = false; - } - }; - - this.pushJobEvent = data => { - this.lag++; - - this.chain = this.chain - .then(() => { - if (data.end_line < this.lines.min) { - return $q.resolve(); - } - - if (!this.isActive()) { - this.start(); - } else if (data.event === JOB_END) { - if (this.isPaused()) { - this.end(true); - } else { - this.end(); - } - } - - this.checkLines(data); - this.buffer(data); - this.count++; - - if (!this.isReadyToRender()) { - return $q.resolve(); - } - - const events = this.page.emptyBuffer(); - this.count -= events.length; - - return this.renderFrame(events); - }) - .then(() => --this.lag); - - return this.chain; - }; - - this.renderFrame = events => this.hooks.onEventFrame(events) - .then(() => { - if (this.scroll.isLocked()) { - this.scroll.scrollToBottom(); - } - - if (this.isEnding()) { - const lastEvents = this.page.emptyBuffer(); - - if (lastEvents.length) { - return this.renderFrame(lastEvents); - } - - this.end(true); - } - - return $q.resolve(); - }); - - this.resume = done => { - if (done) { - this.state.resuming = false; - this.state.paused = false; - } else if (!this.isTransitioning()) { - this.scroll.pause(); - this.scroll.lock(); - this.scroll.scrollToBottom(); - this.state.resuming = true; - this.page.removeBookmark(); - } - }; - - this.pause = done => { - if (done) { - this.state.pausing = false; - this.state.paused = true; - this.scroll.resume(); - } else if (!this.isTransitioning()) { - this.scroll.pause(); - this.scroll.unlock(); - this.state.pausing = true; - this.page.setBookmark(); - } - }; - - this.start = () => { - if (!this.state.ending && !this.state.ended) { - this.state.started = true; - this.scroll.pause(); - this.scroll.lock(); - - this.hooks.onStart(); - } - }; - - this.end = done => { - if (done) { - this.state.ending = false; - this.state.ended = true; - this.scroll.unlock(); - this.scroll.resume(); - - this.hooks.onStop(); - - return; - } - - this.state.ending = true; - }; - - this.isReadyToRender = () => this.isDone() || - (!this.isPaused() && this.hasAllLines() && this.isBatchFull()); - this.hasAllLines = () => this.lines.ready; - this.isBatchFull = () => this.count % this.framesPerRender === 0; - this.isPaused = () => this.state.paused; - this.isPausing = () => this.state.pausing; - this.isResuming = () => this.state.resuming; - this.isTransitioning = () => this.isActive() && (this.state.pausing || this.state.resuming); - this.isActive = () => this.state.started && !this.state.ended; - this.isEnding = () => this.state.ending; - this.isDone = () => this.state.ended; -} - -JobEventEngine.$inject = ['$q']; - -export default JobEventEngine; diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index d2daef915e..6a56b94bd8 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -1,129 +1,156 @@ +/* eslint camelcase: 0 */ let $compile; -let $filter; let $q; let $scope; let $state; -let page; -let render; let resource; +let render; let scroll; -let engine; let status; +let slide; +let stream; let vm; -let streaming; -let listeners = []; -function JobsIndexController ( - _$compile_, - _$filter_, - _$q_, - _$scope_, - _$state_, - _resource_, - _page_, - _scroll_, - _render_, - _engine_, - _status_, - _strings_, -) { - vm = this || {}; +const bufferState = [0, 0]; // [length, count] +const listeners = []; +const rx = []; - $compile = _$compile_; - $filter = _$filter_; - $q = _$q_; - $scope = _$scope_; - $state = _$state_; +let following = false; - resource = _resource_; - page = _page_; - scroll = _scroll_; - render = _render_; - engine = _engine_; - status = _status_; +function bufferInit () { + rx.length = 0; - vm.strings = _strings_; - - // Development helper(s) - vm.clear = devClear; - - // Expand/collapse - vm.expanded = false; - vm.toggleExpanded = toggleExpanded; - - // Panel - vm.resource = resource; - vm.title = $filter('sanitize')(resource.model.get('name')); - - // Stdout Navigation - vm.scroll = { - showBackToTop: false, - home: scrollFirst, - end: scrollLast, - down: scrollPageDown, - up: scrollPageUp - }; - - render.requestAnimationFrame(() => init()); + bufferState[0] = 0; + bufferState[1] = 0; } -function init () { - status.init({ - resource, - }); +function bufferAdd (event) { + rx.push(event); - page.init({ - resource, - }); + bufferState[0] += 1; + bufferState[1] += 1; - render.init({ - compile: html => $compile(html)($scope), - isStreamActive: engine.isActive, - }); + return bufferState; +} - scroll.init({ - isAtRest: scrollIsAtRest, - previous, - next, - }); +function bufferEmpty () { + bufferState[0] = 0; - engine.init({ - page, - scroll, - resource, - onEventFrame (events) { - return shift().then(() => append(events, true)); - }, - onStart () { - status.setJobStatus('running'); - }, - onStop () { - stopListening(); - status.updateStats(); - status.dispatch(); + return rx.splice(0, rx.length); +} + +function onFrames (events) { + if (!following) { + const minCounter = Math.min(...events.map(({ counter }) => counter)); + // attachment range + const max = slide.getTailCounter() + 1; + const min = Math.max(1, slide.getHeadCounter(), max - 50); + + if (minCounter > max || minCounter < min) { + return $q.resolve(); } - }); - streaming = false; - - if (status.state.running) { - return scrollLast().then(() => startListening()); - } else if (!status.state.finished) { - return scrollFirst().then(() => startListening()); + follow(); } - return scrollLast(); + const capacity = slide.getCapacity(); + + if (capacity >= events.length) { + return slide.pushFront(events); + } + + delete render.record; + + render.record = {}; + + return slide.popBack(events.length - capacity) + .then(() => slide.pushFront(events)) + .then(() => { + scroll.setScrollPosition(scroll.getScrollHeight()); + + return $q.resolve(); + }); +} + +function first () { + unfollow(); + scroll.pause(); + + return slide.getFirst() + .then(() => { + scroll.resetScrollPosition(); + scroll.resume(); + + return $q.resolve(); + }); +} + +function next () { + return slide.slideDown(); +} + +function previous () { + unfollow(); + + const initialPosition = scroll.getScrollPosition(); + + return slide.slideUp() + .then(changed => { + if (changed[0] !== 0 || changed[1] !== 0) { + const currentHeight = scroll.getScrollHeight(); + scroll.setScrollPosition((currentHeight / 4) - initialPosition); + } + + return $q.resolve(); + }); +} + +function last () { + scroll.pause(); + + return slide.getLast() + .then(() => { + stream.setMissingCounterThreshold(slide.getTailCounter() + 1); + scroll.setScrollPosition(scroll.getScrollHeight()); + + scroll.resume(); + + return $q.resolve(); + }); +} + +function compile (html) { + return $compile(html)($scope); +} + +function follow () { + scroll.pause(); + scroll.hide(); + + following = true; +} + +function unfollow () { + following = false; + + scroll.unhide(); + scroll.resume(); +} + +function showHostDetails (id, uuid) { + $state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); } function stopListening () { listeners.forEach(deregister => deregister()); - listeners = []; + listeners.length = 0; } function startListening () { stopListening(); + listeners.push($scope.$on(resource.ws.events, (scope, data) => handleJobEvent(data))); listeners.push($scope.$on(resource.ws.status, (scope, data) => handleStatusEvent(data))); } @@ -133,271 +160,95 @@ function handleStatusEvent (data) { } function handleJobEvent (data) { - streaming = streaming || attachToRunningJob(); + stream.pushJobEvent(data); + status.pushJobEvent(data); +} - streaming.then(() => { - engine.pushJobEvent(data); - status.pushJobEvent(data); +function OutputIndexController ( + _$compile_, + _$q_, + _$scope_, + _$state_, + _resource_, + _scroll_, + _render_, + _status_, + _slide_, + _stream_, + $filter, + strings, +) { + $compile = _$compile_; + $q = _$q_; + $scope = _$scope_; + $state = _$state_; + + resource = _resource_; + scroll = _scroll_; + render = _render_; + slide = _slide_; + status = _status_; + stream = _stream_; + + vm = this || {}; + + // Panel + vm.strings = strings; + vm.resource = resource; + vm.title = $filter('sanitize')(resource.model.get('name')); + + vm.expanded = false; + vm.showHostDetails = showHostDetails; + vm.toggleExpanded = () => { vm.expanded = !vm.expanded; }; + + // Stdout Navigation + vm.menu = { + end: last, + home: first, + up: previous, + down: next, + }; + + render.requestAnimationFrame(() => { + bufferInit(); + + status.init(resource); + slide.init(render, resource.events); + + render.init({ compile }); + scroll.init({ previous, next }); + + stream.init({ + bufferAdd, + bufferEmpty, + onFrames, + onStop () { + stopListening(); + status.updateStats(); + status.dispatch(); + unfollow(); + } + }); + + startListening(); + + return last(); }); } -function next () { - return page.next() - .then(events => { - if (!events) { - return $q.resolve(); - } - - return shift() - .then(() => append(events)) - .then(() => { - if (scroll.isMissing()) { - return next(); - } - - return $q.resolve(); - }); - }); -} - -function previous () { - const initialPosition = scroll.getScrollPosition(); - let postPopHeight; - - return page.previous() - .then(events => { - if (!events) { - return $q.resolve(); - } - - return pop() - .then(() => { - postPopHeight = scroll.getScrollHeight(); - - return prepend(events); - }) - .then(() => { - const currentHeight = scroll.getScrollHeight(); - scroll.setScrollPosition(currentHeight - postPopHeight + initialPosition); - }); - }); -} - -function append (events, eng) { - return render.append(events) - .then(count => { - page.updateLineCount(count, eng); - }); -} - -function prepend (events) { - return render.prepend(events) - .then(count => { - page.updateLineCount(count); - }); -} - -function pop () { - if (!page.isOverCapacity()) { - return $q.resolve(); - } - - const lines = page.trim(); - - return render.pop(lines); -} - -function shift () { - if (!page.isOverCapacity()) { - return $q.resolve(); - } - - const lines = page.trim(true); - - return render.shift(lines); -} - -function scrollFirst () { - if (engine.isActive()) { - if (engine.isTransitioning()) { - return $q.resolve(); - } - - if (!engine.isPaused()) { - engine.pause(true); - } - } else if (scroll.isPaused()) { - return $q.resolve(); - } - - scroll.pause(); - - return page.first() - .then(events => { - if (!events) { - return $q.resolve(); - } - - return render.clear() - .then(() => prepend(events)) - .then(() => { - scroll.resetScrollPosition(); - scroll.resume(); - }) - .then(() => { - if (scroll.isMissing()) { - return next(); - } - - return $q.resolve(); - }); - }); -} - -function scrollLast () { - if (engine.isActive()) { - if (engine.isTransitioning()) { - return $q.resolve(); - } - - if (engine.isPaused()) { - engine.resume(true); - } - } else if (scroll.isPaused()) { - return $q.resolve(); - } - - scroll.pause(); - - return render.clear() - .then(() => page.last()) - .then(events => { - if (!events) { - return $q.resolve(); - } - - const minLine = 1 + Math.max(...events.map(event => event.end_line)); - engine.setMinLine(minLine); - - return append(events); - }) - .then(() => { - if (!engine.isActive()) { - scroll.resume(); - } - scroll.setScrollPosition(scroll.getScrollHeight()); - }) - .then(() => { - if (!engine.isActive() && scroll.isMissing()) { - return previous(); - } - - return $q.resolve(); - }); -} - -function attachToRunningJob () { - if (engine.isActive()) { - if (engine.isTransitioning()) { - return $q.resolve(); - } - - if (engine.isPaused()) { - engine.resume(true); - } - } else if (scroll.isPaused()) { - return $q.resolve(); - } - - scroll.pause(); - - return page.last() - .then(events => { - if (!events) { - return $q.resolve(); - } - - const minLine = 1 + Math.max(...events.map(event => event.end_line)); - engine.setMinLine(minLine); - - return append(events); - }) - .then(() => { - scroll.setScrollPosition(scroll.getScrollHeight()); - }); -} - -function scrollPageUp () { - if (scroll.isPaused()) { - return; - } - - scroll.pageUp(); -} - -function scrollPageDown () { - if (scroll.isPaused()) { - return; - } - - scroll.pageDown(); -} - -function scrollIsAtRest (isAtRest) { - vm.scroll.showBackToTop = !isAtRest; -} - -function toggleExpanded () { - vm.expanded = !vm.expanded; -} - -function devClear () { - render.clear().then(() => init()); -} - -function showHostDetails (id, uuid) { - $state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); -} - -// function toggle (uuid, menu) { -// const lines = $(`.child-of-${uuid}`); -// let icon = $(`#${uuid} .at-Stdout-toggle > i`); - -// if (menu || record[uuid].level === 1) { -// vm.isExpanded = !vm.isExpanded; -// } - -// if (record[uuid].children) { -// icon = icon.add($(`#${record[uuid].children.join(', #')}`) -// .find('.at-Stdout-toggle > i')); -// } - -// if (icon.hasClass('fa-angle-down')) { -// icon.addClass('fa-angle-right'); -// icon.removeClass('fa-angle-down'); - -// lines.addClass('hidden'); -// } else { -// icon.addClass('fa-angle-down'); -// icon.removeClass('fa-angle-right'); - -// lines.removeClass('hidden'); -// } -// } - -JobsIndexController.$inject = [ +OutputIndexController.$inject = [ '$compile', - '$filter', '$q', '$scope', '$state', 'resource', - 'JobPageService', - 'JobScrollService', - 'JobRenderService', - 'JobEventEngine', - 'JobStatusService', + 'OutputScrollService', + 'OutputRenderService', + 'OutputStatusService', + 'OutputSlideService', + 'OutputStreamService', + '$filter', 'OutputStrings', ]; -module.exports = JobsIndexController; +module.exports = OutputIndexController; diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index de29d1b4c6..f124edb342 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -3,13 +3,13 @@ import atLibComponents from '~components'; import Strings from '~features/output/output.strings'; import Controller from '~features/output/index.controller'; -import PageService from '~features/output/page.service'; import RenderService from '~features/output/render.service'; import ScrollService from '~features/output/scroll.service'; -import EngineService from '~features/output/engine.service'; +import StreamService from '~features/output/stream.service'; import StatusService from '~features/output/status.service'; import MessageService from '~features/output/message.service'; import EventsApiService from '~features/output/api.events.service'; +import SlideService from '~features/output/slide.service'; import LegacyRedirect from '~features/output/legacy.route'; import DetailsComponent from '~features/output/details.component'; @@ -24,6 +24,8 @@ const MODULE_NAME = 'at.features.output'; const PAGE_CACHE = true; const PAGE_LIMIT = 5; const PAGE_SIZE = 50; +const ORDER_BY = 'counter'; +// const ORDER_BY = 'start_line'; const WS_PREFIX = 'ws'; function resolveResource ( @@ -74,7 +76,7 @@ function resolveResource ( const params = { page_size: PAGE_SIZE, - order_by: 'start_line', + order_by: ORDER_BY, }; const config = { @@ -250,13 +252,13 @@ angular HostEvent ]) .service('OutputStrings', Strings) - .service('JobPageService', PageService) - .service('JobScrollService', ScrollService) - .service('JobRenderService', RenderService) - .service('JobEventEngine', EngineService) - .service('JobStatusService', StatusService) - .service('JobMessageService', MessageService) + .service('OutputScrollService', ScrollService) + .service('OutputRenderService', RenderService) + .service('OutputStreamService', StreamService) + .service('OutputStatusService', StatusService) + .service('OutputMessageService', MessageService) .service('JobEventsApiService', EventsApiService) + .service('OutputSlideService', SlideService) .component('atJobSearch', SearchComponent) .component('atJobStats', StatsComponent) .component('atJobDetails', DetailsComponent) diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html index bfe204958e..441879b21f 100644 --- a/awx/ui/client/features/output/index.view.html +++ b/awx/ui/client/features/output/index.view.html @@ -22,17 +22,17 @@ ng-class="{ 'fa-minus': vm.expanded, 'fa-plus': !vm.expanded }">
-
+
+ ng-class=" { 'at-Stdout-menuIcon--active': vm.menu.isLocked }">
-
+
-
+
-
+
@@ -52,8 +52,8 @@ -
-
+
+

{{:: vm.strings.get('stdout.BACK_TO_TOP') }}

diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js deleted file mode 100644 index b8d9f96fb2..0000000000 --- a/awx/ui/client/features/output/page.service.js +++ /dev/null @@ -1,283 +0,0 @@ -function JobPageService ($q) { - this.init = ({ resource }) => { - this.resource = resource; - this.api = this.resource.events; - - this.page = { - limit: this.resource.page.pageLimit, - size: this.resource.page.size, - cache: [], - state: { - count: 0, - current: 0, - first: 0, - last: 0 - } - }; - - this.bookmark = { - pending: false, - set: true, - cache: [], - state: { - count: 0, - first: 0, - last: 0, - current: 0 - } - }; - - this.result = { - limit: this.page.limit * this.page.size, - count: 0 - }; - - this.buffer = { - count: 0 - }; - }; - - this.addPage = (number, events, push, reference) => { - const page = { number, events, lines: 0 }; - reference = reference || this.getActiveReference(); - - if (push) { - reference.cache.push(page); - reference.state.last = page.number; - reference.state.first = reference.cache[0].number; - } else { - reference.cache.unshift(page); - reference.state.first = page.number; - reference.state.last = reference.cache[reference.cache.length - 1].number; - } - - reference.state.current = page.number; - reference.state.count++; - }; - - this.addToBuffer = event => { - const reference = this.getReference(); - const index = reference.cache.length - 1; - let pageAdded = false; - - if (this.result.count % this.page.size === 0) { - this.addPage(reference.state.current + 1, [event], true, reference); - - if (this.isBookmarkPending()) { - this.setBookmark(); - } - - this.trimBuffer(); - - pageAdded = true; - } else { - reference.cache[index].events.push(event); - } - - this.buffer.count++; - this.result.count++; - - return pageAdded; - }; - - this.trimBuffer = () => { - const reference = this.getReference(); - const diff = reference.cache.length - this.page.limit; - - if (diff <= 0) { - return; - } - - for (let i = 0; i < diff; i++) { - if (reference.cache[i].events) { - this.buffer.count -= reference.cache[i].events.length; - reference.cache[i].events.splice(0, reference.cache[i].events.length); - } - } - }; - - this.isBufferFull = () => { - if (this.buffer.count === 2) { - return true; - } - - return false; - }; - - this.emptyBuffer = () => { - const reference = this.getReference(); - let data = []; - - for (let i = 0; i < reference.cache.length; i++) { - const count = reference.cache[i].events.length; - - if (count > 0) { - this.buffer.count -= count; - data = data.concat(reference.cache[i].events.splice(0, count)); - } - } - - return data; - }; - - this.emptyCache = number => { - const reference = this.getActiveReference(); - - number = number || reference.state.current; - - reference.state.first = number; - reference.state.current = number; - reference.state.last = number; - - reference.cache.splice(0, reference.cache.length); - }; - - this.isOverCapacity = () => { - const reference = this.getActiveReference(); - - return (reference.cache.length - this.page.limit) > 0; - }; - - this.trim = left => { - const reference = this.getActiveReference(); - const excess = reference.cache.length - this.page.limit; - - let ejected; - - if (left) { - ejected = reference.cache.splice(0, excess); - reference.state.first = reference.cache[0].number; - } else { - ejected = reference.cache.splice(-excess); - reference.state.last = reference.cache[reference.cache.length - 1].number; - } - - return ejected.reduce((total, page) => total + page.lines, 0); - }; - - this.isPageBookmarked = number => number >= this.page.bookmark.first && - number <= this.page.bookmark.last; - - this.updateLineCount = (lines, engine) => { - let reference; - - if (engine) { - reference = this.getReference(); - } else { - reference = this.getActiveReference(); - } - - const index = reference.cache.findIndex(item => item.number === reference.state.current); - - reference.cache[index].lines += lines; - }; - - this.isBookmarkPending = () => this.bookmark.pending; - this.isBookmarkSet = () => this.bookmark.set; - - this.setBookmark = () => { - if (this.isBookmarkSet()) { - return; - } - - if (!this.isBookmarkPending()) { - this.bookmark.pending = true; - - return; - } - - this.bookmark.state.first = this.page.state.first; - this.bookmark.state.last = this.page.state.last - 1; - this.bookmark.state.current = this.page.state.current - 1; - this.bookmark.cache = JSON.parse(JSON.stringify(this.page.cache)); - this.bookmark.set = true; - this.bookmark.pending = false; - }; - - this.removeBookmark = () => { - this.bookmark.set = false; - this.bookmark.pending = false; - this.bookmark.cache.splice(0, this.bookmark.cache.length); - this.bookmark.state.first = 0; - this.bookmark.state.last = 0; - this.bookmark.state.current = 0; - }; - - this.next = () => { - const reference = this.getActiveReference(); - const number = reference.state.last + 1; - - return this.api.getPage(number) - .then(data => { - if (!data || !data.results) { - return $q.resolve(); - } - - this.addPage(data.page, [], true); - - return data.results; - }); - }; - - this.previous = () => { - const reference = this.getActiveReference(); - - return this.api.getPage(reference.state.first - 1) - .then(data => { - if (!data || !data.results) { - return $q.resolve(); - } - - this.addPage(data.page, [], false); - - return data.results; - }); - }; - - this.last = () => this.api.last() - .then(data => { - if (!data || !data.results || !data.results.length > 0) { - return $q.resolve(); - } - - this.emptyCache(data.page); - this.addPage(data.page, [], true); - - return data.results; - }); - - this.first = () => this.api.first() - .then(data => { - if (!data || !data.results) { - return $q.resolve(); - } - - this.emptyCache(data.page); - this.addPage(data.page, [], false); - - return data.results; - }); - - this.getActiveReference = () => (this.isBookmarkSet() ? - this.getReference(true) : this.getReference()); - - this.getReference = (bookmark) => { - if (bookmark) { - return { - bookmark: true, - cache: this.bookmark.cache, - state: this.bookmark.state - }; - } - - return { - bookmark: false, - cache: this.page.cache, - state: this.page.state - }; - }; -} - -JobPageService.$inject = ['$q']; - -export default JobPageService; diff --git a/awx/ui/client/features/output/scroll.service.js b/awx/ui/client/features/output/scroll.service.js index a568813ddc..6ebafe09e4 100644 --- a/awx/ui/client/features/output/scroll.service.js +++ b/awx/ui/client/features/output/scroll.service.js @@ -4,7 +4,7 @@ const DELAY = 100; const THRESHOLD = 0.1; function JobScrollService ($q, $timeout) { - this.init = (hooks) => { + this.init = ({ next, previous }) => { this.el = $(ELEMENT_CONTAINER); this.timer = null; @@ -14,15 +14,15 @@ function JobScrollService ($q, $timeout) { }; this.hooks = { - isAtRest: hooks.isAtRest, - next: hooks.next, - previous: hooks.previous + next, + previous, + isAtRest: () => $q.resolve() }; this.state = { - locked: false, + hidden: false, paused: false, - top: true + top: true, }; this.el.scroll(this.listen); @@ -158,6 +158,20 @@ function JobScrollService ($q, $timeout) { this.state.locked = false; }; + this.hide = () => { + if (!this.state.hidden) { + this.el.css('overflow', 'hidden'); + this.state.hidden = true; + } + }; + + this.unhide = () => { + if (this.state.hidden) { + this.el.css('overflow', 'auto'); + this.state.hidden = false; + } + }; + this.isLocked = () => this.state.locked; this.isMissing = () => $(ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight(); } diff --git a/awx/ui/client/features/output/search.component.js b/awx/ui/client/features/output/search.component.js index 490b7c283e..a6dcd3aa4a 100644 --- a/awx/ui/client/features/output/search.component.js +++ b/awx/ui/client/features/output/search.component.js @@ -132,7 +132,7 @@ JobSearchController.$inject = [ '$state', 'QuerySet', 'OutputStrings', - 'JobStatusService', + 'OutputStatusService', ]; export default { diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html index 702b4749b3..ea91fc3de6 100644 --- a/awx/ui/client/features/output/search.partial.html +++ b/awx/ui/client/features/output/search.partial.html @@ -1,39 +1,37 @@ - + +
-
- - - - + - + - -
-

- {{ vm.message }} -

+ type="button"> {{:: vm.strings.get('search.KEY') }} + + +
+

{{ vm.message }}

@@ -41,19 +39,25 @@
-
-
- {{:: vm.strings.get('search.EXAMPLES') }}: +
+
+ {{:: vm.strings.get('search.EXAMPLES') }}: +
+
- -
{{:: vm.strings.get('search.FIELDS') }}: diff --git a/awx/ui/client/features/output/slide.service.js b/awx/ui/client/features/output/slide.service.js new file mode 100644 index 0000000000..afb8044280 --- /dev/null +++ b/awx/ui/client/features/output/slide.service.js @@ -0,0 +1,298 @@ +/* eslint camelcase: 0 */ +const PAGE_SIZE = 50; +const PAGE_LIMIT = 5; +const EVENT_LIMIT = PAGE_LIMIT * PAGE_SIZE; + +const TAIL_ADDITION = 'TAIL_ADDITION'; +const TAIL_DELETION = 'TAIL_DELETION'; +const HEAD_ADDITION = 'HEAD_ADDITION'; +const HEAD_DELETION = 'HEAD_DELETION'; + +function SlidingWindowService ($q) { + this.init = (storage, api) => { + const { prepend, append, shift, pop } = storage; + const { getMaxCounter, getRange, getFirst, getLast } = api; + + this.api = { + getMaxCounter, + getRange, + getFirst, + getLast + }; + + this.storage = { + prepend, + append, + shift, + pop + }; + + this.commands = { + [TAIL_ADDITION]: this.pushFront, + [HEAD_ADDITION]: this.pushBack, + [TAIL_DELETION]: this.popFront, + [HEAD_DELETION]: this.popBack + }; + + this.vectors = { + [TAIL_ADDITION]: [0, 1], + [HEAD_ADDITION]: [-1, 0], + [TAIL_DELETION]: [0, -1], + [HEAD_DELETION]: [1, 0], + }; + + this.records = {}; + this.chain = $q.resolve(); + }; + + this.pushFront = events => { + const tail = this.getTailCounter(); + const newEvents = events.filter(({ counter }) => counter > tail); + + return this.storage.append(newEvents) + .then(() => { + newEvents.forEach(({ counter, start_line, end_line }) => { + this.records[counter] = { start_line, end_line }; + }); + + return $q.resolve(); + }); + }; + + this.pushBack = events => { + const [head, tail] = this.getRange(); + const newEvents = events + .filter(({ counter }) => counter < head || counter > tail); + + return this.storage.prepend(newEvents) + .then(() => { + newEvents.forEach(({ counter, start_line, end_line }) => { + this.records[counter] = { start_line, end_line }; + }); + + return $q.resolve(); + }); + }; + + this.popFront = count => { + if (!count || count <= 0) { + return $q.resolve(); + } + + const max = this.getTailCounter(); + const min = Math.max(this.getHeadCounter(), max - count); + + let lines = 0; + + for (let i = min; i <= max; ++i) { + if (this.records[i]) { + lines += (this.records[i].end_line - this.records[i].start_line); + } + } + + return this.storage.pop(lines) + .then(() => { + for (let i = min; i <= max; ++i) { + delete this.records[i]; + } + + return $q.resolve(); + }); + }; + + this.popBack = count => { + if (!count || count <= 0) { + return $q.resolve(); + } + + const min = this.getHeadCounter(); + const max = Math.min(this.getTailCounter(), min + count); + + let lines = 0; + + for (let i = min; i <= max; ++i) { + if (this.records[i]) { + lines += (this.records[i].end_line - this.records[i].start_line); + } + } + + return this.storage.shift(lines) + .then(() => { + for (let i = min; i <= max; ++i) { + delete this.records[i]; + } + + return $q.resolve(); + }); + }; + + this.getBoundedRange = ([low, high]) => { + const bounds = [1, this.getMaxCounter()]; + + return [Math.max(low, bounds[0]), Math.min(high, bounds[1])]; + }; + + this.move = ([low, high]) => { + const [head, tail] = this.getRange(); + const [newHead, newTail] = this.getBoundedRange([low, high]); + + if (newHead > newTail) { + return $q.resolve([0, 0]); + } + + if (!Number.isFinite(newHead) || !Number.isFinite(newTail)) { + return $q.resolve([0, 0]); + } + + const additions = []; + const deletions = []; + + for (let counter = tail + 1; counter <= newTail; counter++) { + additions.push([counter, TAIL_ADDITION]); + } + + for (let counter = head - 1; counter >= newHead; counter--) { + additions.push([counter, HEAD_ADDITION]); + } + + for (let counter = head; counter < newHead; counter++) { + deletions.push([counter, HEAD_DELETION]); + } + + for (let counter = tail; counter > newTail; counter--) { + deletions.push([counter, TAIL_DELETION]); + } + + const hasCounter = (items, n) => items + .filter(([counter]) => counter === n).length !== 0; + + const commandRange = { + [TAIL_DELETION]: 0, + [HEAD_DELETION]: 0, + [TAIL_ADDITION]: [tail, tail], + [HEAD_ADDITION]: [head, head], + }; + + deletions.forEach(([counter, key]) => { + if (!hasCounter(additions, counter)) { + commandRange[key] += 1; + } + + commandRange[TAIL_ADDITION][0] += this.vectors[key][0]; + commandRange[TAIL_ADDITION][1] += this.vectors[key][1]; + + commandRange[HEAD_ADDITION][0] += this.vectors[key][0]; + commandRange[HEAD_ADDITION][1] += this.vectors[key][1]; + }); + + additions.forEach(([counter, key]) => { + if (!hasCounter(deletions, counter)) { + if (counter < commandRange[key][0]) { + commandRange[key][0] = counter; + } + + if (counter > commandRange[key][1]) { + commandRange[key][1] = counter; + } + } + }); + + this.chain = this.chain + .then(() => this.commands[TAIL_DELETION](commandRange[TAIL_DELETION])) + .then(() => this.commands[HEAD_DELETION](commandRange[HEAD_DELETION])) + .then(() => this.api.getRange(commandRange[TAIL_ADDITION])) + .then(events => this.commands[TAIL_ADDITION](events)) + .then(() => this.api.getRange(commandRange[HEAD_ADDITION])) + .then(events => this.commands[HEAD_ADDITION](events)) + .then(() => { + const range = this.getRange(); + const displacement = [range[0] - head, range[1] - tail]; + + return $q.resolve(displacement); + }); + + return this.chain; + }; + + this.slideDown = (displacement = PAGE_SIZE) => { + const [head, tail] = this.getRange(); + + const tailRoom = this.getMaxCounter() - tail; + const tailDisplacement = Math.min(tailRoom, displacement); + const headDisplacement = Math.min(tailRoom, displacement); + + return this.move([head + headDisplacement, tail + tailDisplacement]); + }; + + this.slideUp = (displacement = PAGE_SIZE) => { + const [head, tail] = this.getRange(); + + const headRoom = head - 1; + const headDisplacement = Math.min(headRoom, displacement); + const tailDisplacement = Math.min(headRoom, displacement); + + return this.move([head - headDisplacement, tail - tailDisplacement]); + }; + + this.moveHead = displacement => { + const [head, tail] = this.getRange(); + + const headRoom = head - 1; + const headDisplacement = Math.min(headRoom, displacement); + + return this.move([head + headDisplacement, tail]); + }; + + this.moveTail = displacement => { + const [head, tail] = this.getRange(); + + const tailRoom = this.getMaxCounter() - tail; + const tailDisplacement = Math.max(tailRoom, displacement); + + return this.move([head, tail + tailDisplacement]); + }; + + this.clear = () => { + const count = this.getRecordCount(); + + if (count > 0) { + this.chain = this.chain + .then(() => this.commands[HEAD_DELETION](count)); + } + + return this.chain; + }; + + this.getFirst = () => this.clear() + .then(() => this.api.getFirst()) + .then(events => this.commands[TAIL_ADDITION](events)) + .then(() => this.moveTail(PAGE_SIZE)); + + this.getLast = () => this.clear() + .then(() => this.api.getLast()) + .then(events => this.commands[HEAD_ADDITION](events)) + .then(() => this.moveHead(-PAGE_SIZE)); + + this.getTailCounter = () => { + const tail = Math.max(...Object.keys(this.records)); + + return Number.isFinite(tail) ? tail : 0; + }; + + this.getHeadCounter = () => { + const head = Math.min(...Object.keys(this.records)); + + return Number.isFinite(head) ? head : 0; + }; + + this.compareRange = (a, b) => a[0] === b[0] && a[1] === b[1]; + this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; + + this.getMaxCounter = () => this.api.getMaxCounter(); + this.getRecordCount = () => Object.keys(this.records).length; + this.getCapacity = () => EVENT_LIMIT - this.getRecordCount(); +} + +SlidingWindowService.$inject = ['$q']; + +export default SlidingWindowService; diff --git a/awx/ui/client/features/output/stats.component.js b/awx/ui/client/features/output/stats.component.js index d9051727de..7ce5e31af1 100644 --- a/awx/ui/client/features/output/stats.component.js +++ b/awx/ui/client/features/output/stats.component.js @@ -63,7 +63,7 @@ function JobStatsController (strings, { subscribe }) { JobStatsController.$inject = [ 'OutputStrings', - 'JobStatusService', + 'OutputStatusService', ]; export default { diff --git a/awx/ui/client/features/output/stats.partial.html b/awx/ui/client/features/output/stats.partial.html index c1c7fdb4a7..151ae1d23b 100644 --- a/awx/ui/client/features/output/stats.partial.html +++ b/awx/ui/client/features/output/stats.partial.html @@ -1,4 +1,4 @@ - +
plays ... diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index 717744fea7..dfb7d40e04 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -12,9 +12,7 @@ function JobStatusService (moment, message) { this.dispatch = () => message.dispatch('status', this.state); this.subscribe = listener => message.subscribe('status', listener); - this.init = ({ resource }) => { - const { model } = resource; - + this.init = ({ model, stats }) => { this.created = model.get('created'); this.job = model.get('id'); this.jobType = model.get('type'); @@ -43,7 +41,7 @@ function JobStatusService (moment, message) { }, }; - this.setStatsEvent(resource.stats); + this.setStatsEvent(stats); this.updateStats(); this.updateRunningState(); @@ -213,7 +211,7 @@ function JobStatusService (moment, message) { JobStatusService.$inject = [ 'moment', - 'JobMessageService', + 'OutputMessageService', ]; export default JobStatusService; diff --git a/awx/ui/client/features/output/stream.service.js b/awx/ui/client/features/output/stream.service.js new file mode 100644 index 0000000000..da73f70c23 --- /dev/null +++ b/awx/ui/client/features/output/stream.service.js @@ -0,0 +1,146 @@ +/* eslint camelcase: 0 */ +const PAGE_SIZE = 50; +const MAX_LAG = 120; +const JOB_END = 'playbook_on_stats'; + +function OutputStream ($q) { + this.init = ({ bufferAdd, bufferEmpty, onFrames, onStop }) => { + this.hooks = { + bufferAdd, + bufferEmpty, + onFrames, + onStop, + }; + + this.counters = { + used: [], + min: 1, + max: 0, + ready: false, + }; + + this.state = { + ending: false, + ended: false + }; + + this.lag = 0; + this.chain = $q.resolve(); + + this.factors = this.calcFactors(PAGE_SIZE); + this.setFramesPerRender(); + }; + + this.calcFactors = size => { + const factors = [1]; + + for (let i = 2; i <= size / 2; i++) { + if (size % i === 0) { + factors.push(i); + } + } + + factors.push(size); + + return factors; + }; + + this.setFramesPerRender = () => { + const index = Math.floor((this.lag / MAX_LAG) * this.factors.length); + const boundedIndex = Math.min(this.factors.length - 1, index); + + this.framesPerRender = this.factors[boundedIndex]; + }; + + this.setMissingCounterThreshold = counter => { + if (counter > this.counters.min) { + this.counters.min = counter; + } + }; + + this.updateCounterState = ({ counter }) => { + this.counters.used.push(counter); + + if (counter > this.counters.max) { + this.counters.max = counter; + } + + const missing = []; + const ready = []; + + for (let i = this.counters.min; i < this.counters.max; i++) { + if (this.counters.used.indexOf(i) === -1) { + missing.push(i); + } else if (missing.length === 0) { + ready.push(i); + } + } + + if (missing.length === 0) { + this.counters.ready = true; + this.counters.min = this.counters.max + 1; + this.counters.used = []; + } else { + this.counters.ready = false; + } + + this.counters.missing = missing; + this.counters.readyLines = ready; + + return this.counters.ready; + }; + + this.pushJobEvent = data => { + this.lag++; + + this.chain = this.chain + .then(() => { + if (data.event === JOB_END) { + this.state.ending = true; + } + + const isMissingCounters = !this.updateCounterState(data); + const [length, count] = this.hooks.bufferAdd(data); + + if (count % PAGE_SIZE === 0) { + this.setFramesPerRender(); + } + + const isBatchReady = length % this.framesPerRender === 0; + const isReady = this.state.ended || (!isMissingCounters && isBatchReady); + + if (!isReady) { + return $q.resolve(); + } + + const events = this.hooks.bufferEmpty(); + + return this.emitFrames(events); + }) + .then(() => --this.lag); + + return this.chain; + }; + + this.emitFrames = events => this.hooks.onFrames(events) + .then(() => { + if (this.state.ending) { + const lastEvents = this.hooks.bufferEmpty(); + + if (lastEvents.length) { + return this.emitFrames(lastEvents); + } + + this.state.ending = false; + this.state.ended = true; + + this.hooks.onStop(); + } + + return $q.resolve(); + }); +} + +OutputStream.$inject = ['$q']; + +export default OutputStream; From 09aa75e7c4938b524b606bd8f414c3e3e7a818ef Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 7 Jun 2018 22:26:38 -0400 Subject: [PATCH 172/762] simplify and add docstrings --- .../features/output/api.events.service.js | 16 -- .../features/output/index.controller.js | 4 +- awx/ui/client/features/output/index.js | 1 - .../client/features/output/slide.service.js | 180 ++++++++++-------- 4 files changed, 104 insertions(+), 97 deletions(-) diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index 0e9fc6b6a6..acd640f22f 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -19,22 +19,6 @@ function JobEventsApiService ($http, $q) { this.fetch = () => this.getFirst().then(() => this); - this.getPage = number => { - if (number === 1) return this.getFirst(); - - const [low, high] = [1 + PAGE_SIZE * (number - 1), PAGE_SIZE * number]; - const params = merge(this.params, { counter__gte: [low], counter__lte: [high] }); - - return $http.get(this.endpoint, { params }) - .then(({ data }) => { - const { results } = data; - - this.state.current = number; - - return results; - }); - }; - this.getFirst = () => { const page = 1; const params = merge(this.params, { page }); diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 6a56b94bd8..6fc0746571 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -127,7 +127,7 @@ function compile (html) { function follow () { scroll.pause(); - scroll.hide(); + // scroll.hide(); following = true; } @@ -135,7 +135,7 @@ function follow () { function unfollow () { following = false; - scroll.unhide(); + // scroll.unhide(); scroll.resume(); } diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index f124edb342..8d772e037d 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -25,7 +25,6 @@ const PAGE_CACHE = true; const PAGE_LIMIT = 5; const PAGE_SIZE = 50; const ORDER_BY = 'counter'; -// const ORDER_BY = 'start_line'; const WS_PREFIX = 'ws'; function resolveResource ( diff --git a/awx/ui/client/features/output/slide.service.js b/awx/ui/client/features/output/slide.service.js index afb8044280..96b32b2703 100644 --- a/awx/ui/client/features/output/slide.service.js +++ b/awx/ui/client/features/output/slide.service.js @@ -3,10 +3,60 @@ const PAGE_SIZE = 50; const PAGE_LIMIT = 5; const EVENT_LIMIT = PAGE_LIMIT * PAGE_SIZE; -const TAIL_ADDITION = 'TAIL_ADDITION'; -const TAIL_DELETION = 'TAIL_DELETION'; -const HEAD_ADDITION = 'HEAD_ADDITION'; -const HEAD_DELETION = 'HEAD_DELETION'; +/** + * Check if a range overlaps another range + * + * @arg {Array} range - A [low, high] range array. + * @arg {Array} other - A [low, high] range array to be compared with the first. + * + * @returns {Boolean} - Indicating that the ranges overlap. + */ +function checkRangeOverlap (range, other) { + const span = Math.max(range[1], other[1]) - Math.min(range[0], other[0]); + + return (range[1] - range[0]) + (other[1] - other[0]) >= span; +} + +/** + * Get an array that describes the overlap of two ranges. + * + * @arg {Array} range - A [low, high] range array. + * @arg {Array} other - A [low, high] range array to be compared with the first. + * + * @returns {(Array|Boolean)} - Returns false if the ranges aren't overlapping. + * For overlapping ranges, a length-2 array describing the nature of the overlap + * is returned. The overlap array describes the position of the second range in + * terms of how many steps inward (negative) or outward (positive) its sides are + * relative to the first range. + * + * ++45678 + * 234---- => getOverlapArray([4, 8], [2, 4]) = [2, -4] + * + * 45678 + * 45--- => getOverlapArray([4, 8], [4, 5]) = [0, -3] + * + * 45678 + * -56-- => getOverlapArray([4, 8], [5, 6]) = [-1, -2] + * + * 45678 + * --678 => getOverlapArray([4, 8], [6, 8]) = [-2, 0] + * + * 456++ + * --678 => getOverlapArray([4, 6], [6, 8]) = [-2, 2] + * + * +++456++ + * 12345678 => getOverlapArray([4, 6], [1, 8]) = [3, 2] + ^ + * 12345678 + * ---456-- => getOverlapArray([1, 8], [4, 6]) = [-3, -2] + */ +function getOverlapArray (range, other) { + if (!checkRangeOverlap(range, other)) { + return false; + } + + return [range[0] - other[0], other[1] - range[1]]; +} function SlidingWindowService ($q) { this.init = (storage, api) => { @@ -27,20 +77,6 @@ function SlidingWindowService ($q) { pop }; - this.commands = { - [TAIL_ADDITION]: this.pushFront, - [HEAD_ADDITION]: this.pushBack, - [TAIL_DELETION]: this.popFront, - [HEAD_DELETION]: this.popBack - }; - - this.vectors = { - [TAIL_ADDITION]: [0, 1], - [HEAD_ADDITION]: [-1, 0], - [TAIL_DELETION]: [0, -1], - [HEAD_DELETION]: [1, 0], - }; - this.records = {}; this.chain = $q.resolve(); }; @@ -80,11 +116,11 @@ function SlidingWindowService ($q) { } const max = this.getTailCounter(); - const min = Math.max(this.getHeadCounter(), max - count); + const min = max - count; let lines = 0; - for (let i = min; i <= max; ++i) { + for (let i = max; i >= min; --i) { if (this.records[i]) { lines += (this.records[i].end_line - this.records[i].start_line); } @@ -92,7 +128,7 @@ function SlidingWindowService ($q) { return this.storage.pop(lines) .then(() => { - for (let i = min; i <= max; ++i) { + for (let i = max; i >= min; --i) { delete this.records[i]; } @@ -106,11 +142,11 @@ function SlidingWindowService ($q) { } const min = this.getHeadCounter(); - const max = Math.min(this.getTailCounter(), min + count); + const max = min + count; let lines = 0; - for (let i = min; i <= max; ++i) { + for (let i = min; i <= min + count; ++i) { if (this.records[i]) { lines += (this.records[i].end_line - this.records[i].start_line); } @@ -144,66 +180,40 @@ function SlidingWindowService ($q) { return $q.resolve([0, 0]); } - const additions = []; - const deletions = []; + const overlap = getOverlapArray([head, tail], [newHead, newTail]); - for (let counter = tail + 1; counter <= newTail; counter++) { - additions.push([counter, TAIL_ADDITION]); + if (!overlap) { + this.chain = this.chain + .then(() => this.popBack(this.getRecordCount())) + .then(() => this.api.getRange([newHead, newTail])) + .then(events => this.pushFront(events)); } - for (let counter = head - 1; counter >= newHead; counter--) { - additions.push([counter, HEAD_ADDITION]); + if (overlap && overlap[0] > 0) { + const pushBackRange = [head - overlap[0], head]; + + this.chain = this.chain + .then(() => this.api.getRange(pushBackRange)) + .then(events => this.pushBack(events)); } - for (let counter = head; counter < newHead; counter++) { - deletions.push([counter, HEAD_DELETION]); + if (overlap && overlap[1] > 0) { + const pushFrontRange = [tail, tail + overlap[1]]; + + this.chain = this.chain + .then(() => this.api.getRange(pushFrontRange)) + .then(events => this.pushFront(events)); } - for (let counter = tail; counter > newTail; counter--) { - deletions.push([counter, TAIL_DELETION]); + if (overlap && overlap[0] < 0) { + this.chain = this.chain.then(() => this.popBack(Math.abs(overlap[0]))); } - const hasCounter = (items, n) => items - .filter(([counter]) => counter === n).length !== 0; - - const commandRange = { - [TAIL_DELETION]: 0, - [HEAD_DELETION]: 0, - [TAIL_ADDITION]: [tail, tail], - [HEAD_ADDITION]: [head, head], - }; - - deletions.forEach(([counter, key]) => { - if (!hasCounter(additions, counter)) { - commandRange[key] += 1; - } - - commandRange[TAIL_ADDITION][0] += this.vectors[key][0]; - commandRange[TAIL_ADDITION][1] += this.vectors[key][1]; - - commandRange[HEAD_ADDITION][0] += this.vectors[key][0]; - commandRange[HEAD_ADDITION][1] += this.vectors[key][1]; - }); - - additions.forEach(([counter, key]) => { - if (!hasCounter(deletions, counter)) { - if (counter < commandRange[key][0]) { - commandRange[key][0] = counter; - } - - if (counter > commandRange[key][1]) { - commandRange[key][1] = counter; - } - } - }); + if (overlap && overlap[1] < 0) { + this.chain = this.chain.then(() => this.popFront(Math.abs(overlap[1]))); + } this.chain = this.chain - .then(() => this.commands[TAIL_DELETION](commandRange[TAIL_DELETION])) - .then(() => this.commands[HEAD_DELETION](commandRange[HEAD_DELETION])) - .then(() => this.api.getRange(commandRange[TAIL_ADDITION])) - .then(events => this.commands[TAIL_ADDITION](events)) - .then(() => this.api.getRange(commandRange[HEAD_ADDITION])) - .then(events => this.commands[HEAD_ADDITION](events)) .then(() => { const range = this.getRange(); const displacement = [range[0] - head, range[1] - tail]; @@ -219,7 +229,14 @@ function SlidingWindowService ($q) { const tailRoom = this.getMaxCounter() - tail; const tailDisplacement = Math.min(tailRoom, displacement); - const headDisplacement = Math.min(tailRoom, displacement); + + const newTail = tail + tailDisplacement; + + let headDisplacement = 0; + + if (newTail - head > EVENT_LIMIT) { + headDisplacement = (newTail - EVENT_LIMIT) - head; + } return this.move([head + headDisplacement, tail + tailDisplacement]); }; @@ -229,9 +246,16 @@ function SlidingWindowService ($q) { const headRoom = head - 1; const headDisplacement = Math.min(headRoom, displacement); - const tailDisplacement = Math.min(headRoom, displacement); - return this.move([head - headDisplacement, tail - tailDisplacement]); + const newHead = head - headDisplacement; + + let tailDisplacement = 0; + + if (tail - newHead > EVENT_LIMIT) { + tailDisplacement = tail - (newHead + EVENT_LIMIT); + } + + return this.move([newHead, tail - tailDisplacement]); }; this.moveHead = displacement => { @@ -257,7 +281,7 @@ function SlidingWindowService ($q) { if (count > 0) { this.chain = this.chain - .then(() => this.commands[HEAD_DELETION](count)); + .then(() => this.popBack(count)); } return this.chain; @@ -265,12 +289,12 @@ function SlidingWindowService ($q) { this.getFirst = () => this.clear() .then(() => this.api.getFirst()) - .then(events => this.commands[TAIL_ADDITION](events)) + .then(events => this.pushFront(events)) .then(() => this.moveTail(PAGE_SIZE)); this.getLast = () => this.clear() .then(() => this.api.getLast()) - .then(events => this.commands[HEAD_ADDITION](events)) + .then(events => this.pushBack(events)) .then(() => this.moveHead(-PAGE_SIZE)); this.getTailCounter = () => { From 0de369b42f6e5a1d47f64dd442cd9cb1969af884 Mon Sep 17 00:00:00 2001 From: Yunfan Zhang Date: Fri, 8 Jun 2018 09:31:38 -0400 Subject: [PATCH 173/762] Fix job id incorrectly cast to string in ActiveJobConflict. --- awx/api/exceptions.py | 8 ++++++-- awx/main/models/mixins.py | 4 ++-- awx/main/tests/functional/api/test_instance_group.py | 4 ++-- awx/main/tests/functional/api/test_organizations.py | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/awx/api/exceptions.py b/awx/api/exceptions.py index 0c67be279b..7c7a182d06 100644 --- a/awx/api/exceptions.py +++ b/awx/api/exceptions.py @@ -12,7 +12,11 @@ class ActiveJobConflict(ValidationError): status_code = 409 def __init__(self, active_jobs): - super(ActiveJobConflict, self).__init__({ + # During APIException.__init__(), Django Rest Framework + # turn everything in self.detail into string by using force_text. + # Declare detail afterwards circumvent this behavior. + super(ActiveJobConflict, self).__init__() + self.detail = { "error": _("Resource is being used by running jobs."), "active_jobs": active_jobs - }) + } diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index 2314b295ae..7dcb560aa2 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -466,7 +466,7 @@ class RelatedJobsMixin(object): return self._get_related_jobs().filter(status__in=ACTIVE_STATES) ''' - Returns [{'id': '1', 'type': 'job'}, {'id': 2, 'type': 'project_update'}, ...] + Returns [{'id': 1, 'type': 'job'}, {'id': 2, 'type': 'project_update'}, ...] ''' def get_active_jobs(self): UnifiedJob = apps.get_model('main', 'UnifiedJob') @@ -475,5 +475,5 @@ class RelatedJobsMixin(object): if not isinstance(jobs, QuerySet): raise RuntimeError("Programmer error. Expected _get_active_jobs() to return a QuerySet.") - return [dict(id=str(t[0]), type=mapping[t[1]]) for t in jobs.values_list('id', 'polymorphic_ctype_id')] + return [dict(id=t[0], type=mapping[t[1]]) for t in jobs.values_list('id', 'polymorphic_ctype_id')] diff --git a/awx/main/tests/functional/api/test_instance_group.py b/awx/main/tests/functional/api/test_instance_group.py index cd78d0de33..bac1c9a806 100644 --- a/awx/main/tests/functional/api/test_instance_group.py +++ b/awx/main/tests/functional/api/test_instance_group.py @@ -73,12 +73,12 @@ def test_delete_instance_group_jobs(delete, instance_group_jobs_successful, inst @pytest.mark.django_db def test_delete_instance_group_jobs_running(delete, instance_group_jobs_running, instance_group_jobs_successful, instance_group, admin): def sort_keys(x): - return (x['type'], x['id']) + return (x['type'], str(x['id'])) url = reverse("api:instance_group_detail", kwargs={'pk': instance_group.pk}) response = delete(url, None, admin, expect=409) - expect_transformed = [dict(id=str(j.id), type=j.model_to_str()) for j in instance_group_jobs_running] + expect_transformed = [dict(id=j.id, type=j.model_to_str()) for j in instance_group_jobs_running] response_sorted = sorted(response.data['active_jobs'], key=sort_keys) expect_sorted = sorted(expect_transformed, key=sort_keys) diff --git a/awx/main/tests/functional/api/test_organizations.py b/awx/main/tests/functional/api/test_organizations.py index 43a9ffb1e5..5e4793b6a5 100644 --- a/awx/main/tests/functional/api/test_organizations.py +++ b/awx/main/tests/functional/api/test_organizations.py @@ -260,12 +260,12 @@ def test_organization_delete(delete, admin, organization, organization_jobs_succ @pytest.mark.django_db def test_organization_delete_with_active_jobs(delete, admin, organization, organization_jobs_running): def sort_keys(x): - return (x['type'], x['id']) + return (x['type'], str(x['id'])) url = reverse('api:organization_detail', kwargs={'pk': organization.id}) resp = delete(url, None, user=admin, expect=409) - expect_transformed = [dict(id=str(j.id), type=j.model_to_str()) for j in organization_jobs_running] + expect_transformed = [dict(id=j.id, type=j.model_to_str()) for j in organization_jobs_running] resp_sorted = sorted(resp.data['active_jobs'], key=sort_keys) expect_sorted = sorted(expect_transformed, key=sort_keys) From 4bafe02a94cab69acbf5f451cf5ebf1c72bef06c Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Fri, 8 Jun 2018 10:55:07 -0400 Subject: [PATCH 174/762] Hide run selection when current tab is different from selected --- .../workflow-maker.partial.html | 71 ++++++++++--------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html index 9580b72bfc..3f45da0b0c 100644 --- a/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html +++ b/awx/ui/client/src/templates/workflows/workflow-maker/workflow-maker.partial.html @@ -93,42 +93,47 @@
-
-
- - {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }} + +
+
+ + {{:: strings.get('workflows.INVALID_JOB_TEMPLATE') }} +
-
-
-
- - {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} +
+
+ + {{:: strings.get('workflows.CREDENTIAL_WITH_PASS') }} +
-
-
- -
- +
+ +
+ +
-
-
- - - - -
+
+ + + + +
+
From 750f70c21f2a493a94818b6d9cb9d1f61580656a Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Fri, 8 Jun 2018 10:59:27 -0400 Subject: [PATCH 175/762] Add value to $scope.querySet when in .users.add states --- .../rbac-multiselect/rbac-multiselect-list.directive.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js b/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js index 2c3a7b9c8c..175ae7452a 100644 --- a/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js +++ b/awx/ui/client/src/access/rbac-multiselect/rbac-multiselect-list.directive.js @@ -88,6 +88,7 @@ export default ['addPermissionsTeamsList', 'addPermissionsUsersList', 'TemplateL list.fields.name.columnClass = 'col-md-6 col-sm-6 col-xs-11'; break; case 'Users': + list.querySet = { order_by: 'username', page_size: '5' }; list.fields = { username: list.fields.username, first_name: list.fields.first_name, @@ -161,10 +162,11 @@ export default ['addPermissionsTeamsList', 'addPermissionsUsersList', 'TemplateL function optionsRequestDataProcessing(){ if(scope.list.name === 'users'){ if (scope[list.name] !== undefined) { + scope[`${list.iterator}_queryset`] = list.querySet; scope[list.name].forEach(function(item, item_idx) { var itm = scope[list.name][item_idx]; if(itm.summary_fields.user_capabilities.edit){ - // undefined doesn't render the tooltip, + // undefined doesn't render the tooltip, // which is intended here. itm.tooltip = undefined; } From 1cea20092c26e0667d1459fd64779dd2a5620842 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 8 Jun 2018 11:20:54 -0400 Subject: [PATCH 176/762] remove rampart group queue subscription * We now target Instances in the task manager when transitioning jobs from pending to waiting; whereas before we submitted jobs to Instance Groups to be picked up by Instance's in those Instance Groups. Subscribing Instances to their Instance Groups is no longer needed. This change removes the Instance Group queue subscription. --- awx/main/tests/unit/utils/test_ha.py | 23 ++++++++++++----------- awx/main/utils/ha.py | 7 ++----- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py index 94cb7d3606..f73f8e908a 100644 --- a/awx/main/tests/unit/utils/test_ha.py +++ b/awx/main/tests/unit/utils/test_ha.py @@ -18,7 +18,8 @@ from awx.main.utils.ha import ( class TestAddRemoveCeleryWorkerQueues(): @pytest.fixture def instance_generator(self, mocker): - def fn(groups=['east', 'west', 'north', 'south'], hostname='east-1'): + def fn(hostname='east-1'): + groups=['east', 'west', 'north', 'south'] instance = mocker.MagicMock() instance.hostname = hostname instance.rampart_groups = mocker.MagicMock() @@ -40,29 +41,29 @@ class TestAddRemoveCeleryWorkerQueues(): app.control.cancel_consumer = mocker.MagicMock() return app - @pytest.mark.parametrize("broadcast_queues,static_queues,_worker_queues,groups,hostname,added_expected,removed_expected", [ - (['tower_broadcast_all'], ['east', 'west'], ['east', 'west', 'east-1'], [], 'east-1', ['tower_broadcast_all_east-1'], []), - ([], [], ['east', 'west', 'east-1'], ['east', 'west'], 'east-1', [], []), - ([], [], ['east', 'west'], ['east', 'west'], 'east-1', ['east-1'], []), - ([], [], [], ['east', 'west'], 'east-1', ['east', 'west', 'east-1'], []), - ([], [], ['china', 'russia'], ['east', 'west'], 'east-1', ['east', 'west', 'east-1'], ['china', 'russia']), + @pytest.mark.parametrize("broadcast_queues,static_queues,_worker_queues,hostname,added_expected,removed_expected", [ + (['tower_broadcast_all'], ['east', 'west'], ['east', 'west', 'east-1'], 'east-1', ['tower_broadcast_all_east-1'], []), + ([], [], ['east', 'west', 'east-1'], 'east-1', [], ['east', 'west']), + ([], [], ['east', 'west'], 'east-1', ['east-1'], ['east', 'west']), + ([], [], [], 'east-1', ['east-1'], []), + ([], [], ['china', 'russia'], 'east-1', [ 'east-1'], ['china', 'russia']), ]) def test__add_remove_celery_worker_queues_noop(self, mock_app, instance_generator, worker_queues_generator, broadcast_queues, static_queues, _worker_queues, - groups, hostname, + hostname, added_expected, removed_expected): - instance = instance_generator(groups=groups, hostname=hostname) + instance = instance_generator(hostname=hostname) worker_queues = worker_queues_generator(_worker_queues) with nested( mock.patch('awx.main.utils.ha.settings.AWX_CELERY_QUEUES_STATIC', static_queues), mock.patch('awx.main.utils.ha.settings.AWX_CELERY_BCAST_QUEUES_STATIC', broadcast_queues), mock.patch('awx.main.utils.ha.settings.CLUSTER_HOST_ID', hostname)): (added_queues, removed_queues) = _add_remove_celery_worker_queues(mock_app, [instance], worker_queues, hostname) - assert set(added_queues) == set(added_expected) - assert set(removed_queues) == set(removed_expected) + assert set(added_expected) == set(added_queues) + assert set(removed_expected) == set(removed_queues) class TestUpdateCeleryWorkerRouter(): diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py index 49421ad4cb..dd629ca24d 100644 --- a/awx/main/utils/ha.py +++ b/awx/main/utils/ha.py @@ -17,14 +17,11 @@ def construct_bcast_queue_name(common_name): def _add_remove_celery_worker_queues(app, controlled_instances, worker_queues, worker_name): removed_queues = [] added_queues = [] - ig_names = set() hostnames = set([instance.hostname for instance in controlled_instances]) - for instance in controlled_instances: - ig_names.update(instance.rampart_groups.values_list('name', flat=True)) worker_queue_names = set([q['name'] for q in worker_queues]) bcast_queue_names = set([construct_bcast_queue_name(n) for n in settings.AWX_CELERY_BCAST_QUEUES_STATIC]) - all_queue_names = ig_names | hostnames | set(settings.AWX_CELERY_QUEUES_STATIC) + all_queue_names = hostnames | set(settings.AWX_CELERY_QUEUES_STATIC) desired_queues = bcast_queue_names | (all_queue_names if instance.enabled else set()) # Remove queues @@ -33,7 +30,7 @@ def _add_remove_celery_worker_queues(app, controlled_instances, worker_queues, w app.control.cancel_consumer(queue_name.encode("utf8"), reply=True, destination=[worker_name]) removed_queues.append(queue_name.encode("utf8")) - # Add queues for instance and instance groups + # Add queues for instances for queue_name in all_queue_names: if queue_name not in worker_queue_names: app.control.add_consumer(queue_name.encode("utf8"), reply=True, destination=[worker_name]) From fb119671146b779a1f710872cccbae9e8adb93c7 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Fri, 8 Jun 2018 13:46:58 -0400 Subject: [PATCH 177/762] remove isolated instance group queue listening --- awx/main/tasks.py | 12 ++++++------ awx/main/tests/unit/utils/test_ha.py | 2 +- awx/main/utils/ha.py | 14 ++++---------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index be10d3ef58..c0667d15b6 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -209,19 +209,19 @@ def handle_ha_toplogy_changes(self): logger.debug(six.text_type("Reconfigure celeryd queues task on host {}").format(self.request.hostname)) awx_app = Celery('awx') awx_app.config_from_object('django.conf:settings') - instances, removed_queues, added_queues = register_celery_worker_queues(awx_app, self.request.hostname) + removed_queues, added_queues = register_celery_worker_queues(awx_app, self.request.hostname) if len(removed_queues) + len(added_queues) > 0: - logger.info(six.text_type("Workers on tower node(s) '{}' removed from queues {} and added to queues {}") - .format([i.hostname for i in instances], removed_queues, added_queues)) + logger.info(six.text_type("Workers on tower node '{}' removed from queues {} and added to queues {}") + .format(self.request.hostname, removed_queues, added_queues)) @worker_ready.connect def handle_ha_toplogy_worker_ready(sender, **kwargs): logger.debug(six.text_type("Configure celeryd queues task on host {}").format(sender.hostname)) - instances, removed_queues, added_queues = register_celery_worker_queues(sender.app, sender.hostname) + removed_queues, added_queues = register_celery_worker_queues(sender.app, sender.hostname) if len(removed_queues) + len(added_queues) > 0: - logger.info(six.text_type("Workers on tower node(s) '{}' removed from queues {} and added to queues {}") - .format([i.hostname for i in instances], removed_queues, added_queues)) + logger.info(six.text_type("Workers on tower node '{}' removed from queues {} and added to queues {}") + .format(sender.hostname, removed_queues, added_queues)) # Expedite the first hearbeat run so a node comes online quickly. cluster_node_heartbeat.apply([]) diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py index f73f8e908a..35c8005781 100644 --- a/awx/main/tests/unit/utils/test_ha.py +++ b/awx/main/tests/unit/utils/test_ha.py @@ -61,7 +61,7 @@ class TestAddRemoveCeleryWorkerQueues(): mock.patch('awx.main.utils.ha.settings.AWX_CELERY_QUEUES_STATIC', static_queues), mock.patch('awx.main.utils.ha.settings.AWX_CELERY_BCAST_QUEUES_STATIC', broadcast_queues), mock.patch('awx.main.utils.ha.settings.CLUSTER_HOST_ID', hostname)): - (added_queues, removed_queues) = _add_remove_celery_worker_queues(mock_app, [instance], worker_queues, hostname) + (added_queues, removed_queues) = _add_remove_celery_worker_queues(mock_app, instance, worker_queues, hostname) assert set(added_expected) == set(added_queues) assert set(removed_expected) == set(removed_queues) diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py index dd629ca24d..1d9b6e08d3 100644 --- a/awx/main/utils/ha.py +++ b/awx/main/utils/ha.py @@ -14,14 +14,13 @@ def construct_bcast_queue_name(common_name): return common_name.encode('utf8') + '_' + settings.CLUSTER_HOST_ID -def _add_remove_celery_worker_queues(app, controlled_instances, worker_queues, worker_name): +def _add_remove_celery_worker_queues(app, instance, worker_queues, worker_name): removed_queues = [] added_queues = [] - hostnames = set([instance.hostname for instance in controlled_instances]) worker_queue_names = set([q['name'] for q in worker_queues]) bcast_queue_names = set([construct_bcast_queue_name(n) for n in settings.AWX_CELERY_BCAST_QUEUES_STATIC]) - all_queue_names = hostnames | set(settings.AWX_CELERY_QUEUES_STATIC) + all_queue_names = set([instance.hostname]) | set(settings.AWX_CELERY_QUEUES_STATIC) desired_queues = bcast_queue_names | (all_queue_names if instance.enabled else set()) # Remove queues @@ -69,18 +68,13 @@ class AWXCeleryRouter(object): def register_celery_worker_queues(app, celery_worker_name): instance = Instance.objects.me() - controlled_instances = [instance] - if instance.is_controller(): - controlled_instances.extend(Instance.objects.filter( - rampart_groups__controller__instances__hostname=instance.hostname - )) added_queues = [] removed_queues = [] celery_host_queues = app.control.inspect([celery_worker_name]).active_queues() celery_worker_queues = celery_host_queues[celery_worker_name] if celery_host_queues else [] - (added_queues, removed_queues) = _add_remove_celery_worker_queues(app, controlled_instances, + (added_queues, removed_queues) = _add_remove_celery_worker_queues(app, instance, celery_worker_queues, celery_worker_name) - return (controlled_instances, removed_queues, added_queues) + return (removed_queues, added_queues) From 1a3ab7d48737def3dc916031222893e54291f708 Mon Sep 17 00:00:00 2001 From: Yunfan Zhang Date: Fri, 8 Jun 2018 15:42:00 -0400 Subject: [PATCH 178/762] Fix exception format in project_update_stdout. Signed-off-by: Yunfan Zhang --- awx/api/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/awx/api/views.py b/awx/api/views.py index f7a939e839..4de82197e3 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -104,6 +104,8 @@ def api_exception_handler(exc, context): exc = ParseError(exc.args[0]) if isinstance(exc, FieldError): exc = ParseError(exc.args[0]) + if isinstance(context['view'], UnifiedJobStdout): + context['view'].renderer_classes = [BrowsableAPIRenderer, renderers.JSONRenderer] return exception_handler(exc, context) From 92ae09e163ece56d685fdc820eb11dd662926e10 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 7 Jun 2018 17:57:09 -0400 Subject: [PATCH 179/762] add host_status_counts and playbook_counts to project update details --- awx/api/serializers.py | 42 +++++++++++++++++ awx/api/views.py | 2 +- .../api/serializers/test_job_serializers.py | 46 ++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index e79f2e3ad0..c621481712 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1419,6 +1419,48 @@ class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer): return res +class ProjectUpdateDetailSerializer(ProjectUpdateSerializer): + + host_status_counts = serializers.SerializerMethodField( + help_text=_('A count of hosts uniquely assigned to each status.'), + ) + playbook_counts = serializers.SerializerMethodField( + help_text=_('A count of all plays and tasks for the job run.'), + ) + + class Meta: + model = ProjectUpdate + fields = ('*', 'host_status_counts', 'playbook_counts',) + + def get_playbook_counts(self, obj): + task_count = obj.project_update_events.filter(event='playbook_on_task_start').count() + play_count = obj.project_update_events.filter(event='playbook_on_play_start').count() + + data = {'play_count': play_count, 'task_count': task_count} + + return data + + def get_host_status_counts(self, obj): + try: + event_data = obj.project_update_events.only('event_data').get(event='playbook_on_stats').event_data + except ProjectUpdateEvent.DoesNotExist: + event_data = {} + + host_status = {} + host_status_keys = ['skipped', 'ok', 'changed', 'failures', 'dark'] + + for key in host_status_keys: + for host in event_data.get(key, {}): + host_status[host] = key + + host_status_counts = defaultdict(lambda: 0) + + for value in host_status.values(): + host_status_counts[value] += 1 + + return host_status_counts + + class ProjectUpdateListSerializer(ProjectUpdateSerializer, UnifiedJobListSerializer): pass diff --git a/awx/api/views.py b/awx/api/views.py index f7a939e839..e2f913bd14 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1416,7 +1416,7 @@ class ProjectUpdateList(ListAPIView): class ProjectUpdateDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): model = ProjectUpdate - serializer_class = ProjectUpdateSerializer + serializer_class = ProjectUpdateDetailSerializer class ProjectUpdateEventsList(SubListAPIView): diff --git a/awx/main/tests/unit/api/serializers/test_job_serializers.py b/awx/main/tests/unit/api/serializers/test_job_serializers.py index d3fd514ecc..5688a845ec 100644 --- a/awx/main/tests/unit/api/serializers/test_job_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_job_serializers.py @@ -11,12 +11,14 @@ from awx.api.serializers import ( JobDetailSerializer, JobSerializer, JobOptionsSerializer, + ProjectUpdateDetailSerializer, ) from awx.main.models import ( Label, Job, JobEvent, + ProjectUpdateEvent, ) @@ -142,10 +144,52 @@ class TestJobDetailSerializerGetHostStatusCountFields(object): assert host_status_counts == {'ok': 1, 'changed': 1, 'dark': 2} - def test_host_status_counts_is_empty_dict_without_stats_event(self, job, mocker): + def test_host_status_counts_is_empty_dict_without_stats_event(self, job): job.job_events = JobEvent.objects.none() serializer = JobDetailSerializer() host_status_counts = serializer.get_host_status_counts(job) assert host_status_counts == {} + + +class TestProjectUpdateDetailSerializerGetHostStatusCountFields(object): + + def test_hosts_are_counted_once(self, project_update, mocker): + mock_event = ProjectUpdateEvent(**{ + 'event': 'playbook_on_stats', + 'event_data': { + 'skipped': { + 'localhost': 2, + 'fiz': 1, + }, + 'ok': { + 'localhost': 1, + 'foo': 2, + }, + 'changed': { + 'localhost': 1, + 'bar': 3, + }, + 'dark': { + 'localhost': 2, + 'fiz': 2, + } + } + }) + + mock_qs = namedtuple('mock_qs', ['get'])(mocker.MagicMock(return_value=mock_event)) + project_update.project_update_events.only = mocker.MagicMock(return_value=mock_qs) + + serializer = ProjectUpdateDetailSerializer() + host_status_counts = serializer.get_host_status_counts(project_update) + + assert host_status_counts == {'ok': 1, 'changed': 1, 'dark': 2} + + def test_host_status_counts_is_empty_dict_without_stats_event(self, project_update): + project_update.project_update_events = ProjectUpdateEvent.objects.none() + + serializer = ProjectUpdateDetailSerializer() + host_status_counts = serializer.get_host_status_counts(project_update) + + assert host_status_counts == {} From 4c93c68a2975be32518f36e530d1d26a5368daff Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 7 Jun 2018 17:57:53 -0400 Subject: [PATCH 180/762] remove single-event api queries and extra labels request --- .../features/output/api.events.service.js | 2 +- .../features/output/details.component.js | 2 +- awx/ui/client/features/output/index.js | 19 ++----- .../client/features/output/stats.component.js | 12 +++-- .../client/features/output/status.service.js | 51 ++++++++++++++++--- awx/ui/client/lib/models/AdHocCommand.js | 5 -- awx/ui/client/lib/models/InventoryUpdate.js | 6 --- awx/ui/client/lib/models/Job.js | 26 ---------- awx/ui/client/lib/models/ProjectUpdate.js | 32 +----------- awx/ui/client/lib/models/SystemJob.js | 6 --- 10 files changed, 61 insertions(+), 100 deletions(-) diff --git a/awx/ui/client/features/output/api.events.service.js b/awx/ui/client/features/output/api.events.service.js index acd640f22f..a7a16c75c8 100644 --- a/awx/ui/client/features/output/api.events.service.js +++ b/awx/ui/client/features/output/api.events.service.js @@ -17,7 +17,7 @@ function JobEventsApiService ($http, $q) { this.state = { current: 0, count: 0 }; }; - this.fetch = () => this.getFirst().then(() => this); + this.fetch = () => this.getLast().then(() => this); this.getFirst = () => { const page = 1; diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 33f143ecd5..310a8d0f31 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -516,7 +516,7 @@ function getExtraVarsDetails () { } function getLabelDetails () { - const jobLabels = _.get(resource.model.get('related.labels'), 'results', []); + const jobLabels = _.get(resource.model.get('summary_fields.labels'), 'results', []); if (jobLabels.length < 1) { return null; diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 8d772e037d..76ac46e63e 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -89,27 +89,18 @@ function resolveResource ( Object.assign(config.params, query); } - let model; - Wait('start'); const resourcePromise = new Resource(['get', 'options'], [id, id]) - .then(job => { - const endpoint = `${job.get('url')}${related}/`; + .then(model => { + const endpoint = `${model.get('url')}${related}/`; eventsApi.init(endpoint, config.params); - const promises = [job.getStats(), eventsApi.fetch()]; - - if (job.has('related.labels')) { - promises.push(job.extend('get', 'labels')); - } - - model = job; - return Promise.all(promises); + return eventsApi.fetch() + .then(events => ([model, events])); }) - .then(([stats, events]) => ({ + .then(([model, events]) => ({ id, type, - stats, model, events, related, diff --git a/awx/ui/client/features/output/stats.component.js b/awx/ui/client/features/output/stats.component.js index 7ce5e31af1..31285cfbf1 100644 --- a/awx/ui/client/features/output/stats.component.js +++ b/awx/ui/client/features/output/stats.component.js @@ -26,13 +26,13 @@ function JobStatsController (strings, { subscribe }) { strings.get('tooltips.COLLAPSE_OUTPUT') : strings.get('tooltips.EXPAND_OUTPUT'); - unsubscribe = subscribe(({ running, elapsed, counts, stats, hosts }) => { + unsubscribe = subscribe(({ running, elapsed, counts, hosts }) => { vm.plays = counts.plays; vm.tasks = counts.tasks; vm.hosts = counts.hosts; vm.elapsed = elapsed; vm.running = running; - vm.setHostStatusCounts(stats, hosts); + vm.setHostStatusCounts(hosts); }); }; @@ -40,7 +40,9 @@ function JobStatsController (strings, { subscribe }) { unsubscribe(); }; - vm.setHostStatusCounts = (stats, counts) => { + vm.setHostStatusCounts = counts => { + let statsAreAvailable; + Object.keys(counts).forEach(key => { const count = counts[key]; const statusBarElement = $(`.HostStatusBar-${key}`); @@ -48,9 +50,11 @@ function JobStatsController (strings, { subscribe }) { statusBarElement.css('flex', `${count} 0 auto`); vm.tooltips[key] = createStatsBarTooltip(key, count); + + if (count) statsAreAvailable = true; }); - vm.statsAreAvailable = stats; + vm.statsAreAvailable = statsAreAvailable; }; vm.toggleExpanded = () => { diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js index dfb7d40e04..d387f8db41 100644 --- a/awx/ui/client/features/output/status.service.js +++ b/awx/ui/client/features/output/status.service.js @@ -1,3 +1,4 @@ +/* eslint camelcase: 0 */ const JOB_START = 'playbook_on_start'; const JOB_END = 'playbook_on_stats'; const PLAY_START = 'playbook_on_play_start'; @@ -12,7 +13,7 @@ function JobStatusService (moment, message) { this.dispatch = () => message.dispatch('status', this.state); this.subscribe = listener => message.subscribe('status', listener); - this.init = ({ model, stats }) => { + this.init = ({ model }) => { this.created = model.get('created'); this.job = model.get('id'); this.jobType = model.get('type'); @@ -24,7 +25,6 @@ function JobStatusService (moment, message) { this.state = { running: false, - stats: false, counts: { plays: 0, tasks: 0, @@ -41,13 +41,37 @@ function JobStatusService (moment, message) { }, }; - this.setStatsEvent(stats); - this.updateStats(); - this.updateRunningState(); + if (model.get('type') === 'job' || model.get('type') === 'project_update') { + if (model.has('playbook_counts')) { + this.setPlaybookCounts(model.get('playbook_counts')); + } + if (model.has('host_status_counts')) { + this.setHostStatusCounts(model.get('host_status_counts')); + } + } else { + const hostStatusCounts = this.createHostStatusCounts(this.state.status); + + this.setHostStatusCounts(hostStatusCounts); + this.setPlaybookCounts({ task_count: 1, play_count: 1 }); + } + + this.updateRunningState(); this.dispatch(); }; + this.createHostStatusCounts = status => { + if (_.includes(COMPLETE, status)) { + return { ok: 1 }; + } + + if (_.includes(INCOMPLETE, status)) { + return { failures: 1 }; + } + + return null; + }; + this.pushStatusEvent = data => { const isJobStatusEvent = (this.job === data.unified_job_id); const isProjectStatusEvent = (this.project && (this.project === data.project_id)); @@ -112,7 +136,6 @@ function JobStatusService (moment, message) { this.updateHostCounts(); if (this.statsEvent) { - this.state.stats = true; this.setFinished(this.statsEvent.created); this.setJobStatus(this.statsEvent.failed ? 'failed' : 'successful'); } @@ -199,9 +222,25 @@ function JobStatusService (moment, message) { }; this.setHostStatusCounts = counts => { + counts = counts || {}; + + HOST_STATUS_KEYS.forEach(key => { + counts[key] = counts[key] || 0; + }); + + if (!this.state.counts.hosts) { + this.state.counts.hosts = Object.keys(counts) + .reduce((sum, key) => sum + counts[key], 0); + } + this.state.hosts = counts; }; + this.setPlaybookCounts = ({ play_count, task_count }) => { + this.state.counts.plays = play_count; + this.state.counts.tasks = task_count; + }; + this.resetCounts = () => { this.state.counts.plays = 0; this.state.counts.tasks = 0; diff --git a/awx/ui/client/lib/models/AdHocCommand.js b/awx/ui/client/lib/models/AdHocCommand.js index 7bea2677ac..9f259a929a 100644 --- a/awx/ui/client/lib/models/AdHocCommand.js +++ b/awx/ui/client/lib/models/AdHocCommand.js @@ -19,17 +19,12 @@ function postRelaunch (params) { return $http(req); } -function getStats () { - return Promise.resolve(null); -} - function AdHocCommandModel (method, resource, config) { BaseModel.call(this, 'ad_hoc_commands'); this.Constructor = AdHocCommandModel; this.postRelaunch = postRelaunch.bind(this); this.getRelaunch = getRelaunch.bind(this); - this.getStats = getStats.bind(this); return this.create(method, resource, config); } diff --git a/awx/ui/client/lib/models/InventoryUpdate.js b/awx/ui/client/lib/models/InventoryUpdate.js index 668a05459d..37951911d7 100644 --- a/awx/ui/client/lib/models/InventoryUpdate.js +++ b/awx/ui/client/lib/models/InventoryUpdate.js @@ -1,14 +1,8 @@ let BaseModel; -function getStats () { - return Promise.resolve(null); -} - function InventoryUpdateModel (method, resource, config) { BaseModel.call(this, 'inventory_updates'); - this.getStats = getStats.bind(this); - this.Constructor = InventoryUpdateModel; return this.create(method, resource, config); diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js index 1e466b0e6d..7e2f017826 100644 --- a/awx/ui/client/lib/models/Job.js +++ b/awx/ui/client/lib/models/Job.js @@ -23,31 +23,6 @@ function postRelaunch (params) { return $http(req); } -function getStats () { - if (!this.has('GET', 'id')) { - return Promise.reject(new Error('No property, id, exists')); - } - - if (!this.has('GET', 'related.job_events')) { - return Promise.reject(new Error('No related property, job_events, exists')); - } - - const req = { - method: 'GET', - url: `${this.path}${this.get('id')}/job_events/`, - params: { event: 'playbook_on_stats' }, - }; - - return $http(req) - .then(({ data }) => { - if (data.results.length > 0) { - return data.results[0]; - } - - return null; - }); -} - function getCredentials (id) { const req = { method: 'GET', @@ -64,7 +39,6 @@ function JobModel (method, resource, config) { this.postRelaunch = postRelaunch.bind(this); this.getRelaunch = getRelaunch.bind(this); - this.getStats = getStats.bind(this); this.getCredentials = getCredentials.bind(this); return this.create(method, resource, config); diff --git a/awx/ui/client/lib/models/ProjectUpdate.js b/awx/ui/client/lib/models/ProjectUpdate.js index df038283cf..a8b1ae1fe9 100644 --- a/awx/ui/client/lib/models/ProjectUpdate.js +++ b/awx/ui/client/lib/models/ProjectUpdate.js @@ -1,50 +1,20 @@ -let $http; let BaseModel; -function getStats () { - if (!this.has('GET', 'id')) { - return Promise.reject(new Error('No property, id, exists')); - } - - if (!this.has('GET', 'related.events')) { - return Promise.reject(new Error('No related property, events, exists')); - } - - const req = { - method: 'GET', - url: `${this.path}${this.get('id')}/events/`, - params: { event: 'playbook_on_stats' }, - }; - - return $http(req) - .then(({ data }) => { - if (data.results.length > 0) { - return data.results[0]; - } - - return null; - }); -} - function ProjectUpdateModel (method, resource, config) { BaseModel.call(this, 'project_updates'); - this.getStats = getStats.bind(this); - this.Constructor = ProjectUpdateModel; return this.create(method, resource, config); } -function ProjectUpdateModelLoader (_$http_, _BaseModel_) { - $http = _$http_; +function ProjectUpdateModelLoader (_BaseModel_) { BaseModel = _BaseModel_; return ProjectUpdateModel; } ProjectUpdateModelLoader.$inject = [ - '$http', 'BaseModel' ]; diff --git a/awx/ui/client/lib/models/SystemJob.js b/awx/ui/client/lib/models/SystemJob.js index 1f1f1c5ee3..cc092ff8f4 100644 --- a/awx/ui/client/lib/models/SystemJob.js +++ b/awx/ui/client/lib/models/SystemJob.js @@ -1,14 +1,8 @@ let BaseModel; -function getStats () { - return Promise.resolve(null); -} - function SystemJobModel (method, resource, config) { BaseModel.call(this, 'system_jobs'); - this.getStats = getStats.bind(this); - this.Constructor = SystemJobModel; return this.create(method, resource, config); From 5e9da2d7720a928b4324ced956ac183ead0193ab Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 8 Jun 2018 22:23:54 -0400 Subject: [PATCH 181/762] request job details and initial events dataset concurrently --- awx/ui/client/features/output/index.js | 32 +++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 76ac46e63e..9f0d05df3c 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -26,19 +26,20 @@ const PAGE_LIMIT = 5; const PAGE_SIZE = 50; const ORDER_BY = 'counter'; const WS_PREFIX = 'ws'; +const API_ROOT = '/api/v2/'; function resolveResource ( $state, + $stateParams, Job, ProjectUpdate, AdHocCommand, SystemJob, WorkflowJob, InventoryUpdate, - $stateParams, qs, Wait, - eventsApi, + Events, ) { const { id, type, handleErrors } = $stateParams; const { job_event_search } = $stateParams; // eslint-disable-line camelcase @@ -46,24 +47,28 @@ function resolveResource ( const { name, key } = getWebSocketResource(type); let Resource; - let related = 'events'; + let related; switch (type) { case 'project': Resource = ProjectUpdate; + related = `project_updates/${id}/events/`; break; case 'playbook': Resource = Job; - related = 'job_events'; + related = `jobs/${id}/job_events/`; break; case 'command': Resource = AdHocCommand; + related = `ad_hoc_commands/${id}/events/`; break; case 'system': Resource = SystemJob; + related = `system_jobs/${id}/events/`; break; case 'inventory': Resource = InventoryUpdate; + related = `inventory_updates/${id}/events/`; break; // case 'workflow': // todo: integrate workflow chart components into this view @@ -89,21 +94,15 @@ function resolveResource ( Object.assign(config.params, query); } - Wait('start'); - const resourcePromise = new Resource(['get', 'options'], [id, id]) - .then(model => { - const endpoint = `${model.get('url')}${related}/`; - eventsApi.init(endpoint, config.params); + Events.init(`${API_ROOT}${related}`, config.params); - return eventsApi.fetch() - .then(events => ([model, events])); - }) + Wait('start'); + const promise = Promise.all([new Resource(['get', 'options'], [id, id]), Events.fetch()]) .then(([model, events]) => ({ id, type, model, events, - related, ws: { events: `${WS_PREFIX}-${key}-${id}`, status: `${WS_PREFIX}-${name}`, @@ -116,13 +115,14 @@ function resolveResource ( })); if (!handleErrors) { - return resourcePromise + return promise .finally(() => Wait('stop')); } - return resourcePromise + return promise .catch(({ data, status }) => { qs.error(data, status); + return $state.go($state.current, $state.params, { reload: true }); }) .finally(() => Wait('stop')); @@ -209,13 +209,13 @@ function JobsRun ($stateRegistry, $filter, strings) { ], resource: [ '$state', + '$stateParams', 'JobModel', 'ProjectUpdateModel', 'AdHocCommandModel', 'SystemJobModel', 'WorkflowJobModel', 'InventoryUpdateModel', - '$stateParams', 'QuerySet', 'Wait', 'JobEventsApiService', From 6a59356200dd144d874206e389003dc1cf4b077a Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 7 Jun 2018 18:51:09 -0400 Subject: [PATCH 182/762] apply a windowing function to buffered event stream --- .../features/output/index.controller.js | 18 ++++++-- .../client/features/output/slide.service.js | 9 ++-- .../client/features/output/stream.service.js | 43 +++++++++---------- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 6fc0746571..64d53b4faa 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -32,13 +32,23 @@ function bufferAdd (event) { bufferState[0] += 1; bufferState[1] += 1; - return bufferState; + return bufferState[1]; } -function bufferEmpty () { - bufferState[0] = 0; +function bufferEmpty (min, max) { + let count = 0; + let removed = []; - return rx.splice(0, rx.length); + for (let i = bufferState[0] - 1; i >= 0; i--) { + if (rx[i].counter <= max) { + removed = removed.concat(rx.splice(i, 1)); + count++; + } + } + + bufferState[0] -= count; + + return removed; } function onFrames (events) { diff --git a/awx/ui/client/features/output/slide.service.js b/awx/ui/client/features/output/slide.service.js index 96b32b2703..507a277733 100644 --- a/awx/ui/client/features/output/slide.service.js +++ b/awx/ui/client/features/output/slide.service.js @@ -309,10 +309,13 @@ function SlidingWindowService ($q) { return Number.isFinite(head) ? head : 0; }; - this.compareRange = (a, b) => a[0] === b[0] && a[1] === b[1]; - this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; + this.getMaxCounter = () => { + const counter = this.api.getMaxCounter(); - this.getMaxCounter = () => this.api.getMaxCounter(); + return Number.isFinite(counter) ? counter : this.getTailCounter(); + }; + + this.getRange = () => [this.getHeadCounter(), this.getTailCounter()]; this.getRecordCount = () => Object.keys(this.records).length; this.getCapacity = () => EVENT_LIMIT - this.getRecordCount(); } diff --git a/awx/ui/client/features/output/stream.service.js b/awx/ui/client/features/output/stream.service.js index da73f70c23..c710a1fc1a 100644 --- a/awx/ui/client/features/output/stream.service.js +++ b/awx/ui/client/features/output/stream.service.js @@ -16,6 +16,7 @@ function OutputStream ($q) { used: [], min: 1, max: 0, + last: null, ready: false, }; @@ -66,28 +67,30 @@ function OutputStream ($q) { } const missing = []; - const ready = []; + let minReady; + let maxReady; - for (let i = this.counters.min; i < this.counters.max; i++) { + for (let i = this.counters.min; i <= this.counters.max; i++) { if (this.counters.used.indexOf(i) === -1) { missing.push(i); } else if (missing.length === 0) { - ready.push(i); + maxReady = i; } } - if (missing.length === 0) { + if (maxReady) { + minReady = this.counters.min; + this.counters.ready = true; - this.counters.min = this.counters.max + 1; - this.counters.used = []; + this.counters.min = maxReady + 1; + this.counters.used = this.counters.used.filter(c => c > maxReady); } else { this.counters.ready = false; } this.counters.missing = missing; - this.counters.readyLines = ready; - return this.counters.ready; + return [minReady, maxReady]; }; this.pushJobEvent = data => { @@ -97,40 +100,36 @@ function OutputStream ($q) { .then(() => { if (data.event === JOB_END) { this.state.ending = true; + this.counters.last = data.counter; } - const isMissingCounters = !this.updateCounterState(data); - const [length, count] = this.hooks.bufferAdd(data); + const [minReady, maxReady] = this.updateCounterState(data); + const count = this.hooks.bufferAdd(data); if (count % PAGE_SIZE === 0) { this.setFramesPerRender(); } - const isBatchReady = length % this.framesPerRender === 0; - const isReady = this.state.ended || (!isMissingCounters && isBatchReady); + const isReady = maxReady && (this.state.ending || + (maxReady - minReady) % this.framesPerRender === 0); if (!isReady) { return $q.resolve(); } - const events = this.hooks.bufferEmpty(); + const isLastFrame = this.state.ending && (maxReady >= this.counters.last); + const events = this.hooks.bufferEmpty(minReady, maxReady); - return this.emitFrames(events); + return this.emitFrames(events, isLastFrame); }) .then(() => --this.lag); return this.chain; }; - this.emitFrames = events => this.hooks.onFrames(events) + this.emitFrames = (events, last) => this.hooks.onFrames(events) .then(() => { - if (this.state.ending) { - const lastEvents = this.hooks.bufferEmpty(); - - if (lastEvents.length) { - return this.emitFrames(lastEvents); - } - + if (last) { this.state.ending = false; this.state.ended = true; From 709cf7013896949ca7d425a1c0916abcc20bb6cb Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Mon, 11 Jun 2018 09:50:12 -0400 Subject: [PATCH 183/762] use dot notation for state params --- awx/ui/client/src/instance-groups/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/instance-groups/main.js b/awx/ui/client/src/instance-groups/main.js index 37e9240f23..78b238fefa 100644 --- a/awx/ui/client/src/instance-groups/main.js +++ b/awx/ui/client/src/instance-groups/main.js @@ -254,7 +254,7 @@ function InstanceGroupsRun ($stateExtender, strings, ComponentsStrings) { resolvedModels: InstanceGroupsResolve, Dataset: ['GetBasePath', 'QuerySet', '$stateParams', function(GetBasePath, qs, $stateParams) { - let path = `${GetBasePath('instance_groups')}${$stateParams['instance_group_id']}/instances`; + let path = `${GetBasePath('instance_groups')}${$stateParams.instance_group_id}/instances`; return qs.search(path, $stateParams[`instance_search`]); } ] From 6794afb28489f3ef537c57ab375d5bc0204747c9 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 11 Jun 2018 11:56:33 -0400 Subject: [PATCH 184/762] Maintain reference to querySet rather than cloning so that changes made after initialization will propagate --- .../client/src/shared/smart-search/smart-search.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js index 2ac0550a2b..3a53ddf0aa 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.controller.js +++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js @@ -40,7 +40,7 @@ function SmartSearchController ( } if ($scope.querySet) { - queryset = _.cloneDeep($scope.querySet); + queryset = $scope.querySet; } else { queryset = $state.params[searchKey]; } From 88e3c468103cb7549820543f310d9309c6debaf7 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 7 Jun 2018 16:58:30 -0400 Subject: [PATCH 185/762] add a background process to spot celery hangs and reload the worker pool see: https://github.com/ansible/tower/issues/2085 --- awx/main/management/commands/watch_celery.py | 61 +++++++++++++++++++ .../image_build/files/supervisor_task.conf | 14 ++++- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 awx/main/management/commands/watch_celery.py diff --git a/awx/main/management/commands/watch_celery.py b/awx/main/management/commands/watch_celery.py new file mode 100644 index 0000000000..64e03e52aa --- /dev/null +++ b/awx/main/management/commands/watch_celery.py @@ -0,0 +1,61 @@ +import os +import signal +import subprocess +import sys +import socket +import time + +from celery import Celery +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + """Watch local celery workers""" + help=("Sends a periodic ping to the local celery process over AMQP to ensure " + "it's responsive; this command is only intended to run in an environment " + "where celeryd is running") + + # + # Just because celery is _running_ doesn't mean it's _working_; it's + # imperative that celery workers are _actually_ handling AMQP messages on + # their appropriate queues for awx to function. Unfortunately, we've been + # plagued by a variety of bugs in celery that cause it to hang and become + # an unresponsive zombie, such as: + # + # https://github.com/celery/celery/issues/4185 + # https://github.com/celery/celery/issues/4457 + # + # The goal of this code is periodically send a broadcast AMQP message to + # the celery process on the local host via celery.app.control.ping; + # If that _fails_, we attempt to determine the pid of the celery process + # and send SIGHUP (which tends to resolve these sorts of issues for us). + # + + INTERVAL = 60 + + def handle(self, **options): + app = Celery('awx') + app.config_from_object('django.conf:settings') + while True: + try: + pongs = app.control.ping(['celery@{}'.format(socket.gethostname())]) + except: + pongs = [] + if len(pongs): + sys.stderr.write(str(pongs) + '\n') + else: + sys.stderr.write('celery is not responsive to ping over local AMQP\n') + pid = self.getpid() + if pid: + sys.stderr.write('sending SIGHUP to {}\n'.format(pid)) + os.kill(pid, signal.SIGHUP) + time.sleep(self.INTERVAL) + + def getpid(self): + cmd = 'supervisorctl pid tower-processes:awx-celeryd' + if os.path.exists('/supervisor_task.conf'): + cmd = 'supervisorctl -c /supervisor_task.conf pid tower-processes:celery' + try: + return int(subprocess.check_output(cmd, shell=True)) + except Exception: + sys.stderr.write('could not detect celery pid\n') diff --git a/installer/roles/image_build/files/supervisor_task.conf b/installer/roles/image_build/files/supervisor_task.conf index 31f4cbfb1f..c90c6336f0 100644 --- a/installer/roles/image_build/files/supervisor_task.conf +++ b/installer/roles/image_build/files/supervisor_task.conf @@ -15,6 +15,18 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 +[program:celery-watcher] +command = /usr/bin/awx-manage celery_watcher +directory = /var/lib/awx +environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" +autostart = true +autorestart = true +stopwaitsecs = 5 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + [program:callback-receiver] command = awx-manage run_callback_receiver directory = /var/lib/awx @@ -38,7 +50,7 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [group:tower-processes] -programs=celery,callback-receiver,channels-worker +programs=celery,celery-watcher,callback-receiver,channels-worker priority=5 # TODO: Exit Handler From 1bc1a6f63ff00461dff4a0050edad747801d91cc Mon Sep 17 00:00:00 2001 From: Yunfan Zhang Date: Fri, 8 Jun 2018 17:29:20 -0400 Subject: [PATCH 186/762] Disallow HTTP update methods in job_detail API endpoint. --- awx/api/views.py | 39 ++++++++++++++++++----- awx/main/tests/functional/api/test_job.py | 35 +++++++++++++++++--- docs/CHANGELOG.md | 2 ++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 4de82197e3..a635263140 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1613,12 +1613,12 @@ class OAuth2UserTokenList(SubListCreateAPIView): relationship = 'main_oauth2accesstoken' parent_key = 'user' swagger_topic = 'Authentication' - - + + class UserAuthorizedTokenList(SubListCreateAPIView): view_name = _("OAuth2 User Authorized Access Tokens") - + model = OAuth2AccessToken serializer_class = UserAuthorizedTokenSerializer parent_model = User @@ -1628,12 +1628,12 @@ class UserAuthorizedTokenList(SubListCreateAPIView): def get_queryset(self): return get_access_token_model().objects.filter(application__isnull=False, user=self.request.user) - + class OrganizationApplicationList(SubListCreateAPIView): view_name = _("Organization OAuth2 Applications") - + model = OAuth2Application serializer_class = OAuth2ApplicationSerializer parent_model = Organization @@ -1643,16 +1643,16 @@ class OrganizationApplicationList(SubListCreateAPIView): class UserPersonalTokenList(SubListCreateAPIView): - + view_name = _("OAuth2 Personal Access Tokens") - + model = OAuth2AccessToken serializer_class = UserPersonalTokenSerializer parent_model = User relationship = 'main_oauth2accesstoken' parent_key = 'user' swagger_topic = 'Authentication' - + def get_queryset(self): return get_access_token_model().objects.filter(application__isnull=True, user=self.request.user) @@ -4084,6 +4084,29 @@ class JobDetail(UnifiedJobDeletionMixin, RetrieveUpdateDestroyAPIView): metadata_class = JobTypeMetadata serializer_class = JobDetailSerializer + # NOTE: When removing the V1 API in 3.4, delete the following four methods, + # and let this class inherit from RetrieveDestroyAPIView instead of + # RetrieveUpdateDestroyAPIView. + @property + def allowed_methods(self): + methods = super(JobDetail, self).allowed_methods + if get_request_version(getattr(self, 'request', None)) > 1: + methods.remove('PUT') + methods.remove('PATCH') + return methods + + def put(self, request, *args, **kwargs): + if get_request_version(self.request) > 1: + return Response({"error": _("PUT not allowed for Job Details in version 2 of the API")}, + status=status.HTTP_405_METHOD_NOT_ALLOWED) + return super(JobDetail, self).put(request, *args, **kwargs) + + def patch(self, request, *args, **kwargs): + if get_request_version(self.request) > 1: + return Response({"error": _("PUT not allowed for Job Details in version 2 of the API")}, + status=status.HTTP_405_METHOD_NOT_ALLOWED) + return super(JobDetail, self).patch(request, *args, **kwargs) + def update(self, request, *args, **kwargs): obj = self.get_object() # Only allow changes (PUT/PATCH) when job status is "new". diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index fce3c6fb5a..8cda6a6cfe 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -1,14 +1,16 @@ +# Python import pytest import mock - from dateutil.parser import parse from dateutil.relativedelta import relativedelta +from crum import impersonate +# Django rest framework from rest_framework.exceptions import PermissionDenied +# AWX from awx.api.versioning import reverse from awx.api.views import RelatedJobsPreventDeleteMixin, UnifiedJobDeletionMixin - from awx.main.models import ( JobTemplate, User, @@ -17,8 +19,6 @@ from awx.main.models import ( ProjectUpdate, ) -from crum import impersonate - @pytest.mark.django_db def test_extra_credentials(get, organization_factory, job_template_factory, credential): @@ -167,6 +167,33 @@ def test_block_related_unprocessed_events(mocker, organization, project, delete, view.perform_destroy(organization) +@pytest.mark.django_db +def test_disallowed_http_update_methods(put, patch, post, inventory, project, admin_user): + jt = JobTemplate.objects.create( + name='test_disallowed_methods', inventory=inventory, + project=project + ) + job = jt.create_unified_job() + post( + url=reverse('api:job_detail', kwargs={'pk': job.pk, 'version': 'v2'}), + data={}, + user=admin_user, + expect=405 + ) + put( + url=reverse('api:job_detail', kwargs={'pk': job.pk, 'version': 'v2'}), + data={}, + user=admin_user, + expect=405 + ) + patch( + url=reverse('api:job_detail', kwargs={'pk': job.pk, 'version': 'v2'}), + data={}, + user=admin_user, + expect=405 + ) + + class TestControllerNode(): @pytest.fixture def project_update(self, project): diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d6b75726bc..06f28b477f 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -67,6 +67,8 @@ [[#1873](https://github.com/ansible/awx/issues/1873)]. * Switched authentication to Django sessions. * Implemented OAuth2 support for token based authentication [[#21](https://github.com/ansible/awx/issues/21)]. +* Added the ability to forcibly expire sessions through `awx-manage expire_sessions`. +* Disallowed using HTTP PUT/PATCH methods to modify existing jobs in Job Details API endpoint. 3.2.0 ===== From 62c57848338d150e1165fc9643ac07c36d1f2626 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 11 Jun 2018 15:21:47 -0400 Subject: [PATCH 187/762] fix failing flake8 --- awx/main/management/commands/watch_celery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/management/commands/watch_celery.py b/awx/main/management/commands/watch_celery.py index 64e03e52aa..c991164045 100644 --- a/awx/main/management/commands/watch_celery.py +++ b/awx/main/management/commands/watch_celery.py @@ -39,7 +39,7 @@ class Command(BaseCommand): while True: try: pongs = app.control.ping(['celery@{}'.format(socket.gethostname())]) - except: + except Exception: pongs = [] if len(pongs): sys.stderr.write(str(pongs) + '\n') From d0d7bf5c21139542c0c4af96aae018277cbc5514 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 11 Jun 2018 15:01:46 -0400 Subject: [PATCH 188/762] more gracefully handle fact cache failures for hosts that contain / see: https://github.com/ansible/awx/issues/1977 related: https://github.com/ansible/ansible/issues/41413 --- awx/main/models/jobs.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 7d195b78ac..fdb350d9bb 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -774,9 +774,13 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana if not os.path.realpath(filepath).startswith(destination): system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) continue - with codecs.open(filepath, 'w', encoding='utf-8') as f: - os.chmod(f.name, 0o600) - json.dump(host.ansible_facts, f) + try: + with codecs.open(filepath, 'w', encoding='utf-8') as f: + os.chmod(f.name, 0o600) + json.dump(host.ansible_facts, f) + except IOError: + system_tracking_logger.error('facts for host {} could not be cached'.format(smart_str(host.name))) + continue # make note of the time we wrote the file so we can check if it changed later modification_times[filepath] = os.path.getmtime(filepath) From 987063e7fd677dcb98945035a08b9d947d7a5128 Mon Sep 17 00:00:00 2001 From: chris meyers Date: Mon, 11 Jun 2018 14:07:07 -0400 Subject: [PATCH 189/762] iso nodes do not have celery. that is ok --- awx/__init__.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/awx/__init__.py b/awx/__init__.py index f107340385..2241a165a0 100644 --- a/awx/__init__.py +++ b/awx/__init__.py @@ -7,11 +7,18 @@ import sys import warnings from pkg_resources import get_distribution -from .celery import app as celery_app # noqa __version__ = get_distribution('awx').version +__all__ = ['__version__'] + + +# Isolated nodes do not have celery installed +try: + from .celery import app as celery_app # noqa + __all__.append('celery_app') +except ImportError: + pass -__all__ = ['__version__', 'celery_app'] # Check for the presence/absence of "devonly" module to determine if running # from a source code checkout or release packaage. From 49f0a63150e3996c685a7c4edc97dcbf26139071 Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Mon, 11 Jun 2018 11:48:37 -0400 Subject: [PATCH 190/762] fix error when deleting the only item on page 2 for list page of organization, scheduler, instance_groups, inventories, projects, jobs, template, credentials, inventory-scripts, teams, users, credential-types and notifications --- awx/ui/client/features/jobs/jobsList.controller.js | 7 +++---- .../features/templates/templatesList.controller.js | 4 ++-- .../src/credential-types/list/list.controller.js | 2 +- .../credentials/list/credentials-list.controller.js | 2 +- .../list/instance-groups-list.controller.js | 11 +++++++++-- .../inventories/list/inventory-list.controller.js | 2 +- .../related/groups/list/groups-list.controller.js | 2 +- .../related/hosts/list/host-list.controller.js | 2 +- .../src/inventory-scripts/list/list.controller.js | 2 +- .../notification-templates-list/list.controller.js | 2 +- .../list/organizations-list.controller.js | 2 +- .../src/projects/list/projects-list.controller.js | 2 +- .../scheduler/factories/delete-schedule.factory.js | 2 +- awx/ui/client/src/teams/list/teams-list.controller.js | 2 +- awx/ui/client/src/users/list/users-list.controller.js | 2 +- 15 files changed, 26 insertions(+), 20 deletions(-) diff --git a/awx/ui/client/features/jobs/jobsList.controller.js b/awx/ui/client/features/jobs/jobsList.controller.js index 77be97e72d..2780e63ce1 100644 --- a/awx/ui/client/features/jobs/jobsList.controller.js +++ b/awx/ui/client/features/jobs/jobsList.controller.js @@ -102,12 +102,11 @@ function ListJobsController ( let reloadListStateParams = null; if ($scope.jobs.length === 1 && $state.params.job_search && - !_.isEmpty($state.params.job_search.page) && + _.has($state, 'params.job_search.page') && $state.params.job_search.page !== '1') { - const page = `${(parseInt(reloadListStateParams - .job_search.page, 10) - 1)}`; reloadListStateParams = _.cloneDeep($state.params); - reloadListStateParams.job_search.page = page; + reloadListStateParams.job_search.page = + (parseInt(reloadListStateParams.job_search.page, 10) - 1).toString(); } $state.go('.', reloadListStateParams, { reload: true }); diff --git a/awx/ui/client/features/templates/templatesList.controller.js b/awx/ui/client/features/templates/templatesList.controller.js index b8a29f4db8..ede54116c4 100644 --- a/awx/ui/client/features/templates/templatesList.controller.js +++ b/awx/ui/client/features/templates/templatesList.controller.js @@ -164,7 +164,7 @@ function ListTemplatesController( return html; }; - + vm.buildCredentialTags = (credentials) => { return credentials.map(credential => { const icon = `${credential.kind}`; @@ -265,7 +265,7 @@ function ListTemplatesController( const { page } = _.get($state.params, 'template_search'); let reloadListStateParams = null; - if ($scope.templates.length === 1 && !_.isEmpty(page) && page !== '1') { + if ($scope.templates.length === 1 && page && page !== '1') { reloadListStateParams = _.cloneDeep($state.params); const pageNumber = (parseInt(reloadListStateParams.template_search.page, 0) - 1); reloadListStateParams.template_search.page = pageNumber.toString(); diff --git a/awx/ui/client/src/credential-types/list/list.controller.js b/awx/ui/client/src/credential-types/list/list.controller.js index 6e10b5cb34..d4ecde7d6f 100644 --- a/awx/ui/client/src/credential-types/list/list.controller.js +++ b/awx/ui/client/src/credential-types/list/list.controller.js @@ -68,7 +68,7 @@ export default ['$rootScope', '$scope', 'Wait', 'CredentialTypesList', let reloadListStateParams = null; - if($scope.credential_types.length === 1 && $state.params.credential_type_search && !_.isEmpty($state.params.credential_type_search.page) && $state.params.credential_type_search.page !== '1') { + if($scope.credential_types.length === 1 && $state.params.credential_type_search && _.has($state, 'params.credential_type_search.page') && $state.params.credential_type_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.credential_type_search.page = (parseInt(reloadListStateParams.credential_type_search.page)-1).toString(); } diff --git a/awx/ui/client/src/credentials/list/credentials-list.controller.js b/awx/ui/client/src/credentials/list/credentials-list.controller.js index 2e8c6e87e8..1f088dbcdc 100644 --- a/awx/ui/client/src/credentials/list/credentials-list.controller.js +++ b/awx/ui/client/src/credentials/list/credentials-list.controller.js @@ -122,7 +122,7 @@ export default ['$scope', 'Rest', 'CredentialList', 'Prompt', 'ProcessErrors', ' let reloadListStateParams = null; - if($scope.credentials.length === 1 && $state.params.credential_search && !_.isEmpty($state.params.credential_search.page) && $state.params.credential_search.page !== '1') { + if($scope.credentials.length === 1 && $state.params.credential_search && _.has($state, 'params.credential_search.page') && $state.params.credential_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.credential_search.page = (parseInt(reloadListStateParams.credential_search.page)-1).toString(); } diff --git a/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js b/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js index 8568919616..ac569e4b8d 100644 --- a/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js +++ b/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js @@ -66,10 +66,17 @@ export default ['$scope', '$filter', '$state', 'Alert', 'resolvedModels', 'Datas }; function handleSuccessfulDelete(instance_group) { + let reloadListStateParams = null; + + if($scope.instance_groups.length === 1 && $state.params.instance_group_search && _.has($state, 'params.instance_group_search.page') && $state.params.instance_group_search.page !== '1') { + reloadListStateParams = _.cloneDeep($state.params); + reloadListStateParams.instance_group_search.page = (parseInt(reloadListStateParams.instance_group_search.page)-1).toString(); + } + if (parseInt($state.params.instance_group_id, 0) === instance_group.id) { - $state.go('instanceGroups', $state.params, { reload: true }); + $state.go('instanceGroups', reloadListStateParams, { reload: true }); } else { - $state.go('.', $state.params, { reload: true }); + $state.go('.', reloadListStateParams, { reload: true }); } } diff --git a/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js b/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js index c656016b9b..f3f0736740 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/list/inventory-list.controller.js @@ -153,7 +153,7 @@ function InventoriesList($scope, if (data.status === 'deleted') { let reloadListStateParams = null; - if($scope.inventories.length === 1 && $state.params.inventory_search && !_.isEmpty($state.params.inventory_search.page) && $state.params.inventory_search.page !== '1') { + if($scope.inventories.length === 1 && $state.params.inventory_search && _.has($state, 'params.inventory_search.page') && $state.params.inventory_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.inventory_search.page = (parseInt(reloadListStateParams.inventory_search.page)-1).toString(); } diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/groups/list/groups-list.controller.js b/awx/ui/client/src/inventories-hosts/inventories/related/groups/list/groups-list.controller.js index 120d09567b..daf0ab3e81 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/groups/list/groups-list.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/groups/list/groups-list.controller.js @@ -127,7 +127,7 @@ $scope.confirmDelete = function(){ let reloadListStateParams = null; - if($scope.groups.length === 1 && $state.params.group_search && !_.isEmpty($state.params.group_search.page) && $state.params.group_search.page !== '1') { + if($scope.groups.length === 1 && $state.params.group_search && _.has($state, 'params.group_search.page') && $state.params.group_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.group_search.page = (parseInt(reloadListStateParams.group_search.page)-1).toString(); } diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/hosts/list/host-list.controller.js b/awx/ui/client/src/inventories-hosts/inventories/related/hosts/list/host-list.controller.js index d1f32f366a..e0a49fc059 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/hosts/list/host-list.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/hosts/list/host-list.controller.js @@ -112,7 +112,7 @@ export default ['$scope', 'ListDefinition', '$rootScope', 'GetBasePath', let reloadListStateParams = null; - if($scope.hosts.length === 1 && $state.params.host_search && !_.isEmpty($state.params.host_search.page) && $state.params.host_search.page !== '1') { + if($scope.hosts.length === 1 && $state.params.host_search && _.has($state, 'params.host_search.page') && $state.params.host_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.host_search.page = (parseInt(reloadListStateParams.host_search.page)-1).toString(); } diff --git a/awx/ui/client/src/inventory-scripts/list/list.controller.js b/awx/ui/client/src/inventory-scripts/list/list.controller.js index b4307b54c8..96c15c31d8 100644 --- a/awx/ui/client/src/inventory-scripts/list/list.controller.js +++ b/awx/ui/client/src/inventory-scripts/list/list.controller.js @@ -73,7 +73,7 @@ export default ['$rootScope', '$scope', 'Wait', 'InventoryScriptsList', let reloadListStateParams = null; - if($scope.inventory_scripts.length === 1 && $state.params.inventory_script_search && !_.isEmpty($state.params.inventory_script_search.page) && $state.params.inventory_script_search.page !== '1') { + if($scope.inventory_scripts.length === 1 && $state.params.inventory_script_search && _.has($state, 'params.inventory_script_search.page') && $state.params.inventory_script_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.inventory_script_search.page = (parseInt(reloadListStateParams.inventory_script_search.page)-1).toString(); } diff --git a/awx/ui/client/src/notifications/notification-templates-list/list.controller.js b/awx/ui/client/src/notifications/notification-templates-list/list.controller.js index 8e588d9671..f471a2aac6 100644 --- a/awx/ui/client/src/notifications/notification-templates-list/list.controller.js +++ b/awx/ui/client/src/notifications/notification-templates-list/list.controller.js @@ -193,7 +193,7 @@ let reloadListStateParams = null; - if($scope.notification_templates.length === 1 && $state.params.notification_template_search && !_.isEmpty($state.params.notification_template_search.page) && $state.params.notification_template_search.page !== '1') { + if($scope.notification_templates.length === 1 && $state.params.notification_template_search && _.has($state, 'params.notification_template_search.page') && $state.params.notification_template_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.notification_template_search.page = (parseInt(reloadListStateParams.notification_template_search.page)-1).toString(); } diff --git a/awx/ui/client/src/organizations/list/organizations-list.controller.js b/awx/ui/client/src/organizations/list/organizations-list.controller.js index 48a61b0397..6d86ab884b 100644 --- a/awx/ui/client/src/organizations/list/organizations-list.controller.js +++ b/awx/ui/client/src/organizations/list/organizations-list.controller.js @@ -149,7 +149,7 @@ export default ['$stateParams', '$scope', '$rootScope', let reloadListStateParams = null; - if($scope.organizations.length === 1 && $state.params.organization_search && !_.isEmpty($state.params.organization_search.page) && $state.params.organization_search.page !== '1') { + if($scope.organizations.length === 1 && $state.params.organization_search && _.has($state, 'params.organization_search.page') && parseInt($state.params.organization_search.page).toString() !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.organization_search.page = (parseInt(reloadListStateParams.organization_search.page)-1).toString(); } diff --git a/awx/ui/client/src/projects/list/projects-list.controller.js b/awx/ui/client/src/projects/list/projects-list.controller.js index c4aa5db6c8..6208a7c553 100644 --- a/awx/ui/client/src/projects/list/projects-list.controller.js +++ b/awx/ui/client/src/projects/list/projects-list.controller.js @@ -198,7 +198,7 @@ export default ['$scope', '$rootScope', '$log', 'Rest', 'Alert', let reloadListStateParams = null; - if($scope.projects.length === 1 && $state.params.project_search && !_.isEmpty($state.params.project_search.page) && $state.params.project_search.page !== '1') { + if($scope.projects.length === 1 && $state.params.project_search && _.has($state, 'params.project_search.page') && $state.params.project_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.project_search.page = (parseInt(reloadListStateParams.project_search.page)-1).toString(); } diff --git a/awx/ui/client/src/scheduler/factories/delete-schedule.factory.js b/awx/ui/client/src/scheduler/factories/delete-schedule.factory.js index 2321028055..d4526cab8b 100644 --- a/awx/ui/client/src/scheduler/factories/delete-schedule.factory.js +++ b/awx/ui/client/src/scheduler/factories/delete-schedule.factory.js @@ -28,7 +28,7 @@ export default let reloadListStateParams = null; - if(scope.schedules.length === 1 && $state.params.schedule_search && !_.isEmpty($state.params.schedule_search.page) && $state.params.schedule_search.page !== '1') { + if(scope.schedules.length === 1 && $state.params.schedule_search && _.has($state, 'params.schedule_search.page') && $state.params.schedule_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.schedule_search.page = (parseInt(reloadListStateParams.schedule_search.page)-1).toString(); } diff --git a/awx/ui/client/src/teams/list/teams-list.controller.js b/awx/ui/client/src/teams/list/teams-list.controller.js index 024f74361b..88df298f55 100644 --- a/awx/ui/client/src/teams/list/teams-list.controller.js +++ b/awx/ui/client/src/teams/list/teams-list.controller.js @@ -53,7 +53,7 @@ export default ['$scope', 'Rest', 'TeamList', 'Prompt', let reloadListStateParams = null; - if($scope.teams.length === 1 && $state.params.team_search && !_.isEmpty($state.params.team_search.page) && $state.params.team_search.page !== '1') { + if($scope.teams.length === 1 && $state.params.team_search && _.has($state, 'params.team_search.page') && $state.params.team_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.team_search.page = (parseInt(reloadListStateParams.team_search.page)-1).toString(); } diff --git a/awx/ui/client/src/users/list/users-list.controller.js b/awx/ui/client/src/users/list/users-list.controller.js index b684a5bbdb..96d01ebf04 100644 --- a/awx/ui/client/src/users/list/users-list.controller.js +++ b/awx/ui/client/src/users/list/users-list.controller.js @@ -68,7 +68,7 @@ export default ['$scope', '$rootScope', 'Rest', 'UserList', 'Prompt', let reloadListStateParams = null; - if($scope.users.length === 1 && $state.params.user_search && !_.isEmpty($state.params.user_search.page) && $state.params.user_search.page !== '1') { + if($scope.users.length === 1 && $state.params.user_search && _.has($state, 'params.user_search.page') && $state.params.user_search.page !== '1') { reloadListStateParams = _.cloneDeep($state.params); reloadListStateParams.user_search.page = (parseInt(reloadListStateParams.user_search.page)-1).toString(); } From 069c5dacaa3fcf1ed458bf210cfe90fb853e50a3 Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Tue, 5 Jun 2018 10:29:09 -0400 Subject: [PATCH 191/762] add completed job section on host page host completed jobs added on both host page and related section under inventory edit --- .../jobs/routes/hostCompletedJobs.route.js | 58 +++++++++++++++++++ .../src/inventories-hosts/hosts/host.form.js | 5 ++ .../src/inventories-hosts/hosts/main.js | 7 ++- .../src/inventories-hosts/inventories/main.js | 7 ++- .../related/hosts/related-host.form.js | 5 ++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 awx/ui/client/features/jobs/routes/hostCompletedJobs.route.js diff --git a/awx/ui/client/features/jobs/routes/hostCompletedJobs.route.js b/awx/ui/client/features/jobs/routes/hostCompletedJobs.route.js new file mode 100644 index 0000000000..04d98996ea --- /dev/null +++ b/awx/ui/client/features/jobs/routes/hostCompletedJobs.route.js @@ -0,0 +1,58 @@ +import { N_ } from '../../../src/i18n'; +import jobsListController from '../jobsList.controller'; + +const jobsListTemplate = require('~features/jobs/jobsList.view.html'); + +export default { + url: '/completed_jobs', + params: { + job_search: { + value: { + page_size: '20', + job__hosts: '', + order_by: '-id' + }, + dynamic: true, + squash: '' + } + }, + ncyBreadcrumb: { + label: N_('COMPLETED JOBS') + }, + views: { + related: { + templateUrl: jobsListTemplate, + controller: jobsListController, + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: [ + 'UnifiedJobModel', + (UnifiedJob) => { + const models = [ + new UnifiedJob(['options']), + ]; + return Promise.all(models); + }, + ], + Dataset: [ + '$stateParams', + 'Wait', + 'GetBasePath', + 'QuerySet', + ($stateParams, Wait, GetBasePath, qs) => { + const hostId = $stateParams.host_id; + + const searchParam = _.assign($stateParams + .job_search, { job__hosts: hostId }); + + const searchPath = GetBasePath('unified_jobs'); + + Wait('start'); + return qs.search(searchPath, searchParam) + .finally(() => Wait('stop')); + } + ] + } +}; diff --git a/awx/ui/client/src/inventories-hosts/hosts/host.form.js b/awx/ui/client/src/inventories-hosts/hosts/host.form.js index 52c6f0d8c1..f66f48b4df 100644 --- a/awx/ui/client/src/inventories-hosts/hosts/host.form.js +++ b/awx/ui/client/src/inventories-hosts/hosts/host.form.js @@ -123,6 +123,11 @@ function(i18n) { title: i18n._('Insights'), skipGenerator: true, ngIf: "host.insights_system_id!==null && host.summary_fields.inventory.hasOwnProperty('insights_credential_id')" + }, + completed_jobs: { + name: 'completed_jobs', + title: i18n._('Completed Jobs'), + skipGenerator: true } } }; diff --git a/awx/ui/client/src/inventories-hosts/hosts/main.js b/awx/ui/client/src/inventories-hosts/hosts/main.js index c2675c2fda..575a2a8e49 100644 --- a/awx/ui/client/src/inventories-hosts/hosts/main.js +++ b/awx/ui/client/src/inventories-hosts/hosts/main.js @@ -14,6 +14,7 @@ import insightsRoute from '../inventories/insights/insights.route'; import hostGroupsRoute from './related/groups/hosts-related-groups.route'; import hostGroupsAssociateRoute from './related/groups/hosts-related-groups-associate.route'; + import hostCompletedJobsRoute from '~features/jobs/routes/hostCompletedJobs.route.js'; import hostGroups from './related/groups/main'; export default @@ -87,6 +88,9 @@ angular.module('host', [ let hostInsights = _.cloneDeep(insightsRoute); hostInsights.name = 'hosts.edit.insights'; + let hostCompletedJobs = _.cloneDeep(hostCompletedJobsRoute); + hostCompletedJobs.name = 'hosts.edit.completed_jobs'; + return Promise.all([ hostTree ]).then((generated) => { @@ -97,7 +101,8 @@ angular.module('host', [ stateExtender.buildDefinition(hostAnsibleFacts), stateExtender.buildDefinition(hostInsights), stateExtender.buildDefinition(hostGroupsRoute), - stateExtender.buildDefinition(hostGroupsAssociateRoute) + stateExtender.buildDefinition(hostGroupsAssociateRoute), + stateExtender.buildDefinition(hostCompletedJobs) ]) }; }); diff --git a/awx/ui/client/src/inventories-hosts/inventories/main.js b/awx/ui/client/src/inventories-hosts/inventories/main.js index 66ff152d23..88bb35c819 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/main.js +++ b/awx/ui/client/src/inventories-hosts/inventories/main.js @@ -45,6 +45,7 @@ import hostNestedGroupsAssociateRoute from './related/hosts/related/nested-group import groupNestedGroupsAssociateRoute from './related/groups/related/nested-groups/group-nested-groups-associate.route'; import nestedHostsAssociateRoute from './related/groups/related/nested-hosts/group-nested-hosts-associate.route'; import nestedHostsAddRoute from './related/groups/related/nested-hosts/group-nested-hosts-add.route'; +import hostCompletedJobsRoute from '~features/jobs/routes/hostCompletedJobs.route.js'; export default angular.module('inventory', [ @@ -292,6 +293,9 @@ angular.module('inventory', [ let smartInventoryAdhocCredential = _.cloneDeep(adhocCredentialRoute); smartInventoryAdhocCredential.name = 'inventories.editSmartInventory.adhoc.credential'; + let relatedHostCompletedJobs = _.cloneDeep(hostCompletedJobsRoute); + relatedHostCompletedJobs.name = 'inventories.edit.hosts.edit.completed_jobs'; + return Promise.all([ standardInventoryAdd, standardInventoryEdit, @@ -339,7 +343,8 @@ angular.module('inventory', [ stateExtender.buildDefinition(hostNestedGroupsAssociateRoute), stateExtender.buildDefinition(nestedHostsAssociateRoute), stateExtender.buildDefinition(nestedGroupsAdd), - stateExtender.buildDefinition(nestedHostsAddRoute) + stateExtender.buildDefinition(nestedHostsAddRoute), + stateExtender.buildDefinition(relatedHostCompletedJobs) ]) }; }); diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/hosts/related-host.form.js b/awx/ui/client/src/inventories-hosts/inventories/related/hosts/related-host.form.js index 2ee6de78a2..3b10504b9e 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/hosts/related-host.form.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/hosts/related-host.form.js @@ -122,6 +122,11 @@ function(i18n) { title: i18n._('Insights'), skipGenerator: true, ngIf: "host.insights_system_id!==null && host.summary_fields.inventory.hasOwnProperty('insights_credential_id')" + }, + completed_jobs: { + name: 'completed_jobs', + title: i18n._('Completed Jobs'), + skipGenerator: true } } }; From a3368a8c963e5c9e09b6c2c7362cb0243370b8cc Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 11 Jun 2018 16:07:50 -0400 Subject: [PATCH 192/762] Fixed clear-all on inventory source credential lookup --- awx/ui/client/src/inventories-hosts/inventories/main.js | 2 -- .../sources/lookup/sources-lookup-credential.route.js | 7 +++++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/awx/ui/client/src/inventories-hosts/inventories/main.js b/awx/ui/client/src/inventories-hosts/inventories/main.js index 66ff152d23..7e2f7f03cf 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/main.js +++ b/awx/ui/client/src/inventories-hosts/inventories/main.js @@ -252,7 +252,6 @@ angular.module('inventory', [ let addSourceCredential = _.cloneDeep(inventorySourcesCredentialRoute); addSourceCredential.name = 'inventories.edit.inventory_sources.add.credential'; - addSourceCredential.url = '/credential'; let addSourceInventoryScript = _.cloneDeep(inventorySourcesInventoryScriptRoute); addSourceInventoryScript.name = 'inventories.edit.inventory_sources.add.inventory_script'; @@ -260,7 +259,6 @@ angular.module('inventory', [ let editSourceCredential = _.cloneDeep(inventorySourcesCredentialRoute); editSourceCredential.name = 'inventories.edit.inventory_sources.edit.credential'; - editSourceCredential.url = '/credential'; let addSourceProject = _.cloneDeep(inventorySourcesProjectRoute); addSourceProject.name = 'inventories.edit.inventory_sources.add.project'; diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/lookup/sources-lookup-credential.route.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/lookup/sources-lookup-credential.route.js index b80e65c1c9..b997e07b81 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/lookup/sources-lookup-credential.route.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/lookup/sources-lookup-credential.route.js @@ -36,8 +36,11 @@ export default { ListDefinition: ['CredentialList', function(list) { return list; }], - Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', - (list, qs, $stateParams, GetBasePath) => { + Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', '$transition$', + (list, qs, $stateParams, GetBasePath, $transition$) => { + let toState = $transition$.to(); + toState.params.credential_search.value.kind = _.get($stateParams, 'credential_search.kind', null); + toState.params.credential_search.value.credential_type__kind = _.get($stateParams, 'credential_search.credential_type__kind', null); return qs.search(GetBasePath('credentials'), $stateParams[`${list.iterator}_search`]); } ] From 9d8db1d75b25c1140ca9f407b70f0280d3fac325 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 11 Jun 2018 17:01:11 -0400 Subject: [PATCH 193/762] Changed let to const --- .../related/sources/lookup/sources-lookup-credential.route.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/lookup/sources-lookup-credential.route.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/lookup/sources-lookup-credential.route.js index b997e07b81..90879be5f2 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/lookup/sources-lookup-credential.route.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/lookup/sources-lookup-credential.route.js @@ -38,7 +38,7 @@ export default { }], Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', '$transition$', (list, qs, $stateParams, GetBasePath, $transition$) => { - let toState = $transition$.to(); + const toState = $transition$.to(); toState.params.credential_search.value.kind = _.get($stateParams, 'credential_search.kind', null); toState.params.credential_search.value.credential_type__kind = _.get($stateParams, 'credential_search.credential_type__kind', null); return qs.search(GetBasePath('credentials'), $stateParams[`${list.iterator}_search`]); From 9ef301f25aed8e6a3bb58cdfed4b9c5762a5c200 Mon Sep 17 00:00:00 2001 From: kialam Date: Mon, 11 Jun 2018 17:43:13 -0400 Subject: [PATCH 194/762] Broadcast `updateDataset` for job template controller So it knows to call the `refreshJobs ()` method and update the model accordingly. --- awx/ui/client/src/shared/paginate/paginate.controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/awx/ui/client/src/shared/paginate/paginate.controller.js b/awx/ui/client/src/shared/paginate/paginate.controller.js index 5c4a9f1090..28be44eeae 100644 --- a/awx/ui/client/src/shared/paginate/paginate.controller.js +++ b/awx/ui/client/src/shared/paginate/paginate.controller.js @@ -56,6 +56,7 @@ export default ['$scope', '$stateParams', '$state', '$filter', 'GetBasePath', 'Q } $scope.dataset = res.data; $scope.collection = res.data.results; + $scope.$emit('updateDataset', res.data); }); }; From 60a38a196ac41686385368c0ef497c71326d867e Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 8 Jun 2018 22:49:00 -0400 Subject: [PATCH 195/762] add final_counter to EOF websocket --- awx/main/management/commands/run_callback_receiver.py | 4 ++-- awx/main/utils/common.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index 0ffa218348..14e44496bb 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -162,7 +162,7 @@ class CallbackBrokerWorker(ConsumerMixin): if body.get('event') == 'EOF': try: - final_line_count = body.get('final_line_count', 0) + final_counter = body.get('final_counter', 0) logger.info('Event processing is finished for Job {}, sending notifications'.format(job_identifier)) # EOF events are sent when stdout for the running task is # closed. don't actually persist them to the database; we @@ -170,7 +170,7 @@ class CallbackBrokerWorker(ConsumerMixin): # approximation for when a job is "done" emit_channel_notification( 'jobs-summary', - dict(group_name='jobs', unified_job_id=job_identifier, final_line_count=final_line_count) + dict(group_name='jobs', unified_job_id=job_identifier, final_counter=final_counter) ) # Additionally, when we've processed all events, we should # have all the data we need to send out success/failure diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 59499acb2e..ac1f30d25e 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -989,7 +989,7 @@ class OutputEventFilter(object): if value: self._emit_event(value) self._buffer = StringIO() - self._event_callback(dict(event='EOF', final_line_count=self._start_line)) + self._event_callback(dict(event='EOF', final_counter=self._counter - 1)) def _emit_event(self, buffered_stdout, next_event_data=None): next_event_data = next_event_data or {} From 992bc1a5ec863cca580fcfed7396aaeea47c3f75 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Fri, 8 Jun 2018 23:30:12 -0400 Subject: [PATCH 196/762] use final_counter from EOF websocket in UI --- .../features/output/index.controller.js | 12 +++++++ awx/ui/client/features/output/index.js | 1 + .../client/features/output/stream.service.js | 36 ++++++++++++++----- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 64d53b4faa..7ffb8e2f7e 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -163,6 +163,11 @@ function startListening () { listeners.push($scope.$on(resource.ws.events, (scope, data) => handleJobEvent(data))); listeners.push($scope.$on(resource.ws.status, (scope, data) => handleStatusEvent(data))); + + if (resource.model.get('type') === 'job') return; + if (resource.model.get('type') === 'project_update') return; + + listeners.push($scope.$on(resource.ws.summary, (scope, data) => handleSummaryEvent(data))); } function handleStatusEvent (data) { @@ -174,6 +179,13 @@ function handleJobEvent (data) { status.pushJobEvent(data); } +function handleSummaryEvent (data) { + if (resource.model.get('id') !== data.unified_job_id) return; + if (!data.final_counter) return; + + stream.setFinalCounter(data.final_counter); +} + function OutputIndexController ( _$compile_, _$q_, diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js index 9f0d05df3c..6d990772ca 100644 --- a/awx/ui/client/features/output/index.js +++ b/awx/ui/client/features/output/index.js @@ -106,6 +106,7 @@ function resolveResource ( ws: { events: `${WS_PREFIX}-${key}-${id}`, status: `${WS_PREFIX}-${name}`, + summary: `${WS_PREFIX}-${name}-summary`, }, page: { cache: PAGE_CACHE, diff --git a/awx/ui/client/features/output/stream.service.js b/awx/ui/client/features/output/stream.service.js index c710a1fc1a..953c886882 100644 --- a/awx/ui/client/features/output/stream.service.js +++ b/awx/ui/client/features/output/stream.service.js @@ -14,10 +14,10 @@ function OutputStream ($q) { this.counters = { used: [], + ready: [], min: 1, max: 0, - last: null, - ready: false, + final: null, }; this.state = { @@ -81,16 +81,14 @@ function OutputStream ($q) { if (maxReady) { minReady = this.counters.min; - this.counters.ready = true; this.counters.min = maxReady + 1; this.counters.used = this.counters.used.filter(c => c > maxReady); - } else { - this.counters.ready = false; } this.counters.missing = missing; + this.counters.ready = [minReady, maxReady]; - return [minReady, maxReady]; + return this.counters.ready; }; this.pushJobEvent = data => { @@ -100,7 +98,7 @@ function OutputStream ($q) { .then(() => { if (data.event === JOB_END) { this.state.ending = true; - this.counters.last = data.counter; + this.counters.final = data.counter; } const [minReady, maxReady] = this.updateCounterState(data); @@ -117,7 +115,7 @@ function OutputStream ($q) { return $q.resolve(); } - const isLastFrame = this.state.ending && (maxReady >= this.counters.last); + const isLastFrame = this.state.ending && (maxReady >= this.counters.final); const events = this.hooks.bufferEmpty(minReady, maxReady); return this.emitFrames(events, isLastFrame); @@ -127,6 +125,27 @@ function OutputStream ($q) { return this.chain; }; + this.setFinalCounter = counter => { + this.chain = this.chain + .then(() => { + this.state.ending = true; + this.counters.final = counter; + + if (counter >= this.counters.min) { + return $q.resolve(); + } + + let events = []; + if (this.counters.ready.length > 0) { + events = this.hooks.bufferEmpty(...this.counters.ready); + } + + return this.emitFrames(events, true); + }); + + return this.chain; + }; + this.emitFrames = (events, last) => this.hooks.onFrames(events) .then(() => { if (last) { @@ -136,6 +155,7 @@ function OutputStream ($q) { this.hooks.onStop(); } + this.counters.ready.length = 0; return $q.resolve(); }); } From eb39fcfeaf2b5549b1eaeb582ed18be891cbecc6 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 11 Jun 2018 10:51:09 -0400 Subject: [PATCH 197/762] add basic output expand-collapse --- .../features/output/index.controller.js | 90 +++++++++++++++---- awx/ui/client/features/output/index.view.html | 12 +-- .../client/features/output/render.service.js | 38 ++++---- .../client/features/output/slide.service.js | 18 +++- 4 files changed, 113 insertions(+), 45 deletions(-) diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js index 64d53b4faa..4af5247e5b 100644 --- a/awx/ui/client/features/output/index.controller.js +++ b/awx/ui/client/features/output/index.controller.js @@ -67,14 +67,6 @@ function onFrames (events) { const capacity = slide.getCapacity(); - if (capacity >= events.length) { - return slide.pushFront(events); - } - - delete render.record; - - render.record = {}; - return slide.popBack(events.length - capacity) .then(() => slide.pushFront(events)) .then(() => { @@ -131,10 +123,6 @@ function last () { }); } -function compile (html) { - return $compile(html)($scope); -} - function follow () { scroll.pause(); // scroll.hide(); @@ -149,6 +137,69 @@ function unfollow () { scroll.resume(); } +function togglePanelExpand () { + vm.isPanelExpanded = !vm.isPanelExpanded; +} + +function toggleMenuExpand () { + if (scroll.isPaused()) return; + + const recordList = Object.keys(render.record).map(key => render.record[key]); + const minLevel = Math.min(...recordList.map(({ level }) => level)); + + const toggled = recordList + .filter(({ level }) => level === minLevel) + .map(({ uuid }) => getToggleElements(uuid)) + .filter(({ icon }) => icon.length > 0) + .map(({ icon, lines }) => setExpanded(icon, lines, !vm.isMenuExpanded)); + + if (toggled.length > 0) { + vm.isMenuExpanded = !vm.isMenuExpanded; + } +} + +function toggleLineExpand (uuid) { + if (scroll.isPaused()) return; + + const { icon, lines } = getToggleElements(uuid); + const isExpanded = icon.hasClass('fa-angle-down'); + + setExpanded(icon, lines, !isExpanded); + + vm.isMenuExpanded = !isExpanded; +} + +function getToggleElements (uuid) { + const record = render.record[uuid]; + const lines = $(`.child-of-${uuid}`); + + const iconSelector = '.at-Stdout-toggle > i'; + const additionalSelector = `#${(record.children || []).join(', #')}`; + + let icon = $(`#${uuid} ${iconSelector}`); + if (additionalSelector) { + icon = icon.add($(additionalSelector).find(iconSelector)); + } + + return { icon, lines }; +} + +function setExpanded (icon, lines, expanded) { + if (expanded) { + icon.removeClass('fa-angle-right'); + icon.addClass('fa-angle-down'); + lines.removeClass('hidden'); + } else { + icon.removeClass('fa-angle-down'); + icon.addClass('fa-angle-right'); + lines.addClass('hidden'); + } +} + +function compile (html) { + return $compile(html)($scope); +} + function showHostDetails (id, uuid) { $state.go('output.host-event.json', { eventId: id, taskUuid: uuid }); } @@ -203,13 +254,11 @@ function OutputIndexController ( vm = this || {}; // Panel + vm.title = $filter('sanitize')(resource.model.get('name')); vm.strings = strings; vm.resource = resource; - vm.title = $filter('sanitize')(resource.model.get('name')); - - vm.expanded = false; - vm.showHostDetails = showHostDetails; - vm.toggleExpanded = () => { vm.expanded = !vm.expanded; }; + vm.isPanelExpanded = false; + vm.togglePanelExpand = togglePanelExpand; // Stdout Navigation vm.menu = { @@ -218,6 +267,11 @@ function OutputIndexController ( up: previous, down: next, }; + vm.isMenuExpanded = true; + vm.toggleMenuExpand = toggleMenuExpand; + vm.toggleLineExpand = toggleLineExpand; + vm.showHostDetails = showHostDetails; + vm.toggleLineEnabled = resource.model.get('type') === 'job'; render.requestAnimationFrame(() => { bufferInit(); @@ -225,7 +279,7 @@ function OutputIndexController ( status.init(resource); slide.init(render, resource.events); - render.init({ compile }); + render.init({ compile, toggles: vm.toggleLineEnabled }); scroll.init({ previous, next }); stream.init({ diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html index 441879b21f..fa973ec40d 100644 --- a/awx/ui/client/features/output/index.view.html +++ b/awx/ui/client/features/output/index.view.html @@ -1,25 +1,25 @@
- + - +
{{ vm.title }}
+ expanded="vm.isPanelExpanded">
-
- +
+
diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js index 3f315a1ac0..88d9cf6f39 100644 --- a/awx/ui/client/features/output/render.service.js +++ b/awx/ui/client/features/output/render.service.js @@ -30,13 +30,13 @@ const re = new RegExp(pattern); const hasAnsi = input => re.test(input); function JobRenderService ($q, $sce, $window) { - this.init = ({ compile }) => { + this.init = ({ compile, toggles }) => { this.parent = null; this.record = {}; this.el = $(ELEMENT_TBODY); this.hooks = { compile }; - this.createToggles = false; + this.createToggles = toggles; }; this.sortByLineNumber = (a, b) => { @@ -164,6 +164,24 @@ function JobRenderService ($q, $sce, $window) { return info; }; + this.deleteRecord = uuid => { + delete this.record[uuid]; + }; + + this.getParentEvents = (uuid, list) => { + list = list || []; + + if (this.record[uuid]) { + list.push(uuid); + + if (this.record[uuid].parents) { + list = list.concat(this.record[uuid].parents); + } + } + + return list; + }; + this.createRow = (current, ln, content) => { let id = ''; let timestamp = ''; @@ -180,7 +198,7 @@ function JobRenderService ($q, $sce, $window) { if (current) { if (this.createToggles && current.isParent && current.line === ln) { id = current.uuid; - tdToggle = ``; + tdToggle = ``; } if (current.isHost) { @@ -226,20 +244,6 @@ function JobRenderService ($q, $sce, $window) { return `${hour}:${minute}:${second}`; }; - this.getParentEvents = (uuid, list) => { - list = list || []; - - if (this.record[uuid]) { - list.push(uuid); - - if (this.record[uuid].parents) { - list = list.concat(this.record[uuid].parents); - } - } - - return list; - }; - this.remove = elements => this.requestAnimationFrame(() => elements.remove()); this.requestAnimationFrame = fn => $q(resolve => { diff --git a/awx/ui/client/features/output/slide.service.js b/awx/ui/client/features/output/slide.service.js index 507a277733..fdfbd41b42 100644 --- a/awx/ui/client/features/output/slide.service.js +++ b/awx/ui/client/features/output/slide.service.js @@ -60,7 +60,7 @@ function getOverlapArray (range, other) { function SlidingWindowService ($q) { this.init = (storage, api) => { - const { prepend, append, shift, pop } = storage; + const { prepend, append, shift, pop, deleteRecord } = storage; const { getMaxCounter, getRange, getFirst, getLast } = api; this.api = { @@ -74,10 +74,12 @@ function SlidingWindowService ($q) { prepend, append, shift, - pop + pop, + deleteRecord, }; this.records = {}; + this.uuids = {}; this.chain = $q.resolve(); }; @@ -87,8 +89,9 @@ function SlidingWindowService ($q) { return this.storage.append(newEvents) .then(() => { - newEvents.forEach(({ counter, start_line, end_line }) => { + newEvents.forEach(({ counter, start_line, end_line, uuid }) => { this.records[counter] = { start_line, end_line }; + this.uuids[counter] = uuid; }); return $q.resolve(); @@ -102,8 +105,9 @@ function SlidingWindowService ($q) { return this.storage.prepend(newEvents) .then(() => { - newEvents.forEach(({ counter, start_line, end_line }) => { + newEvents.forEach(({ counter, start_line, end_line, uuid }) => { this.records[counter] = { start_line, end_line }; + this.uuids[counter] = uuid; }); return $q.resolve(); @@ -130,6 +134,9 @@ function SlidingWindowService ($q) { .then(() => { for (let i = max; i >= min; --i) { delete this.records[i]; + + this.storage.deleteRecord(this.uuids[i]); + delete this.uuids[i]; } return $q.resolve(); @@ -156,6 +163,9 @@ function SlidingWindowService ($q) { .then(() => { for (let i = min; i <= max; ++i) { delete this.records[i]; + + this.storage.deleteRecord(this.uuids[i]); + delete this.uuids[i]; } return $q.resolve(); From 9ae69a4651251e2ed897d2b97bc4da05a391f5c6 Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Tue, 12 Jun 2018 10:02:08 -0400 Subject: [PATCH 198/762] remove .at-TabGroup + .at-Panel-body margin setting --- awx/ui/client/lib/components/tabs/_index.less | 4 ---- 1 file changed, 4 deletions(-) diff --git a/awx/ui/client/lib/components/tabs/_index.less b/awx/ui/client/lib/components/tabs/_index.less index e999a75613..f022a12c43 100644 --- a/awx/ui/client/lib/components/tabs/_index.less +++ b/awx/ui/client/lib/components/tabs/_index.less @@ -27,10 +27,6 @@ } } -.at-TabGroup + .at-Panel-body { - margin-top: 20px; -} - .at-TabGroup--padBelow { margin-bottom: 20px; } From 913da53ce6099a289c56babf958953850ad2d2f9 Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Tue, 12 Jun 2018 11:45:56 -0400 Subject: [PATCH 199/762] fix schedules list of inventory sources gives global schedules list --- .../related/sources/list/schedule/sources-schedule.route.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule.route.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule.route.js index aebe56b624..89cd27dffe 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule.route.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/list/schedule/sources-schedule.route.js @@ -10,10 +10,9 @@ export default { }, views: { 'related': { - templateProvider: function(SchedulesList, generateList){ - SchedulesList.title = false; + templateProvider: function(ScheduleList, generateList){ let html = generateList.build({ - list: SchedulesList, + list: ScheduleList, mode: 'edit' }); return html; @@ -47,6 +46,7 @@ export default { (SchedulesList, inventorySource) => { let list = _.cloneDeep(SchedulesList); list.basePath = `${inventorySource.get().related.schedules}`; + list.title = false; return list; } ] From f200a39b4bbae4789f7321a0a7188cfe9914b5bd Mon Sep 17 00:00:00 2001 From: Haokun-Chen Date: Tue, 12 Jun 2018 12:54:09 -0400 Subject: [PATCH 200/762] fix to enable to change default value of TCP CONNECTION TIMEOUT for logging --- awx/ui/client/src/configuration/configuration.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/awx/ui/client/src/configuration/configuration.controller.js b/awx/ui/client/src/configuration/configuration.controller.js index f34aecb70b..e1ea7188dd 100644 --- a/awx/ui/client/src/configuration/configuration.controller.js +++ b/awx/ui/client/src/configuration/configuration.controller.js @@ -479,8 +479,8 @@ export default [ else { // Everything else if (key !== 'LOG_AGGREGATOR_TCP_TIMEOUT' || - ($scope.LOG_AGGREGATOR_PROTOCOL === 'https' || - $scope.LOG_AGGREGATOR_PROTOCOL === 'tcp')) { + ($scope.LOG_AGGREGATOR_PROTOCOL.value === 'https' || + $scope.LOG_AGGREGATOR_PROTOCOL.value === 'tcp')) { payload[key] = $scope[key]; } } From 1733a20094d5c75fdb0af175fce69715d4e9a7cf Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 12 Jun 2018 13:57:28 -0400 Subject: [PATCH 201/762] make sdb-listen work for docker-compose-cluster use a different port range for each container, because docker can't map them all to the same port range --- tools/docker-compose-cluster.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tools/docker-compose-cluster.yml b/tools/docker-compose-cluster.yml index 0614b09767..934acbed1a 100644 --- a/tools/docker-compose-cluster.yml +++ b/tools/docker-compose-cluster.yml @@ -24,10 +24,12 @@ services: RABBITMQ_PASS: guest RABBITMQ_VHOST: / SDB_HOST: 0.0.0.0 + SDB_PORT: 5899 AWX_GROUP_QUEUES: alpha,tower volumes: - "../:/awx_devel" - + ports: + - "5899-5999:5899-5999" awx_2: privileged: true image: ${DEV_DOCKER_TAG_BASE}/awx_devel:${TAG} @@ -38,9 +40,12 @@ services: RABBITMQ_PASS: guest RABBITMQ_VHOST: / SDB_HOST: 0.0.0.0 + SDB_PORT: 6899 AWX_GROUP_QUEUES: bravo,tower volumes: - "../:/awx_devel" + ports: + - "6899-6999:6899-6999" awx_3: privileged: true image: ${DEV_DOCKER_TAG_BASE}/awx_devel:${TAG} @@ -51,9 +56,12 @@ services: RABBITMQ_PASS: guest RABBITMQ_VHOST: / SDB_HOST: 0.0.0.0 + SDB_PORT: 7899 AWX_GROUP_QUEUES: charlie,tower volumes: - "../:/awx_devel" + ports: + - "7899-7999:7899-7999" rabbitmq_1: image: ${DEV_DOCKER_TAG_BASE}/rabbit_cluster_node:latest hostname: rabbitmq_1 From 36732d8113d5158324f309786ccf46acbf7e52a3 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 12 Jun 2018 11:57:24 -0400 Subject: [PATCH 202/762] Fixed issue where search actions were sending two requests. Cleaned up and organized smart search controller --- .../smart-search/smart-search.controller.js | 187 +++++++++--------- 1 file changed, 97 insertions(+), 90 deletions(-) diff --git a/awx/ui/client/src/shared/smart-search/smart-search.controller.js b/awx/ui/client/src/shared/smart-search/smart-search.controller.js index 3a53ddf0aa..08fc748c9b 100644 --- a/awx/ui/client/src/shared/smart-search/smart-search.controller.js +++ b/awx/ui/client/src/shared/smart-search/smart-search.controller.js @@ -16,71 +16,26 @@ function SmartSearchController ( let queryset; let transitionSuccessListener; - configService.getConfig() - .then(config => init(config)); - - function init (config) { - let version; - - try { - [version] = config.version.split('-'); - } catch (err) { - version = 'latest'; - } - - $scope.documentationLink = `http://docs.ansible.com/ansible-tower/${version}/html/userguide/search_sort.html`; - $scope.searchPlaceholder = i18n._('Search'); - - if ($scope.defaultParams) { - defaults = $scope.defaultParams; - } else { - // steps through the current tree of $state configurations, grabs default search params - const stateConfig = _.find($state.$current.path, step => _.has(step, `params.${searchKey}`)); - defaults = stateConfig.params[searchKey].config.value; - } - - if ($scope.querySet) { - queryset = $scope.querySet; - } else { - queryset = $state.params[searchKey]; - } - - path = GetBasePath($scope.basePath) || $scope.basePath; - generateSearchTags(); - - qs.initFieldset(path, $scope.djangoModel) - .then((data) => { - $scope.models = data.models; - $scope.options = data.options.data; - $scope.keyFields = _.reduce(data.models[$scope.djangoModel].base, function(result, value, key) { - if (value.filterable) { - result.push(key); - } - return result; - }, []); - if ($scope.list) { - $scope.$emit(optionsKey, data.options); - } - }); - - function compareParams (a, b) { - for (let key in a) { - if (!(key in b) || a[key].toString() !== b[key].toString()) { - return false; - } + const compareParams = (a, b) => { + for (let key in a) { + if (!(key in b) || a[key].toString() !== b[key].toString()) { + return false; } - for (let key in b) { - if (!(key in a)) { - return false; - } + } + for (let key in b) { + if (!(key in a)) { + return false; } - return true; } + return true; + }; - if (transitionSuccessListener) { - transitionSuccessListener(); - } + const generateSearchTags = () => { + const { singleSearchParam } = $scope; + $scope.searchTags = qs.createSearchTagsFromQueryset(queryset, defaults, singleSearchParam); + }; + const listenForTransitionSuccess = () => { transitionSuccessListener = $transitions.onSuccess({}, trans => { // State has changed - check to see if this is a param change if (trans.from().name === trans.to().name) { @@ -99,29 +54,22 @@ function SmartSearchController ( } } }); + }; - $scope.$on('$destroy', transitionSuccessListener); - $scope.$watch('disableSearch', disableSearch => { - if (disableSearch) { - $scope.searchPlaceholder = i18n._('Cannot search running job'); - } else { - $scope.searchPlaceholder = i18n._('Search'); - } - }); - } + const isAnsibleFactField = (termParts) => { + const rootField = termParts[0].split('.')[0].replace(/^-/, ''); + return rootField === 'ansible_facts'; + }; - function generateSearchTags () { - const { singleSearchParam } = $scope; - $scope.searchTags = qs.createSearchTagsFromQueryset(queryset, defaults, singleSearchParam); - } - - function revertSearch (queryToBeRestored) { + const revertSearch = (queryToBeRestored) => { queryset = queryToBeRestored; // https://ui-router.github.io/docs/latest/interfaces/params.paramdeclaration.html#dynamic // This transition will not reload controllers/resolves/views // but will register new $stateParams[$scope.iterator + '_search'] terms if (!$scope.querySet) { - $state.go('.', { [searchKey]: queryset }); + transitionSuccessListener(); + $state.go('.', { [searchKey]: queryset }) + .then(() => listenForTransitionSuccess()); } qs.search(path, queryset).then((res) => { if ($scope.querySet) { @@ -134,18 +82,9 @@ function SmartSearchController ( $scope.searchTerm = null; generateSearchTags(); - } - - $scope.toggleKeyPane = () => { - $scope.showKeyPane = !$scope.showKeyPane; }; - function isAnsibleFactField (termParts) { - const rootField = termParts[0].split('.')[0].replace(/^-/, ''); - return rootField === 'ansible_facts'; - } - - function isFilterableBaseField (termParts) { + const isFilterableBaseField = (termParts) => { const rootField = termParts[0].split('.')[0].replace(/^-/, ''); const listName = $scope.list.name; const baseFieldPath = `models.${listName}.base.${rootField}`; @@ -155,9 +94,9 @@ function SmartSearchController ( const isBaseModelRelatedSearchTermField = (_.get($scope, `${baseFieldPath}.type`) === 'field'); return isBaseField && !isBaseModelRelatedSearchTermField && isFilterable; - } + }; - function isRelatedField (termParts) { + const isRelatedField = (termParts) => { const rootField = termParts[0].split('.')[0].replace(/^-/, ''); const listName = $scope.list.name; const baseRelatedTypePath = `models.${listName}.base.${rootField}.type`; @@ -166,7 +105,69 @@ function SmartSearchController ( const isBaseModelRelatedSearchTermField = (_.get($scope, baseRelatedTypePath) === 'field'); return (isRelatedSearchTermField || isBaseModelRelatedSearchTermField); - } + }; + + configService.getConfig() + .then(config => { + let version; + + try { + [version] = config.version.split('-'); + } catch (err) { + version = 'latest'; + } + + $scope.documentationLink = `http://docs.ansible.com/ansible-tower/${version}/html/userguide/search_sort.html`; + $scope.searchPlaceholder = i18n._('Search'); + + if ($scope.defaultParams) { + defaults = $scope.defaultParams; + } else { + // steps through the current tree of $state configurations, grabs default search params + const stateConfig = _.find($state.$current.path, step => _.has(step, `params.${searchKey}`)); + defaults = stateConfig.params[searchKey].config.value; + } + + if ($scope.querySet) { + queryset = $scope.querySet; + } else { + queryset = $state.params[searchKey]; + } + + path = GetBasePath($scope.basePath) || $scope.basePath; + generateSearchTags(); + + qs.initFieldset(path, $scope.djangoModel) + .then((data) => { + $scope.models = data.models; + $scope.options = data.options.data; + $scope.keyFields = _.reduce(data.models[$scope.djangoModel].base, (result, value, key) => { + if (value.filterable) { + result.push(key); + } + return result; + }, []); + if ($scope.list) { + $scope.$emit(optionsKey, data.options); + } + }); + + $scope.$on('$destroy', transitionSuccessListener); + $scope.$watch('disableSearch', disableSearch => { + if (disableSearch) { + $scope.searchPlaceholder = i18n._('Cannot search running job'); + } else { + $scope.searchPlaceholder = i18n._('Search'); + } + }); + + listenForTransitionSuccess(); + }); + + + $scope.toggleKeyPane = () => { + $scope.showKeyPane = !$scope.showKeyPane; + }; $scope.addTerms = terms => { const { singleSearchParam } = $scope; @@ -182,11 +183,13 @@ function SmartSearchController ( // This transition will not reload controllers/resolves/views but will register new // $stateParams[searchKey] terms. if (!$scope.querySet) { + transitionSuccessListener(); $state.go('.', { [searchKey]: queryset }) .then(() => { // same as above in $scope.remove. For some reason deleting the page // from the queryset works for all lists except lists in modals. delete $stateParams[searchKey].page; + listenForTransitionSuccess(); }); } @@ -212,6 +215,7 @@ function SmartSearchController ( queryset = qs.removeTermsFromQueryset(queryset, term, isFilterableBaseField, isRelatedField, isAnsibleFactField, singleSearchParam); if (!$scope.querySet) { + transitionSuccessListener(); $state.go('.', { [searchKey]: queryset }) .then(() => { // for some reason deleting a tag from a list in a modal does not @@ -219,6 +223,7 @@ function SmartSearchController ( // that that happened and remove it if it didn't. const clearedParams = qs.removeTermsFromQueryset($stateParams[searchKey], term, isFilterableBaseField, isRelatedField, isAnsibleFactField, singleSearchParam); $stateParams[searchKey] = clearedParams; + listenForTransitionSuccess(); }); } @@ -246,7 +251,9 @@ function SmartSearchController ( queryset = cleared; if (!$scope.querySet) { - $state.go('.', { [searchKey]: queryset }); + transitionSuccessListener(); + $state.go('.', { [searchKey]: queryset }) + .then(() => listenForTransitionSuccess()); } qs.search(path, queryset) From c3bda8e2597545688bbba87abd70084a11a979bb Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Tue, 12 Jun 2018 14:28:04 -0400 Subject: [PATCH 203/762] properly detect settings.AUTHENTICATION_BACKEND changes for SSO logins see: https://github.com/ansible/tower/issues/1979 --- awx/sso/middleware.py | 15 +++++++++++++++ awx/wsgi.py | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/awx/sso/middleware.py b/awx/sso/middleware.py index 1944ff4d0f..015e8fdd09 100644 --- a/awx/sso/middleware.py +++ b/awx/sso/middleware.py @@ -8,12 +8,14 @@ import urllib import six # Django +from django.conf import settings from django.utils.functional import LazyObject from django.shortcuts import redirect # Python Social Auth from social_core.exceptions import SocialAuthBaseException from social_core.utils import social_logger +from social_django import utils from social_django.middleware import SocialAuthExceptionMiddleware @@ -24,6 +26,19 @@ class SocialAuthMiddleware(SocialAuthExceptionMiddleware): request.session['social_auth_last_backend'] = callback_kwargs['backend'] def process_request(self, request): + if request.path.startswith('/sso'): + # django-social keeps a list of backends in memory that it gathers + # based on the value of settings.AUTHENTICATION_BACKENDS *at import + # time*: + # https://github.com/python-social-auth/social-app-django/blob/c1e2795b00b753d58a81fa6a0261d8dae1d9c73d/social_django/utils.py#L13 + # + # our settings.AUTHENTICATION_BACKENDS can *change* + # dynamically as Tower settings are changed (i.e., if somebody + # configures Github OAuth2 integration), so we need to + # _overwrite_ this in-memory value at the top of every request so + # that we have the latest version + # see: https://github.com/ansible/tower/issues/1979 + utils.BACKENDS = settings.AUTHENTICATION_BACKENDS token_key = request.COOKIES.get('token', '') token_key = urllib.quote(urllib.unquote(token_key).strip('"')) diff --git a/awx/wsgi.py b/awx/wsgi.py index e291a69d39..d351fed217 100644 --- a/awx/wsgi.py +++ b/awx/wsgi.py @@ -13,6 +13,7 @@ from django.core.wsgi import WSGIHandler # NOQA import django # NOQA from django.conf import settings # NOQA from django.urls import resolve # NOQA +import social_django # NOQA """ @@ -34,6 +35,11 @@ if MODE == 'production': logger.error("Missing or incorrect metadata for Tower version. Ensure Tower was installed using the setup playbook.") raise Exception("Missing or incorrect metadata for Tower version. Ensure Tower was installed using the setup playbook.") +if social_django.__version__ != '2.1.0': + raise RuntimeError("social_django version other than 2.1.0 detected {}. \ + Confirm that per-request social_django.utils.BACKENDS override \ + still works".format(social_django.__version__)) + if django.__version__ != '1.11.11': raise RuntimeError("Django version other than 1.11.11 detected {}. \ From 625ae5d1bf9ebe916b589d60ca82152b70fd4cac Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 12 Jun 2018 15:38:06 -0400 Subject: [PATCH 204/762] Remove panel header directive from instance modals --- .../add-edit/instance-list-policy.directive.js | 4 ++++ .../add-edit/instance-list-policy.partial.html | 18 +++++++++++++----- .../instances/instance-modal.controller.js | 6 +++++- .../instances/instance-modal.partial.html | 18 +++++++++++++----- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.directive.js b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.directive.js index 6b9111ab23..a156ac9ceb 100644 --- a/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.directive.js +++ b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.directive.js @@ -66,6 +66,10 @@ function InstanceListPolicyController ($scope, $state, strings) { $state.go("^.^"); }; + + vm.dismiss = () => { + $state.go('^.^'); + }; } InstanceListPolicyController.$inject = [ diff --git a/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html index 821bbffc64..e9d9287805 100644 --- a/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html +++ b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html @@ -1,8 +1,16 @@
\ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js b/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js index 4d492c65b8..0edac623d1 100644 --- a/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js +++ b/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js @@ -36,7 +36,7 @@ function InstanceModalController ($scope, $state, models, strings, ProcessErrors $scope.$watch('vm.instances', function() { vm.selectedRows = _.filter(vm.instances, 'isSelected'); vm.deselectedRows = _.filter(vm.instances, 'isSelected', false); - }, true); + }, true); vm.submit = () => { Wait('start'); @@ -70,6 +70,10 @@ function InstanceModalController ($scope, $state, models, strings, ProcessErrors vm.onSaveSuccess = () => { $state.go('instanceGroups.instances', {}, {reload: 'instanceGroups.instances'}); }; + + vm.dismiss = () => { + $state.go('instanceGroups.instances'); + }; } InstanceModalController.$inject = [ diff --git a/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html b/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html index 15c614a1df..90675e2f40 100644 --- a/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html +++ b/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html @@ -1,8 +1,16 @@
\ No newline at end of file From ab53fd087482484dd9cb0b3379c402b1cb105225 Mon Sep 17 00:00:00 2001 From: mabashian Date: Tue, 12 Jun 2018 13:59:21 -0400 Subject: [PATCH 205/762] Fix smart search directive unit tests --- awx/ui/test/spec/smart-search/smart-search.directive-test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/test/spec/smart-search/smart-search.directive-test.js b/awx/ui/test/spec/smart-search/smart-search.directive-test.js index afc6df33ed..6e8f1ab037 100644 --- a/awx/ui/test/spec/smart-search/smart-search.directive-test.js +++ b/awx/ui/test/spec/smart-search/smart-search.directive-test.js @@ -8,7 +8,6 @@ describe('Directive: Smart Search', () => { dom, $compile, $state = {}, - $stateParams, GetBasePath, QuerySet, ConfigService = {}, @@ -44,6 +43,7 @@ describe('Directive: Smart Search', () => { translateFilter = jasmine.createSpy('translateFilter'); i18n = jasmine.createSpy('i18n'); $state = jasmine.createSpyObj('$state', ['go']); + $state.go.and.callFake(() => { return { then: function(){} }; }); $provide.value('ConfigService', ConfigService); $provide.value('QuerySet', QuerySet); From acb13367212bae5994db656a76534cf670780f02 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 12 Jun 2018 14:40:44 -0400 Subject: [PATCH 206/762] Fix input lookup button height --- .../client/lib/components/input/_index.less | 26 +++++++++++++++++++ .../lib/components/input/lookup.partial.html | 16 +++++------- awx/ui/client/lib/theme/_mixins.less | 6 +++++ awx/ui/client/lib/theme/_variables.less | 6 +++-- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less index 4867ea2242..6c41607a27 100644 --- a/awx/ui/client/lib/components/input/_index.less +++ b/awx/ui/client/lib/components/input/_index.less @@ -221,6 +221,32 @@ padding: 6px @at-padding-input 0 @at-padding-input; } +.at-InputLookup { + display: flex; + + .at-InputLookup-button { + .at-mixin-InputButton(); + border-radius: @at-border-radius 0 0 @at-border-radius; + border-right: none; + flex: 0 0 35px; + height: auto; + min-height: 30px + } + + .at-InputLookup-tagContainer { + .at-mixin-Border; + display: flex; + flex-flow: row wrap; + padding: 0 10px; + width: 100%; + } + + .at-InputLookup-button + .at-Input, + .at-InputLookup-tagContainer { + border-radius: 0 @at-border-radius @at-border-radius 0; + } +} + .at-InputSlider { display: flex; padding: 5px 0; diff --git a/awx/ui/client/lib/components/input/lookup.partial.html b/awx/ui/client/lib/components/input/lookup.partial.html index 88cd218e9c..4d354ab462 100644 --- a/awx/ui/client/lib/components/input/lookup.partial.html +++ b/awx/ui/client/lib/components/input/lookup.partial.html @@ -2,14 +2,12 @@
-
- - - +
+ -
diff --git a/awx/ui/client/lib/theme/_mixins.less b/awx/ui/client/lib/theme/_mixins.less index a3aa4ef1df..3c3f0c66a5 100644 --- a/awx/ui/client/lib/theme/_mixins.less +++ b/awx/ui/client/lib/theme/_mixins.less @@ -20,6 +20,12 @@ padding: 0; } +.at-mixin-Border (@color: @at-border-default-color) { + border-width: @at-border-default-width; + border-style: @at-border-default-style; + border-color: @color +} + .at-mixin-Button () { border-radius: @at-border-radius; height: @at-height-input; diff --git a/awx/ui/client/lib/theme/_variables.less b/awx/ui/client/lib/theme/_variables.less index 3317148e4f..5acf3419cb 100644 --- a/awx/ui/client/lib/theme/_variables.less +++ b/awx/ui/client/lib/theme/_variables.less @@ -316,6 +316,10 @@ // 5. Misc ---------------------------------------------------------------------------------------- @at-border-radius: 5px; +@at-border-default-style: solid; +@at-border-default-width: 1px; +@at-border-default-color: @at-gray-b7; +@at-border-style-list-active-indicator: 5px solid @at-color-info; @at-popover-maxwidth: 320px; @at-line-height-short: 0.9; @at-line-height-tall: 2; @@ -325,8 +329,6 @@ @at-z-index-nav: 1040; @at-z-index-side-nav: 1030; @at-z-index-footer: 1020; -@at-border-default-width: 1px; -@at-border-style-list-active-indicator: 5px solid @at-color-info; @at-line-height-list-row-item-tag: 22px; // 6. Breakpoints --------------------------------------------------------------------------------- From 1a8b5426f8f8660b3d9eb5f16fa54a6bea91e573 Mon Sep 17 00:00:00 2001 From: Jared Tabor Date: Thu, 31 May 2018 19:06:51 -0700 Subject: [PATCH 207/762] UI i18n Audit: Translates strings throughout the UI Translations for dashboard lists -> tooltips Changing "Portal Mode" to "My View," in part due to translations. Adding "Job Template" to strings to be translated from OPTIONS on API Marking translations for JT that has a project that needs to be re-synced. Marking translations for survey maker Marking translations for lookup modal directive Marking translations for empty, "Go To Notifications To Add Template" Adds strings service for scheduler, and marking strings for translation Translations for teams linkout from orgs, as well as cred types Translations for instance groups Marks translations for the Network UI Translates strings on the workflow editor Translations for workflow results Translations for host event modal and some missing translations on the stdout page. --- awx/api/serializers.py | 3 +- .../host-event/host-event-modal.partial.html | 16 ++-- .../host-event/host-event.controller.js | 6 +- .../client/features/output/output.strings.js | 14 +++ .../client/features/output/stats.partial.html | 2 +- .../features/templates/templates.strings.js | 26 ++++++ .../lib/components/components.strings.js | 2 +- .../lib/components/layout/layout.partial.html | 2 +- .../lib/services/base-string.service.js | 1 + .../credential-types/add/add.controller.js | 6 +- .../lists/jobs/jobs-list.directive.js | 15 ++- .../lists/jobs/jobs-list.partial.html | 4 +- .../instance-groups.strings.js | 6 +- .../adhoc/adhoc-credential.route.js | 3 +- .../context_menu_button.partial.svg | 2 +- awx/ui/client/src/network-ui/models.js | 3 +- .../network-details/details.controller.js | 6 +- .../network-details/details.partial.html | 12 +-- .../client/src/network-ui/network-nav/main.js | 4 +- .../network-nav/network.nav.block.less | 4 +- .../network-nav/network.nav.controller.js | 24 ++++- .../network-nav/network.nav.strings.js | 19 ---- .../network-nav/network.nav.view.html | 14 +-- .../client/src/network-ui/network.ui.app.js | 4 +- .../src/network-ui/network.ui.controller.js | 10 +- .../src/network-ui/network.ui.strings.js | 56 ++++++++++++ .../add-notifications-action.partial.html | 4 +- .../linkout/organizations-linkout.route.js | 2 +- .../src/partials/survey-maker-modal.html | 6 +- awx/ui/client/src/scheduler/main.js | 4 +- .../client/src/scheduler/scheduler.strings.js | 61 +++++++++++++ .../src/scheduler/schedulerAdd.controller.js | 8 +- .../src/scheduler/schedulerEdit.controller.js | 8 +- .../src/scheduler/schedulerForm.partial.html | 91 ++++++++++--------- .../shared/lookup/lookup-modal.directive.js | 6 +- .../shared/lookup/lookup-modal.partial.html | 6 +- .../smart-status/smart-status.controller.js | 6 +- .../job-template-add.controller.js | 9 +- .../shared/question-definition.form.js | 2 +- .../survey-maker/surveys/init.factory.js | 29 +++--- .../workflow-chart.directive.js | 12 +-- .../workflow-maker.controller.js | 24 ++--- .../workflow-maker.partial.html | 48 +++++----- .../workflow-results/standard-out.block.less | 3 +- .../workflow-results.controller.js | 10 +- .../workflow-results.partial.html | 4 +- 46 files changed, 393 insertions(+), 214 deletions(-) delete mode 100644 awx/ui/client/src/network-ui/network-nav/network.nav.strings.js create mode 100644 awx/ui/client/src/network-ui/network.ui.strings.js create mode 100644 awx/ui/client/src/scheduler/scheduler.strings.js diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b59d182496..6490180632 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -299,10 +299,11 @@ class BaseSerializer(serializers.ModelSerializer): 'system_job': _('Management Job'), 'workflow_job': _('Workflow Job'), 'workflow_job_template': _('Workflow Template'), + 'job_template': _('Job Template') } choices = [] for t in self.get_types(): - name = type_name_map.get(t, force_text(get_model_for_type(t)._meta.verbose_name).title()) + name = _(type_name_map.get(t, force_text(get_model_for_type(t)._meta.verbose_name).title())) choices.append((t, name)) return choices diff --git a/awx/ui/client/features/output/host-event/host-event-modal.partial.html b/awx/ui/client/features/output/host-event/host-event-modal.partial.html index 47676df842..3dcc12eb6e 100644 --- a/awx/ui/client/features/output/host-event/host-event-modal.partial.html +++ b/awx/ui/client/features/output/host-event/host-event-modal.partial.html @@ -16,23 +16,23 @@
- CREATED + {{strings.get('host_event_modal.CREATED')}} {{(event.created | longDate) || "No result found"}}
- ID + {{strings.get('host_event_modal.ID')}} {{event.id || "No result found"}}
- PLAY + {{strings.get('host_event_modal.PLAY')}} {{event.play || "No result found"}}
- TASK + {{strings.get('host_event_modal.TASK')}} {{event.task || "No result found"}}
- MODULE + {{strings.get('host_event_modal.MODULE')}} {{module_name}}
@@ -48,12 +48,12 @@
@@ -64,7 +64,7 @@
- +
diff --git a/awx/ui/client/features/output/host-event/host-event.controller.js b/awx/ui/client/features/output/host-event/host-event.controller.js index 280bf51818..9f199fcd34 100644 --- a/awx/ui/client/features/output/host-event/host-event.controller.js +++ b/awx/ui/client/features/output/host-event/host-event.controller.js @@ -2,14 +2,15 @@ function HostEventsController ( $scope, $state, HostEventService, - hostEvent + hostEvent, + OutputStrings ) { $scope.processEventStatus = HostEventService.processEventStatus; $scope.processResults = processResults; $scope.isActiveState = isActiveState; $scope.getActiveHostIndex = getActiveHostIndex; $scope.closeHostEvent = closeHostEvent; - + $scope.strings = OutputStrings; function init () { hostEvent.event_name = hostEvent.event; $scope.event = _.cloneDeep(hostEvent); @@ -165,6 +166,7 @@ HostEventsController.$inject = [ '$state', 'HostEventService', 'hostEvent', + 'OutputStrings' ]; module.exports = HostEventsController; diff --git a/awx/ui/client/features/output/output.strings.js b/awx/ui/client/features/output/output.strings.js index a3fb2458e3..ec22a76d7c 100644 --- a/awx/ui/client/features/output/output.strings.js +++ b/awx/ui/client/features/output/output.strings.js @@ -87,11 +87,25 @@ function OutputStrings (BaseString) { ns.stats = { ELAPSED: t.s('Elapsed'), + PLAYS: t.s('Plays'), + TASKS: t.s('Tasks'), + HOSTS: t.s('Hosts') }; ns.stdout = { BACK_TO_TOP: t.s('Back to Top'), }; + + ns.host_event_modal = { + CREATED: t.s('CREATED'), + ID: t.s('ID'), + PLAY: t.s('PLAY'), + TASK: t.s('TASK'), + MODULE: t.s('MODULE'), + NO_RESULT_FOUND: t.s('No result found'), + STANDARD_OUT: t.s('Standard Out'), + STANDARD_ERROR: t.s('Standard Error') + }; } OutputStrings.$inject = ['BaseStringService']; diff --git a/awx/ui/client/features/output/stats.partial.html b/awx/ui/client/features/output/stats.partial.html index 151ae1d23b..c43992eed8 100644 --- a/awx/ui/client/features/output/stats.partial.html +++ b/awx/ui/client/features/output/stats.partial.html @@ -8,7 +8,7 @@ ... {{ vm.tasks }} - hosts + {{:: vm.strings.get('stats.HOSTS')}} ... {{ vm.hosts }} diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index a0fef3aaff..946fe71fcf 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -23,6 +23,7 @@ function TemplatesStrings (BaseString) { ns.prompt = { INVENTORY: t.s('Inventory'), CREDENTIAL: t.s('Credential'), + PROMPT: t.s('PROMPT'), OTHER_PROMPTS: t.s('Other Prompts'), SURVEY: t.s('Survey'), PREVIEW: t.s('Preview'), @@ -96,6 +97,31 @@ function TemplatesStrings (BaseString) { INVALID_JOB_TEMPLATE: t.s('This Job Template is missing a default inventory or project. This must be addressed in the Job Template form before this node can be saved.'), CREDENTIAL_WITH_PASS: t.s('This Job Template has a credential that requires a password. Credentials requiring passwords on launch are not permitted on workflow nodes.') }; + + ns.workflow_maker = { + DELETE_NODE_PROMPT_TEXT: t.s('Are you sure you want to delete this workflow node?'), + KEY: t.s('KEY'), + ON_SUCCESS: t.s('On Success'), + ON_FAILURE: t.s('On Failure'), + ALWAYS: t.s('Always'), + PROJECT_SYNC: t.s('Project Sync'), + INVENTORY_SYNC: t.s('Inventory Sync'), + WARNING: t.s('Warning'), + TOTAL_TEMPLATES: t.s('TOTAL TEMPLATES'), + ADD_A_TEMPLATE: t.s('ADD A TEMPLATE'), + EDIT_TEMPLATE: t.s('EDIT TEMPLATE'), + JOBS: t.s('JOBS'), + PLEASE_CLICK_THE_START_BUTTON: t.s('Please click the start button to build your workflow.'), + PLEASE_HOVER_OVER_A_TEMPLATE: t.s('Please hover over a template for additional options.'), + RUN: t.s('RUN'), + CHECK: t.s('CHECK'), + SELECT: t.s('SELECT'), + EDGE_CONFLICT: t.s('EDGE CONFLICT'), + DELETED: t.s('DELETED'), + START: t.s('START'), + DETAILS: t.s('DETAILS') + } + } TemplatesStrings.$inject = ['BaseStringService']; diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index a56ea9854d..00102becff 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -68,7 +68,7 @@ function ComponentsStrings (BaseString) { DASHBOARD: t.s('Dashboard'), JOBS: t.s('Jobs'), SCHEDULES: t.s('Schedules'), - PORTAL_MODE: t.s('Portal Mode'), + MY_VIEW: t.s('My View'), PROJECTS: t.s('Projects'), CREDENTIALS: t.s('Credentials'), CREDENTIAL_TYPES: t.s('Credential Types'), diff --git a/awx/ui/client/lib/components/layout/layout.partial.html b/awx/ui/client/lib/components/layout/layout.partial.html index df8c5d5843..bf4b78097c 100644 --- a/awx/ui/client/lib/components/layout/layout.partial.html +++ b/awx/ui/client/lib/components/layout/layout.partial.html @@ -42,7 +42,7 @@ - +
diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js index 9ecf87b69b..2362339192 100644 --- a/awx/ui/client/lib/services/base-string.service.js +++ b/awx/ui/client/lib/services/base-string.service.js @@ -71,6 +71,7 @@ function BaseStringService (namespace) { this.DELETE = t.s('DELETE'); this.COPY = t.s('COPY'); this.YES = t.s('YES'); + this.CLOSE = t.s('CLOSE'); this.deleteResource = { HEADER: t.s('Delete'), diff --git a/awx/ui/client/src/credential-types/add/add.controller.js b/awx/ui/client/src/credential-types/add/add.controller.js index 1e01e16736..2d44414962 100644 --- a/awx/ui/client/src/credential-types/add/add.controller.js +++ b/awx/ui/client/src/credential-types/add/add.controller.js @@ -6,10 +6,10 @@ export default ['Rest', 'Wait', 'CredentialTypesForm', 'ProcessErrors', 'GetBasePath', - 'GenerateForm', '$scope', '$state', 'Alert', 'GetChoices', 'ParseTypeChange', 'ToJSON', 'CreateSelect2', + 'GenerateForm', '$scope', '$state', 'Alert', 'GetChoices', 'ParseTypeChange', 'ToJSON', 'CreateSelect2', 'i18n', function(Rest, Wait, CredentialTypesForm, ProcessErrors, GetBasePath, - GenerateForm, $scope, $state, Alert, GetChoices, ParseTypeChange, ToJSON, CreateSelect2 + GenerateForm, $scope, $state, Alert, GetChoices, ParseTypeChange, ToJSON, CreateSelect2, i18n ) { var form = CredentialTypesForm, url = GetBasePath('credential_types'); @@ -38,7 +38,7 @@ export default ['Rest', 'Wait', }); const docs_url = 'https://docs.ansible.com/ansible-tower/latest/html/userguide/credential_types.html#getting-started-with-credential-types'; - const docs_help_text = `

Getting Started with Credential Types`; + const docs_help_text = `

${i18n._('Getting Started with Credential Types')}`; const api_inputs_help_text = _.get(options, 'actions.POST.inputs.help_text', "Specification for credential type inputs."); const api_injectors_help_text = _.get(options, 'actions.POST.injectors.help_text', "Specification for credential type injector."); diff --git a/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js b/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js index e14bc18ab0..07d95ab21d 100644 --- a/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js +++ b/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.directive.js @@ -3,7 +3,8 @@ export default [ '$filter', 'templateUrl', '$location', - function JobsList($filter, templateUrl, $location) { + 'i18n', + function JobsList($filter, templateUrl, $location, i18n) { return { restrict: 'E', link: link, @@ -29,7 +30,7 @@ export default // detailsUrl, status, name, time scope.jobs = _.map(list, function(job){ - let detailsUrl; + let detailsUrl, tooltip; if (job.type === 'workflow_job') { detailsUrl = `/#/workflows/${job.id}`; @@ -37,12 +38,20 @@ export default detailsUrl = `/#/jobs/playbook/${job.id}`; } + if(_.has(job, 'status') && job.status === 'successful'){ + tooltip = i18n._('Job successful. Click for details.'); + } + else if(_.has(job, 'status') && job.status === 'failed'){ + tooltip = i18n._('Job failed. Click for details.'); + } + return { detailsUrl, status: job.status, name: job.name, id: job.id, - time: $filter('longDate')(job.finished) + time: $filter('longDate')(job.finished), + tooltip: tooltip }; }); } diff --git a/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.partial.html b/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.partial.html index b2fe78689f..790600c33a 100644 --- a/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.partial.html +++ b/awx/ui/client/src/home/dashboard/lists/jobs/jobs-list.partial.html @@ -16,10 +16,10 @@ - + - + diff --git a/awx/ui/client/src/instance-groups/instance-groups.strings.js b/awx/ui/client/src/instance-groups/instance-groups.strings.js index 6e4cb2c66d..aff0edfd95 100644 --- a/awx/ui/client/src/instance-groups/instance-groups.strings.js +++ b/awx/ui/client/src/instance-groups/instance-groups.strings.js @@ -34,7 +34,8 @@ function InstanceGroupsStrings (BaseString) { ns.capacityBar = { IS_OFFLINE: t.s('Unavailable to run jobs.'), - IS_OFFLINE_LABEL: t.s('Unavailable') + IS_OFFLINE_LABEL: t.s('Unavailable'), + USED_CAPACITY: t.s('Used Capacity') }; ns.capacityAdjuster = { @@ -43,7 +44,8 @@ function InstanceGroupsStrings (BaseString) { }; ns.jobs = { - PANEL_TITLE: t.s('Jobs') + PANEL_TITLE: t.s('Jobs'), + RUNNING_JOBS: t.s('Running Jobs') }; ns.error = { diff --git a/awx/ui/client/src/inventories-hosts/inventories/adhoc/adhoc-credential.route.js b/awx/ui/client/src/inventories-hosts/inventories/adhoc/adhoc-credential.route.js index 82f3321652..c62ba4d547 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/adhoc/adhoc-credential.route.js +++ b/awx/ui/client/src/inventories-hosts/inventories/adhoc/adhoc-credential.route.js @@ -31,9 +31,8 @@ export default { } }, resolve: { - ListDefinition: ['CredentialList', 'i18n', function(CredentialList, i18n) { + ListDefinition: ['CredentialList', function(CredentialList) { let list = _.cloneDeep(CredentialList); - list.lookupConfirmText = i18n._('SELECT'); return list; }], Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', diff --git a/awx/ui/client/src/network-ui/context_menu_button.partial.svg b/awx/ui/client/src/network-ui/context_menu_button.partial.svg index 2c619cfb4d..22425b7f9f 100644 --- a/awx/ui/client/src/network-ui/context_menu_button.partial.svg +++ b/awx/ui/client/src/network-ui/context_menu_button.partial.svg @@ -11,7 +11,7 @@ dy=".3em" text-anchor="left">{{contextMenuButton.name}} -${$scope.strings.get('details.HOST_POPOVER')}

myserver.domain.com
127.0.0.1
10.1.0.140:25
server.example.com:25
`; $scope.formSave = function(){ var host = { diff --git a/awx/ui/client/src/network-ui/network-details/details.partial.html b/awx/ui/client/src/network-ui/network-details/details.partial.html index f7771e8916..acb5df2894 100644 --- a/awx/ui/client/src/network-ui/network-details/details.partial.html +++ b/awx/ui/client/src/network-ui/network-details/details.partial.html @@ -11,9 +11,9 @@
- + {{strings.get('details.HOST_NAME')}}
@@ -22,8 +22,8 @@
-
diff --git a/awx/ui/client/src/network-ui/network-nav/main.js b/awx/ui/client/src/network-ui/network-nav/main.js index 666130a0af..facbb7cd7d 100644 --- a/awx/ui/client/src/network-ui/network-nav/main.js +++ b/awx/ui/client/src/network-ui/network-nav/main.js @@ -1,5 +1,4 @@ import NetworkingController from './network.nav.controller'; -import NetworkingStrings from './network.nav.strings'; const MODULE_NAME = 'at.features.networking'; @@ -45,12 +44,11 @@ function NetworkingRun ($stateExtender, strings) { NetworkingRun.$inject = [ '$stateExtender', - 'NetworkingStrings' + 'awxNetStrings' ]; angular .module(MODULE_NAME, []) - .service('NetworkingStrings', NetworkingStrings) .run(NetworkingRun); export default MODULE_NAME; diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.block.less b/awx/ui/client/src/network-ui/network-nav/network.nav.block.less index c7d283bedd..7bfbd778a7 100644 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.block.less +++ b/awx/ui/client/src/network-ui/network-nav/network.nav.block.less @@ -164,6 +164,7 @@ font-weight: bold; display: flex; align-items: center; + text-transform: uppercase; } .Networking-keyContainer{ @@ -205,7 +206,8 @@ .Networking-keySymbolLabel{ font-size: 12px; padding-left: 15px; - color: @default-stdout-txt + color: @default-stdout-txt; + text-transform: uppercase; } .Networking-toolboxPanelToolbarIcon--selected{ diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js b/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js index bdba11d147..1e0f9573c4 100644 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js +++ b/awx/ui/client/src/network-ui/network-nav/network.nav.controller.js @@ -33,26 +33,42 @@ function NetworkingController (models, $state, $scope, strings) { $scope.$on('awxNet-instatiateSelect', (e, devices) => { for(var i = 0; i < devices.length; i++){ let device = devices[i]; + let grouping; + switch (device.type){ + case 'host': + grouping = strings.get('search.HOST'); + break; + case 'switch': + grouping = strings.get('search.SWITCH'); + break; + case 'router': + grouping = strings.get('search.ROUTER'); + break; + default: + grouping = strings.get('search.UNKNOWN'); + } $scope.devices.push({ value: device.id, text: device.name, label: device.name, id: device.id, - type: device.type + type: device.type, + group_type: grouping }); } $("#networking-search").select2({ width:'400px', containerCssClass: 'Form-dropDown', - placeholder: 'SEARCH', + placeholder: strings.get('search.SEARCH'), dropdownParent: $('.Networking-toolbar'), }); + $("#networking-actionsDropdown").select2({ width:'400px', containerCssClass: 'Form-dropDown', minimumResultsForSearch: -1, - placeholder: 'ACTIONS', + placeholder: strings.get('actions.ACTIONS'), dropdownParent: $('.Networking-toolbar'), }); }); @@ -118,7 +134,7 @@ NetworkingController.$inject = [ 'resolvedModels', '$state', '$scope', - 'NetworkingStrings', + 'awxNetStrings', 'CreateSelect2' ]; diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.strings.js b/awx/ui/client/src/network-ui/network-nav/network.nav.strings.js deleted file mode 100644 index 4aa4efca5e..0000000000 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.strings.js +++ /dev/null @@ -1,19 +0,0 @@ -function NetworkingStrings (BaseString) { - BaseString.call(this, 'networking'); - - const { t } = this; - const ns = this.networking; - - ns.state = { - BREADCRUMB_LABEL: t.s('INVENTORIES'), - }; - - ns.actions = { - EXPAND_PANEL: t.s('Expand Panel'), - COLLAPSE_PANEL: t.s('Collapse Panel') - }; -} - -NetworkingStrings.$inject = ['BaseStringService']; - -export default NetworkingStrings; diff --git a/awx/ui/client/src/network-ui/network-nav/network.nav.view.html b/awx/ui/client/src/network-ui/network-nav/network.nav.view.html index 2de980e748..4efd698a90 100644 --- a/awx/ui/client/src/network-ui/network-nav/network.nav.view.html +++ b/awx/ui/client/src/network-ui/network-nav/network.nav.view.html @@ -16,8 +16,8 @@
@@ -33,26 +33,26 @@
- KEY + {{ vm.strings.get('key.KEY') }}
d
-
DEBUG MODE
+
{{ vm.strings.get('key.DEBUG_MODE') }}
i
-
HIDE INTERFACES
+
{{ vm.strings.get('key.HIDE_INTERFACES') }}
0
-
RESET ZOOM
+
{{ vm.strings.get('key.RESET_ZOOM') }}
diff --git a/awx/ui/client/src/network-ui/network.ui.app.js b/awx/ui/client/src/network-ui/network.ui.app.js index 7fb414950d..3da725f7ff 100644 --- a/awx/ui/client/src/network-ui/network.ui.app.js +++ b/awx/ui/client/src/network-ui/network.ui.app.js @@ -3,6 +3,7 @@ import atFeaturesNetworking from './network-nav/main'; import networkDetailsDirective from './network-details/main'; import networkZoomWidget from './zoom-widget/main'; +import awxNetStrings from './network.ui.strings'; //console.log = function () { }; var NetworkUIController = require('./network.ui.controller.js'); @@ -40,4 +41,5 @@ export default .directive('awxNetQuadrants', quadrants.quadrants) .directive('awxNetInventoryToolbox', inventoryToolbox.inventoryToolbox) .directive('awxNetTestResults', test_results.test_results) - .directive('awxNetworkUi', awxNetworkUI.awxNetworkUI); + .directive('awxNetworkUi', awxNetworkUI.awxNetworkUI) + .service('awxNetStrings', awxNetStrings); diff --git a/awx/ui/client/src/network-ui/network.ui.controller.js b/awx/ui/client/src/network-ui/network.ui.controller.js index c7e066553f..a207471da6 100644 --- a/awx/ui/client/src/network-ui/network.ui.controller.js +++ b/awx/ui/client/src/network-ui/network.ui.controller.js @@ -28,7 +28,8 @@ var NetworkUIController = function($scope, $log, ProcessErrors, ConfigService, - rbacUiControlService) { + rbacUiControlService, + awxNetStrings) { window.scope = $scope; @@ -153,6 +154,7 @@ var NetworkUIController = function($scope, to_x: 0, to_y: 0}; $scope.canEdit = $scope.$parent.$resolve.resolvedModels.canEdit; + $scope.strings = awxNetStrings; $scope.send_trace_message = function (message) { if (!$scope.recording) { return; @@ -265,7 +267,7 @@ var NetworkUIController = function($scope, }; //Inventory Toolbox Setup - $scope.inventory_toolbox = new models.ToolBox(0, 'Inventory', 'device', 0, toolboxTopMargin, 200, toolboxHeight); + $scope.inventory_toolbox = new models.ToolBox(0, $scope.strings.get('toolbox.INVENTORY'), 'device', 0, toolboxTopMargin, 200, toolboxHeight); if (!$scope.disconnected) { $scope.for_each_page('/api/v2/inventories/' + $scope.inventory_id + '/hosts/', function(all_results) { @@ -920,8 +922,8 @@ var NetworkUIController = function($scope, const contextMenuButtonHeight = 26; let contextMenuHeight = 64; $scope.context_menu_buttons = [ - new models.ContextMenuButton("Details", 236, 231, 160, contextMenuButtonHeight, $scope.onDetailsContextButton, $scope), - new models.ContextMenuButton("Remove", 256, 231, 160, contextMenuButtonHeight, $scope.onDeleteContextMenu, $scope) + new models.ContextMenuButton($scope.strings.get('context_menu.DETAILS'), 236, 231, 160, contextMenuButtonHeight, $scope.onDetailsContextButton, $scope, 'details'), + new models.ContextMenuButton($scope.strings.get('context_menu.REMOVE'), 256, 231, 160, contextMenuButtonHeight, $scope.onDeleteContextMenu, $scope, 'remove') ]; if(!$scope.canEdit){ $scope.context_menu_buttons.pop(); diff --git a/awx/ui/client/src/network-ui/network.ui.strings.js b/awx/ui/client/src/network-ui/network.ui.strings.js new file mode 100644 index 0000000000..4b93ea90a6 --- /dev/null +++ b/awx/ui/client/src/network-ui/network.ui.strings.js @@ -0,0 +1,56 @@ +function awxNetStrings (BaseString) { + BaseString.call(this, 'awxNet'); + + const { t } = this; + const ns = this.awxNet; + + ns.state = { + BREADCRUMB_LABEL: t.s('INVENTORIES') + }; + + ns.toolbox = { + INVENTORY: t.s('Inventory') + }; + + ns.actions = { + ACTIONS: t.s('Actions'), + EXPORT: t.s('Export'), + EXPAND_PANEL: t.s('Expand Panel'), + COLLAPSE_PANEL: t.s('Collapse Panel') + }; + + ns.key = { + KEY: t.s('Key'), + DEBUG_MODE: t.s('Debug Mode'), + HIDE_CURSOR: t.s('Hide Cursor'), + HIDE_BUTTONS: t.s('Hide Buttons'), + HIDE_INTERFACES: t.s('Hide Interfaces'), + RESET_ZOOM: t.s('Reset Zoom') + }; + + ns.search = { + SEARCH: t.s('Search'), + HOST: t.s('Host'), + SWITCH: t.s('Switch'), + ROUTER: t.s('Router'), + UNKNOWN: t.s('Unknown') + }; + + ns.context_menu = { + DETAILS: t.s('Details'), + REMOVE: t.s('Remove') + }; + + ns.details = { + HOST_NAME: t.s('Host Name'), + DESCRIPTION: t.s('Description'), + HOST_POPOVER: t.s('Provide a host name, ip address, or ip address:port. Examples include:'), + SAVE_COMPLETE: t.s('Save Complete'), + CANCEL: t.s('Cancel') + }; + +} + +awxNetStrings.$inject = ['BaseStringService']; + +export default awxNetStrings; diff --git a/awx/ui/client/src/notifications/notification-templates-list/add-notifications-action.partial.html b/awx/ui/client/src/notifications/notification-templates-list/add-notifications-action.partial.html index af71530737..e6c2484fe1 100644 --- a/awx/ui/client/src/notifications/notification-templates-list/add-notifications-action.partial.html +++ b/awx/ui/client/src/notifications/notification-templates-list/add-notifications-action.partial.html @@ -1,4 +1,4 @@
-
GO TO NOTIFICATIONS TO
-
ADD A NEW TEMPLATE
+
GO TO NOTIFICATIONS TO
+
ADD A NEW TEMPLATE
diff --git a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js index f605f6c08e..b0161b2a89 100644 --- a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js +++ b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js @@ -108,7 +108,7 @@ let lists = [{ delete list.fieldActions.delete; list.listTitle = N_('Teams') + ` | {{ name }}`; list.basePath = `${GetBasePath('organizations')}${$stateParams.organization_id}/teams`; - list.emptyListText = "This list is populated by teams added from the Teams section"; + list.emptyListText = `${N_('This list is populated by teams added from the')} ${N_('Teams')} ${N_('section')}`; return list; }], OrgTeamsDataset: ['OrgTeamList', 'QuerySet', '$stateParams', 'GetBasePath', diff --git a/awx/ui/client/src/partials/survey-maker-modal.html b/awx/ui/client/src/partials/survey-maker-modal.html index b6acbe2ab3..476af0bc30 100644 --- a/awx/ui/client/src/partials/survey-maker-modal.html +++ b/awx/ui/client/src/partials/survey-maker-modal.html @@ -57,17 +57,17 @@ {{question.question_description}}
- +  
- -
diff --git a/awx/ui/client/src/scheduler/main.js b/awx/ui/client/src/scheduler/main.js index 038053e83a..80592b2e59 100644 --- a/awx/ui/client/src/scheduler/main.js +++ b/awx/ui/client/src/scheduler/main.js @@ -14,6 +14,7 @@ import SchedulePost from './factories/schedule-post.factory'; import ToggleSchedule from './factories/toggle-schedule.factory'; import SchedulesList from './schedules.list'; import ScheduledJobsList from './scheduled-jobs.list'; +import SchedulerStrings from './scheduler.strings'; export default angular.module('scheduler', []) @@ -26,4 +27,5 @@ export default .factory('ToggleSchedule', ToggleSchedule) .factory('SchedulesList', SchedulesList) .factory('ScheduledJobsList', ScheduledJobsList) - .directive('schedulerDatePicker', schedulerDatePicker); + .directive('schedulerDatePicker', schedulerDatePicker) + .service('SchedulerStrings', SchedulerStrings); diff --git a/awx/ui/client/src/scheduler/scheduler.strings.js b/awx/ui/client/src/scheduler/scheduler.strings.js new file mode 100644 index 0000000000..4d4173e5a3 --- /dev/null +++ b/awx/ui/client/src/scheduler/scheduler.strings.js @@ -0,0 +1,61 @@ +function SchedulerStrings (BaseString) { + BaseString.call(this, 'scheduler'); + + const { t } = this; + const ns = this.scheduler; + + ns.state = { + CREATE_SCHEDULE: t.s('CREATE SCHEDULE'), + EDIT_SCHEDULE: t.s('EDIT SCHEDULE') + }; + + ns.form = { + NAME: t.s('Name'), + NAME_REQUIRED_MESSAGE: t.s('A schedule name is required.'), + START_DATE: t.s('Start Date'), + START_TIME: t.s('Start Time'), + START_TIME_ERROR_MESSAGE: t.s('The time must be in HH24:MM:SS format.'), + LOCAL_TIME_ZONE: t.s('Local Time Zone'), + REPEAT_FREQUENCY: t.s('Repeat frequency'), + FREQUENCY_DETAILS: t.s('Frequency Details'), + EVERY: t.s('Every'), + REPEAT_FREQUENCY_ERROR_MESSAGE: t.s('Please provide a value between 1 and 999.'), + ON_DAY: t.s('on day'), + MONTH_DAY_ERROR_MESSAGE: t.s('The day must be between 1 and 31.'), + ON_THE: t.s('on the'), + ON: t.s('on'), + ON_DAYS: t.s('on days'), + SUN: t.s('Sun'), + MON: t.s('Mon'), + TUE: t.s('Tue'), + WED: t.s('Wed'), + THU: t.s('Thu'), + FRI: t.s('Fri'), + SAT: t.s('Sat'), + WEEK_DAY_ERROR_MESSAGE: t.s('Please select one or more days.'), + END: t.s('End'), + OCCURENCES: t.s('Occurrences'), + END_DATE: t.s('End Date'), + PROVIDE_VALID_DATE: t.s('Please provide a valid date.'), + END_TIME: t.s('End Time'), + SCHEDULER_OPTIONS_ARE_INVALID: t.s('The scheduler options are invalid, incomplete, or a date is in the past.'), + SCHEDULE_DESCRIPTION: t.s('Schedule Description'), + LIMITED_TO_FIRST_TEN: t.s('Limited to first 10'), + DATE_FORMAT: t.s('Date format'), + EXTRA_VARIABLES: t.s('Extra Variables'), + PROMPT: t.s('Prompt'), + CLOSE: t.s('Close'), + CANCEL: t.s('Cancel'), + SAVE: t.s('Save'), + WARNING: t.s('Warning'), + CREDENTIAL_REQUIRES_PASSWORD_WARNING: t.s('This Job Template has a default credential that requires a password before launch. Adding or editing schedules is prohibited while this credential is selected. To add or edit a schedule, credentials that require a password must be removed from the Job Template.') + }; + + ns.prompt = { + CONFIRM: t.s('CONFIRM') + }; +} + +SchedulerStrings.$inject = ['BaseStringService']; + +export default SchedulerStrings; diff --git a/awx/ui/client/src/scheduler/schedulerAdd.controller.js b/awx/ui/client/src/scheduler/schedulerAdd.controller.js index c5e04c5035..e3fdda845f 100644 --- a/awx/ui/client/src/scheduler/schedulerAdd.controller.js +++ b/awx/ui/client/src/scheduler/schedulerAdd.controller.js @@ -8,12 +8,12 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange', 'GetBasePath', 'Rest', 'ParentObject', 'JobTemplateModel', '$q', 'Empty', 'SchedulePost', 'ProcessErrors', 'SchedulerInit', '$location', 'PromptService', 'RRuleToAPI', 'moment', - 'WorkflowJobTemplateModel', 'TemplatesStrings', 'rbacUiControlService', 'Alert', 'i18n', + 'WorkflowJobTemplateModel', 'SchedulerStrings', 'rbacUiControlService', 'Alert', function($filter, $state, $stateParams, $http, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange, GetBasePath, Rest, ParentObject, JobTemplate, $q, Empty, SchedulePost, ProcessErrors, SchedulerInit, $location, PromptService, RRuleToAPI, moment, - WorkflowJobTemplate, TemplatesStrings, rbacUiControlService, Alert, i18n + WorkflowJobTemplate, SchedulerStrings, rbacUiControlService, Alert ) { var base = $scope.base || $location.path().replace(/^\//, '').split('/')[0], @@ -46,7 +46,7 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', }; $scope.preventCredsWithPasswords = true; - $scope.strings = TemplatesStrings; + $scope.strings = SchedulerStrings; /* * This is a workaround for the angular-scheduler library inserting `ll` into fields after an @@ -116,7 +116,7 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', launchConf.passwords_needed_to_start.length > 0 && !launchConf.ask_credential_on_launch ) { - Alert(i18n._('Warning'), i18n._('This Job Template has a default credential that requires a password before launch. Adding or editing schedules is prohibited while this credential is selected. To add or edit a schedule, credentials that require a password must be removed from the Job Template.'), 'alert-info'); + Alert(SchedulerStrings.get('form.WARNING'), SchedulerStrings.get('form.CREDENTIAL_REQUIRES_PASSWORD_WARNING'), 'alert-info'); $state.go('^', { reload: true }); } diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index 6d63b04361..4aa3044adf 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -1,11 +1,11 @@ export default ['$filter', '$state', '$stateParams', 'Wait', '$scope', 'moment', '$rootScope', '$http', 'CreateSelect2', 'ParseTypeChange', 'ParentObject', 'ProcessErrors', 'Rest', 'GetBasePath', 'SchedulerInit', 'SchedulePost', 'JobTemplateModel', '$q', 'Empty', 'PromptService', 'RRuleToAPI', -'WorkflowJobTemplateModel', 'TemplatesStrings', 'scheduleResolve', 'timezonesResolve', 'Alert', 'i18n', +'WorkflowJobTemplateModel', 'SchedulerStrings', 'scheduleResolve', 'timezonesResolve', 'Alert', function($filter, $state, $stateParams, Wait, $scope, moment, $rootScope, $http, CreateSelect2, ParseTypeChange, ParentObject, ProcessErrors, Rest, GetBasePath, SchedulerInit, SchedulePost, JobTemplate, $q, Empty, PromptService, RRuleToAPI, - WorkflowJobTemplate, TemplatesStrings, scheduleResolve, timezonesResolve, Alert, i18n + WorkflowJobTemplate, SchedulerStrings, scheduleResolve, timezonesResolve, Alert ) { let schedule, scheduler, scheduleCredentials = []; @@ -21,7 +21,7 @@ function($filter, $state, $stateParams, Wait, $scope, moment, $scope.hideForm = true; $scope.parseType = 'yaml'; - $scope.strings = TemplatesStrings; + $scope.strings = SchedulerStrings; /* * Keep processSchedulerEndDt method on the $scope @@ -255,7 +255,7 @@ function($filter, $state, $stateParams, Wait, $scope, moment, launchConf.passwords_needed_to_start.length > 0 && !launchConf.ask_credential_on_launch ) { - Alert(i18n._('Warning'), i18n._('This Job Template has a default credential that requires a password before launch. Adding or editing schedules is prohibited while this credential is selected. To add or edit a schedule, credentials that require a password must be removed from the Job Template.'), 'alert-info'); + Alert(SchedulerStrings.get('form.WARNING'), SchedulerStrings.get('form.CREDENTIAL_REQUIRES_PASSWORD_WARNING'), 'alert-info'); $scope.credentialRequiresPassword = true; } diff --git a/awx/ui/client/src/scheduler/schedulerForm.partial.html b/awx/ui/client/src/scheduler/schedulerForm.partial.html index 6b89b335df..3627fe5d5e 100644 --- a/awx/ui/client/src/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/scheduler/schedulerForm.partial.html @@ -1,7 +1,7 @@
-
{{ schedulerName || "ADD SCHEDULE"}}
-
{{ schedulerName || "EDIT SCHEDULE"}}
+
{{ schedulerName || strings.get('state.CREATE_SCHEDULE') }}
+
{{ schedulerName || strings.get('state.EDIT_SCHEDULE') }}
- on the + {{ strings.get('form.ON_THE') }}
- on + {{ strings.get('form.ON') }}
- The day must be between 1 and 31. + {{ strings.get('form.MONTH_DAY_ERROR_MESSAGE') }}
- on the + {{ strings.get('form.ON_THE') }}
@@ -376,7 +376,7 @@ RepeatFrequencyOptions-weekButton" data-value="SU" ng-click="$parent.setWeekday($event,'su')"> - Sun + {{ strings.get('form.SUN') }}
- Please select one or more days. + {{ strings.get('form.WEEK_DAY_ERROR_MESSAGE') }}
- Please provide a value between 1 and 999. + {{ strings.get('form.REPEAT_FREQUENCY_ERROR_MESSAGE') }}
- Please provide a valid date. + {{ strings.get('form.PROVIDE_VALID_DATE') }}
- The time must be in HH24:MM:SS format. + {{ strings.get('form.START_TIME_ERROR_MESSAGE') }}
@@ -574,13 +574,13 @@ SchedulerFormDetail-container--error" ng-show="(preview_list.isEmpty && scheduler_form.$dirty) || (!schedulerIsValid && scheduler_form.$dirty)">

- The scheduler options are invalid, incomplete, or a date is in the past. + {{ strings.get('form.SCHEDULER_OPTIONS_ARE_INVALID') }}

{{ rrule_nlp_description }} @@ -588,17 +588,17 @@