diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 72c1121c39..ca454a0b1e 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1229,7 +1229,7 @@ class OrganizationSerializer(BaseSerializer): class Meta: model = Organization - fields = ('*', 'custom_virtualenv',) + fields = ('*', 'max_hosts', 'custom_virtualenv',) def get_related(self, obj): res = super(OrganizationSerializer, self).get_related(obj) @@ -1265,6 +1265,20 @@ class OrganizationSerializer(BaseSerializer): summary_dict['related_field_counts'] = counts_dict[obj.id] return summary_dict + def validate(self, attrs): + obj = self.instance + view = self.context['view'] + + obj_limit = getattr(obj, 'max_hosts', None) + api_limit = attrs.get('max_hosts') + + if not view.request.user.is_superuser: + if api_limit is not None and api_limit != obj_limit: + # Only allow superusers to edit the max_hosts field + raise serializers.ValidationError(_('Cannot change max_hosts.')) + + return super(OrganizationSerializer, self).validate(attrs) + class ProjectOptionsSerializer(BaseSerializer): @@ -2202,8 +2216,8 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri class Meta: model = InventoryUpdate - fields = ('*', 'inventory', 'inventory_source', 'license_error', 'source_project_update', - 'custom_virtualenv', '-controller_node',) + fields = ('*', 'inventory', 'inventory_source', 'license_error', 'org_host_limit_error', + 'source_project_update', 'custom_virtualenv', '-controller_node',) def get_related(self, obj): res = super(InventoryUpdateSerializer, self).get_related(obj) diff --git a/awx/main/access.py b/awx/main/access.py index 93150adef8..18ea0a1516 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -330,6 +330,32 @@ class BaseAccess(object): elif "features" not in validation_info: raise LicenseForbids(_("Features not found in active license.")) + def check_org_host_limit(self, data, add_host_name=None): + validation_info = get_licenser().validate() + if validation_info.get('license_type', 'UNLICENSED') == 'open': + return + + inventory = get_object_from_data('inventory', Inventory, data) + if inventory is None: # In this case a missing inventory error is launched + return # further down the line, so just ignore it. + + org = inventory.organization + if org is None or org.max_hosts == 0: + return + + active_count = Host.objects.org_active_count(org.id) + if active_count > org.max_hosts: + raise PermissionDenied( + _("Organization host limit of %s has been exceeded, %s hosts active.") % + (org.max_hosts, active_count)) + + if add_host_name: + host_exists = Host.objects.filter(inventory__organization=org.id, name=add_host_name).exists() + if not host_exists and active_count == org.max_hosts: + raise PermissionDenied( + _("Organization host limit of %s would be exceeded, %s hosts active.") % + (org.max_hosts, active_count)) + def get_user_capabilities(self, obj, method_list=[], parent_obj=None, capabilities_cache={}): if obj is None: return {} @@ -360,7 +386,7 @@ class BaseAccess(object): user_capabilities[display_method] = self.user.is_superuser continue elif display_method == 'copy' and isinstance(obj, Project) and obj.scm_type == '': - # Connot copy manual project without errors + # Cannot copy manual project without errors user_capabilities[display_method] = False continue elif display_method in ['start', 'schedule'] and isinstance(obj, Group): # TODO: remove in 3.3 @@ -628,7 +654,7 @@ class OAuth2ApplicationAccess(BaseAccess): return self.model.objects.filter(organization__in=org_access_qs) def can_change(self, obj, data): - return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj, + return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj, role_field='admin_role', mandatory=True) def can_delete(self, obj): @@ -636,7 +662,7 @@ class OAuth2ApplicationAccess(BaseAccess): def can_add(self, data): if self.user.is_superuser: - return True + return True if not data: return Organization.accessible_objects(self.user, 'admin_role').exists() return self.check_related('organization', Organization, data, role_field='admin_role', mandatory=True) @@ -650,29 +676,29 @@ class OAuth2TokenAccess(BaseAccess): - I am the user of the token. I can create an OAuth2 app token when: - I have the read permission of the related application. - I can read, change or delete a personal token when: + I can read, change or delete a personal token when: - I am the user of the token - I am the superuser I can create an OAuth2 Personal Access Token when: - - I am a user. But I can only create a PAT for myself. + - I am a user. But I can only create a PAT for myself. ''' model = OAuth2AccessToken - + select_related = ('user', 'application') - - def filtered_queryset(self): + + def filtered_queryset(self): org_access_qs = Organization.objects.filter( Q(admin_role__members=self.user) | Q(auditor_role__members=self.user)) return self.model.objects.filter(application__organization__in=org_access_qs) | self.model.objects.filter(user__id=self.user.pk) - + def can_delete(self, obj): if (self.user.is_superuser) | (obj.user == self.user): return True elif not obj.application: return False return self.user in obj.application.organization.admin_role - + def can_change(self, obj, data): return self.can_delete(obj) @@ -840,6 +866,10 @@ class HostAccess(BaseAccess): # Check to see if we have enough licenses self.check_license(add_host_name=data.get('name', None)) + + # Check the per-org limit + self.check_org_host_limit(data, add_host_name=data.get('name', None)) + return True def can_change(self, obj, data): @@ -852,6 +882,10 @@ class HostAccess(BaseAccess): if data and 'name' in data: self.check_license(add_host_name=data['name']) + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}, + add_host_name=data['name']) + # Checks for admin or change permission on inventory, controls whether # the user can edit variable data. return obj and self.user in obj.inventory.admin_role @@ -1346,7 +1380,7 @@ class JobTemplateAccess(BaseAccess): return self.user in project.use_role else: return False - + @check_superuser def can_copy_related(self, obj): ''' @@ -1362,6 +1396,10 @@ class JobTemplateAccess(BaseAccess): # Check license. if validate_license: self.check_license() + + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}) + if obj.survey_enabled: self.check_license(feature='surveys') if Instance.objects.active_count() > 1: @@ -1520,6 +1558,9 @@ class JobAccess(BaseAccess): if validate_license: self.check_license() + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}) + # A super user can relaunch a job if self.user.is_superuser: return True @@ -1886,6 +1927,10 @@ class WorkflowJobTemplateAccess(BaseAccess): if validate_license: # check basic license, node count self.check_license() + + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}) + # if surveys are added to WFJTs, check license here if obj.survey_enabled: self.check_license(feature='surveys') @@ -1957,6 +2002,9 @@ class WorkflowJobAccess(BaseAccess): if validate_license: self.check_license() + # Check the per-org limit + self.check_org_host_limit({'inventory': obj.inventory}) + if self.user.is_superuser: return True @@ -2033,6 +2081,9 @@ class AdHocCommandAccess(BaseAccess): if validate_license: self.check_license() + # Check the per-org limit + self.check_org_host_limit(data) + # If a credential is provided, the user should have use access to it. if not self.check_related('credential', Credential, data, role_field='use_role'): return False @@ -2442,7 +2493,7 @@ class ActivityStreamAccess(BaseAccess): model = ActivityStream prefetch_related = ('organization', 'user', 'inventory', 'host', 'group', 'inventory_update', 'credential', 'credential_type', 'team', - 'ad_hoc_command', 'o_auth2_application', 'o_auth2_access_token', + 'ad_hoc_command', 'o_auth2_application', 'o_auth2_access_token', 'notification_template', 'notification', 'label', 'role', 'actor', 'schedule', 'custom_inventory_script', 'unified_job_template', 'workflow_job_template_node',) diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 3224edf5dd..af4c2d88d7 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -904,10 +904,27 @@ class Command(BaseCommand): logger.error(LICENSE_MESSAGE % d) raise CommandError('License count exceeded!') + def check_org_host_limit(self): + license_info = get_licenser().validate() + if license_info.get('license_type', 'UNLICENSED') == 'open': + return + + org = self.inventory.organization + if org is None or org.max_hosts == 0: + return + + active_count = Host.objects.org_active_count(org.id) + if active_count > org.max_hosts: + raise CommandError('Host limit for organization exceeded!') + def mark_license_failure(self, save=True): self.inventory_update.license_error = True self.inventory_update.save(update_fields=['license_error']) + def mark_org_limits_failure(self, save=True): + self.inventory_update.org_host_limit_error = True + self.inventory_update.save(update_fields=['org_host_limit_error']) + def handle(self, *args, **options): self.verbosity = int(options.get('verbosity', 1)) self.set_logging_level() @@ -961,6 +978,13 @@ class Command(BaseCommand): self.mark_license_failure(save=True) raise e + try: + # Check the per-org host limits + self.check_org_host_limit() + except CommandError as e: + self.mark_org_limits_failure(save=True) + raise e + status, tb, exc = 'error', '', None try: if settings.SQL_DEBUG: @@ -1032,9 +1056,17 @@ class Command(BaseCommand): # If the license is not valid, a CommandError will be thrown, # and inventory update will be marked as invalid. # with transaction.atomic() will roll back the changes. + license_fail = True self.check_license() + + # Check the per-org host limits + license_fail = False + self.check_org_host_limit() except CommandError as e: - self.mark_license_failure() + if license_fail: + self.mark_license_failure() + else: + self.mark_org_limits_failure() raise e if settings.SQL_DEBUG: @@ -1062,7 +1094,6 @@ class Command(BaseCommand): else: tb = traceback.format_exc() exc = e - transaction.rollback() if self.invoked_from_dispatcher is False: with ignore_inventory_computed_fields(): @@ -1073,7 +1104,8 @@ class Command(BaseCommand): self.inventory_source.status = status self.inventory_source.save(update_fields=['status']) - if exc and isinstance(exc, CommandError): - sys.exit(1) - elif exc: + if exc: + logger.error(str(exc)) + if isinstance(exc, CommandError): + sys.exit(1) raise exc diff --git a/awx/main/managers.py b/awx/main/managers.py index 341a7e806a..d554792699 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -29,6 +29,34 @@ class HostManager(models.Manager): """ return self.order_by().exclude(inventory_sources__source='tower').values('name').distinct().count() + def org_active_count(self, org_id): + """Return count of active, unique hosts used by an organization. + Construction of query involves: + - remove any ordering specified in model's Meta + - Exclude hosts sourced from another Tower + - Consider only hosts where the canonical inventory is owned by the organization + - Restrict the query to only return the name column + - Only consider results that are unique + - Return the count of this query + """ + return self.order_by().exclude( + inventory_sources__source='tower' + ).filter(inventory__organization=org_id).values('name').distinct().count() + + def active_counts_by_org(self): + """Return the counts of active, unique hosts for each organization. + Construction of query involves: + - remove any ordering specified in model's Meta + - Exclude hosts sourced from another Tower + - Consider only hosts where the canonical inventory is owned by each organization + - Restrict the query to only count distinct names + - Return the counts + """ + return self.order_by().exclude( + inventory_sources__source='tower' + ).values('inventory__organization').annotate( + inventory__organization__count=models.Count('name', distinct=True)) + def get_queryset(self): """When the parent instance of the host query set has a `kind=smart` and a `host_filter` set. Use the `host_filter` to generate the queryset for the hosts. diff --git a/awx/main/migrations/0063_v350_org_host_limits.py b/awx/main/migrations/0063_v350_org_host_limits.py new file mode 100644 index 0000000000..bc410e757a --- /dev/null +++ b/awx/main/migrations/0063_v350_org_host_limits.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2019-02-15 20:03 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0062_v350_new_playbook_stats'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryupdate', + name='org_host_limit_error', + field=models.BooleanField(default=False, editable=False), + ), + migrations.AddField( + model_name='organization', + name='max_hosts', + field=models.PositiveIntegerField(blank=True, default=0, help_text='Maximum number of hosts allowed to be managed by this organization.'), + ), + ] diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index b098d2e4a9..df04d5b1ad 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1653,6 +1653,10 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, default=False, editable=False, ) + org_host_limit_error = models.BooleanField( + default=False, + editable=False, + ) source_project_update = models.ForeignKey( 'ProjectUpdate', related_name='scm_inventory_updates', diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 8379cfa0cd..ac7b41bda9 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -10,6 +10,7 @@ from django.db.models import Q from django.contrib.auth.models import User from django.contrib.sessions.models import Session from django.utils.timezone import now as tz_now +from django.utils.translation import ugettext_lazy as _ # AWX @@ -42,6 +43,12 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi 'InstanceGroup', blank=True, ) + max_hosts = models.PositiveIntegerField( + blank=True, + default=0, + help_text=_('Maximum number of hosts allowed to be managed by this organization.'), + ) + admin_role = ImplicitRoleField( parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ) diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index 23ed3a4f7d..d261f3e146 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -326,6 +326,24 @@ def test_create_inventory_host(post, inventory, alice, role_field, expected_stat post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}), data, alice, expect=expected_status_code) +@pytest.mark.parametrize("hosts,expected_status_code", [ + (1, 201), + (2, 201), + (3, 201), +]) +@pytest.mark.django_db +def test_create_inventory_host_with_limits(post, admin_user, inventory, hosts, expected_status_code): + # The per-Organization host limits functionality should be a no-op on AWX. + inventory.organization.max_hosts = 2 + inventory.organization.save() + for i in range(hosts): + inventory.hosts.create(name="Existing host %i" % i) + + data = {'name': 'New name', 'description': 'Hello world'} + post(reverse('api:inventory_hosts_list', kwargs={'pk': inventory.id}), + data, admin_user, expect=expected_status_code) + + @pytest.mark.parametrize("role_field,expected_status_code", [ (None, 403), ('admin_role', 201), @@ -356,6 +374,18 @@ def test_edit_inventory_host(put, host, alice, role_field, expected_status_code) put(reverse('api:host_detail', kwargs={'pk': host.id}), data, alice, expect=expected_status_code) +@pytest.mark.django_db +def test_edit_inventory_host_with_limits(put, host, admin_user): + # The per-Organization host limits functionality should be a no-op on AWX. + inventory = host.inventory + inventory.organization.max_hosts = 1 + inventory.organization.save() + inventory.hosts.create(name='Alternate host') + + data = {'name': 'New name', 'description': 'Hello world'} + put(reverse('api:host_detail', kwargs={'pk': host.id}), data, admin_user, expect=200) + + @pytest.mark.parametrize("role_field,expected_status_code", [ (None, 403), ('admin_role', 204), diff --git a/awx/main/tests/functional/api/test_organizations.py b/awx/main/tests/functional/api/test_organizations.py index c1fa99e810..18aeb269ac 100644 --- a/awx/main/tests/functional/api/test_organizations.py +++ b/awx/main/tests/functional/api/test_organizations.py @@ -199,6 +199,30 @@ def test_update_organization(get, put, organization, alice, bob): put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=bob, expect=403) +@pytest.mark.django_db +def test_update_organization_max_hosts(get, put, organization, admin, alice, bob): + # Admin users can get and update max_hosts + data = get(reverse('api:organization_detail', kwargs={'pk': organization.id}), user=admin, expect=200).data + assert organization.max_hosts == 0 + data['max_hosts'] = 3 + put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=admin, expect=200) + organization.refresh_from_db() + assert organization.max_hosts == 3 + + # Organization admins can get the data and can update other fields, but not max_hosts + organization.admin_role.members.add(alice) + data = get(reverse('api:organization_detail', kwargs={'pk': organization.id}), user=alice, expect=200).data + data['max_hosts'] = 5 + put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=alice, expect=400) + organization.refresh_from_db() + assert organization.max_hosts == 3 + + # Ordinary users shouldn't be able to update either. + put(reverse('api:organization_detail', kwargs={'pk': organization.id}), data, user=bob, expect=403) + organization.refresh_from_db() + assert organization.max_hosts == 3 + + @pytest.mark.django_db @mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True) def test_delete_organization(delete, organization, admin): diff --git a/awx/main/tests/functional/test_rbac_job_start.py b/awx/main/tests/functional/test_rbac_job_start.py index 6748b3df5d..d205d992d3 100644 --- a/awx/main/tests/functional/test_rbac_job_start.py +++ b/awx/main/tests/functional/test_rbac_job_start.py @@ -21,6 +21,19 @@ def test_admin_executing_permissions(deploy_jobtemplate, inventory, machine_cred assert admin_user.can_access(Credential, 'use', machine_credential) +@pytest.mark.django_db +@pytest.mark.job_permissions +def test_admin_executing_permissions_with_limits(deploy_jobtemplate, inventory, user): + admin_user = user('admin-user', True) + + inventory.organization.max_hosts = 1 + inventory.organization.save() + inventory.hosts.create(name="Existing host 1") + inventory.hosts.create(name="Existing host 2") + + assert admin_user.can_access(JobTemplate, 'start', deploy_jobtemplate) + + @pytest.mark.django_db @pytest.mark.job_permissions def test_job_template_start_access(deploy_jobtemplate, user): diff --git a/awx/main/tests/functional/test_rbac_workflow.py b/awx/main/tests/functional/test_rbac_workflow.py index 7a2fede786..04926385c0 100644 --- a/awx/main/tests/functional/test_rbac_workflow.py +++ b/awx/main/tests/functional/test_rbac_workflow.py @@ -140,6 +140,16 @@ class TestWorkflowJobAccess: JobLaunchConfig.objects.create(job=workflow_job) assert WorkflowJobAccess(rando).can_start(workflow_job) + def test_can_start_with_limits(self, workflow_job, inventory, admin_user): + inventory.organization.max_hosts = 1 + inventory.organization.save() + inventory.hosts.create(name="Existing host 1") + inventory.hosts.create(name="Existing host 2") + workflow_job.inventory = inventory + workflow_job.save() + + assert WorkflowJobAccess(admin_user).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( diff --git a/awx/ui/client/features/output/details.component.js b/awx/ui/client/features/output/details.component.js index 696cfb8adf..67f3bee4a0 100644 --- a/awx/ui/client/features/output/details.component.js +++ b/awx/ui/client/features/output/details.component.js @@ -236,6 +236,18 @@ function getLicenseErrorDetails () { return { label, value }; } +function getHostLimitErrorDetails () { + if (!resource.model.has('org_host_limit_error')) { + return null; + } + + const label = strings.get('labels.HOST_LIMIT_ERROR'); + const tooltip = strings.get('tooltips.HOST_LIMIT'); + const value = resource.model.get('org_host_limit_error'); + + return { tooltip, label, value }; +} + function getLaunchedByDetails () { const createdBy = resource.model.get('summary_fields.created_by'); const jobTemplate = resource.model.get('summary_fields.job_template'); @@ -804,6 +816,7 @@ function JobDetailsController ( vm.overwrite = getOverwriteDetails(); vm.overwriteVars = getOverwriteVarsDetails(); vm.licenseError = getLicenseErrorDetails(); + vm.hostLimitError = getHostLimitErrorDetails(); // Relaunch and Delete Components vm.job = angular.copy(_.get(resource.model, 'model.GET', {})); diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html index 0e7cc3620a..b2faddea97 100644 --- a/awx/ui/client/features/output/details.partial.html +++ b/awx/ui/client/features/output/details.partial.html @@ -81,6 +81,26 @@ + +
+ +
+ {{ vm.hostLimitError.value }} +
+
+
diff --git a/awx/ui/client/features/output/output.strings.js b/awx/ui/client/features/output/output.strings.js index b47eff0ace..982070837e 100644 --- a/awx/ui/client/features/output/output.strings.js +++ b/awx/ui/client/features/output/output.strings.js @@ -22,6 +22,7 @@ function OutputStrings (BaseString) { 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'), + HOST_LIMIT: t.s('When this field is true, the job\'s inventory belongs to an organization that has exceeded it\'s limit of hosts as defined by the system administrator.'), INVENTORY: t.s('View the Inventory'), INVENTORY_SCM: t.s('View the Project'), INVENTORY_SCM_JOB: t.s('View Project checkout results'), @@ -57,6 +58,7 @@ function OutputStrings (BaseString) { EXTRA_VARS: t.s('Extra Variables'), FINISHED: t.s('Finished'), FORKS: t.s('Forks'), + HOST_LIMIT_ERROR: t.s('Host Limit Error'), INSTANCE_GROUP: t.s('Instance Group'), INVENTORY: t.s('Inventory'), INVENTORY_SCM: t.s('Source Project'), diff --git a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js index fe0dfa0f9a..80cfc85367 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js +++ b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js @@ -39,93 +39,110 @@ function atLaunchTemplateCtrl ( if (vm.template.type === 'job_template') { const selectedJobTemplate = jobTemplate.create(); const preLaunchPromises = [ - selectedJobTemplate.getLaunch(vm.template.id), - selectedJobTemplate.optionsLaunch(vm.template.id), + selectedJobTemplate.getLaunch(vm.template.id) + .catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/launch`, 'GET')), + selectedJobTemplate.optionsLaunch(vm.template.id) + .catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/launch`, 'OPTIONS')) ]; Promise.all(preLaunchPromises) .then(([launchData, launchOptions]) => { - if (selectedJobTemplate.canLaunchWithoutPrompt()) { - selectedJobTemplate - .postLaunch({ id: vm.template.id }) - .then(({ data }) => { - /* Slice Jobs: Redirect to WF Details page if returned - job type is a WF job */ - if (data.type === 'workflow_job' && data.workflow_job !== null) { - $state.go('workflowResults', { id: data.workflow_job }, { reload: true }); - } else { - $state.go('output', { id: data.job, type: 'playbook' }, { reload: true }); - } - }); - } else { - const promptData = { - launchConf: launchData.data, - launchOptions: launchOptions.data, - template: vm.template.id, - templateType: vm.template.type, - prompts: PromptService.processPromptValues({ - launchConf: launchData.data, - launchOptions: launchOptions.data - }), - triggerModalOpen: true - }; - - if (launchData.data.survey_enabled) { - selectedJobTemplate.getSurveyQuestions(vm.template.id) + // If we don't get both of these things then one of the + // promises was rejected + if (launchData && launchOptions) { + if (selectedJobTemplate.canLaunchWithoutPrompt()) { + selectedJobTemplate + .postLaunch({ id: vm.template.id }) .then(({ data }) => { - const processed = PromptService.processSurveyQuestions({ - surveyQuestions: data.spec - }); - promptData.surveyQuestions = processed.surveyQuestions; - vm.promptData = promptData; - }); + /* Slice Jobs: Redirect to WF Details page if returned + job type is a WF job */ + if (data.type === 'workflow_job' && data.workflow_job !== null) { + $state.go('workflowResults', { id: data.workflow_job }, { reload: true }); + } else { + $state.go('output', { id: data.job, type: 'playbook' }, { reload: true }); + } + }) + .catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/launch`, 'POST')); } else { - vm.promptData = promptData; + const promptData = { + launchConf: launchData.data, + launchOptions: launchOptions.data, + template: vm.template.id, + templateType: vm.template.type, + prompts: PromptService.processPromptValues({ + launchConf: launchData.data, + launchOptions: launchOptions.data + }), + triggerModalOpen: true + }; + + if (launchData.data.survey_enabled) { + selectedJobTemplate.getSurveyQuestions(vm.template.id) + .then(({ data }) => { + const processed = PromptService.processSurveyQuestions({ + surveyQuestions: data.spec + }); + promptData.surveyQuestions = processed.surveyQuestions; + vm.promptData = promptData; + }) + .catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/survey_spec`, 'GET')); + } else { + vm.promptData = promptData; + } } } }); } 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), + selectedWorkflowJobTemplate.request('get', vm.template.id) + .catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}`, 'GET')), + selectedWorkflowJobTemplate.getLaunch(vm.template.id) + .catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/launch`, 'GET')), + selectedWorkflowJobTemplate.optionsLaunch(vm.template.id) + .catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/launch`, 'OPTIONS')), ]; Promise.all(preLaunchPromises) .then(([wfjtData, launchData, launchOptions]) => { - if (selectedWorkflowJobTemplate.canLaunchWithoutPrompt()) { - selectedWorkflowJobTemplate - .postLaunch({ id: vm.template.id }) - .then(({ data }) => { - $state.go('workflowResults', { id: data.workflow_job }, { reload: true }); - }); - } else { - launchData.data.defaults.extra_vars = wfjtData.data.extra_vars; - - const promptData = { - launchConf: selectedWorkflowJobTemplate.getLaunchConf(), - launchOptions: launchOptions.data, - template: vm.template.id, - templateType: vm.template.type, - prompts: PromptService.processPromptValues({ - launchConf: selectedWorkflowJobTemplate.getLaunchConf(), - launchOptions: launchOptions.data - }), - triggerModalOpen: true, - }; - - if (launchData.data.survey_enabled) { - selectedWorkflowJobTemplate.getSurveyQuestions(vm.template.id) + // If we don't get all of these things then one of the + // promises was rejected + if (wfjtData && launchData && launchOptions) { + if (selectedWorkflowJobTemplate.canLaunchWithoutPrompt()) { + selectedWorkflowJobTemplate + .postLaunch({ id: vm.template.id }) .then(({ data }) => { - const processed = PromptService.processSurveyQuestions({ - surveyQuestions: data.spec - }); - promptData.surveyQuestions = processed.surveyQuestions; - vm.promptData = promptData; - }); + $state.go('workflowResults', { id: data.workflow_job }, { reload: true }); + }) + .catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/launch`, 'POST')); } else { - vm.promptData = promptData; + launchData.data.defaults.extra_vars = wfjtData.data.extra_vars; + + const promptData = { + launchConf: selectedWorkflowJobTemplate.getLaunchConf(), + launchOptions: launchOptions.data, + template: vm.template.id, + templateType: vm.template.type, + prompts: PromptService.processPromptValues({ + launchConf: selectedWorkflowJobTemplate.getLaunchConf(), + launchOptions: launchOptions.data + }), + triggerModalOpen: true, + }; + + if (launchData.data.survey_enabled) { + selectedWorkflowJobTemplate.getSurveyQuestions(vm.template.id) + .then(({ data }) => { + const processed = PromptService.processSurveyQuestions({ + surveyQuestions: data.spec + }); + promptData.surveyQuestions = processed.surveyQuestions; + vm.promptData = promptData; + }) + .catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/survey_spec`, 'GET')); + } else { + vm.promptData = promptData; + } } } }); @@ -163,14 +180,14 @@ function atLaunchTemplateCtrl ( } else { $state.go('output', { id: launchRes.data.job, type: 'playbook' }, { reload: true }); } - }).catch(createErrorHandler('launch job template', 'POST')); + }).catch(createErrorHandler(`/api/v2/job_templates/${vm.template.id}/launch`, 'POST')); } else if (vm.promptData.templateType === 'workflow_job_template') { workflowTemplate.create().postLaunch({ id: vm.promptData.template, launchData: jobLaunchData }).then((launchRes) => { $state.go('workflowResults', { id: launchRes.data.workflow_job }, { reload: true }); - }).catch(createErrorHandler('launch workflow job template', 'POST')); + }).catch(createErrorHandler(`/api/v2/workflow_job_templates/${vm.template.id}/launch`, 'POST')); } }; } diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/hosts/add/host-add.controller.js b/awx/ui/client/src/inventories-hosts/inventories/related/hosts/add/host-add.controller.js index 207bdf4f8f..3513652a98 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/hosts/add/host-add.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/hosts/add/host-add.controller.js @@ -5,9 +5,9 @@ *************************************************/ export default ['$state', '$stateParams', '$scope', 'RelatedHostsFormDefinition', 'ParseTypeChange', - 'GenerateForm', 'HostsService', 'rbacUiControlService', 'GetBasePath', 'ToJSON', 'canAdd', + 'GenerateForm', 'HostsService', 'GetBasePath', 'ToJSON', 'canAdd', function($state, $stateParams, $scope, RelatedHostsFormDefinition, ParseTypeChange, - GenerateForm, HostsService, rbacUiControlService, GetBasePath, ToJSON, canAdd) { + GenerateForm, HostsService, GetBasePath, ToJSON, canAdd) { init(); diff --git a/awx/ui/client/src/inventories-hosts/shared/hosts.service.js b/awx/ui/client/src/inventories-hosts/shared/hosts.service.js index 8624146892..ceddb8ca07 100644 --- a/awx/ui/client/src/inventories-hosts/shared/hosts.service.js +++ b/awx/ui/client/src/inventories-hosts/shared/hosts.service.js @@ -17,9 +17,9 @@ url: function(){ return ''; }, - error: function(data, status) { - ProcessErrors($rootScope, data.data, status, null, { hdr: 'Error!', - msg: 'Call to ' + this.url + '. GET returned: ' + status }); + error: function(data) { + ProcessErrors($rootScope, data.data, data.status, null, { hdr: 'Error!', + msg: 'Call to ' + this.url + '. GET returned: ' + data.status }); }, success: function(data){ return data; diff --git a/awx/ui/client/src/organizations/add/organizations-add.controller.js b/awx/ui/client/src/organizations/add/organizations-add.controller.js index 6acccc4296..cd6aea6cb9 100644 --- a/awx/ui/client/src/organizations/add/organizations-add.controller.js +++ b/awx/ui/client/src/organizations/add/organizations-add.controller.js @@ -24,8 +24,6 @@ export default ['$scope', '$rootScope', '$location', '$stateParams', init(); function init(){ - // @issue What is this doing, why - $scope.$emit("HideOrgListHeader"); const virtualEnvs = ConfigData.custom_virtualenvs || []; $scope.custom_virtualenvs_visible = virtualEnvs.length > 1; $scope.custom_virtualenvs_options = virtualEnvs.filter( @@ -43,15 +41,18 @@ export default ['$scope', '$rootScope', '$location', '$stateParams', // Save $scope.formSave = function() { + var fld, params = {}; Wait('start'); + for (fld in form.fields) { + params[fld] = $scope[fld]; + } + if (!params.max_hosts || params.max_hosts === '') { + params.max_hosts = 0; + } var url = GetBasePath(base); url += (base !== 'organizations') ? $stateParams.project_id + '/organizations/' : ''; Rest.setUrl(url); - Rest.post({ - name: $scope.name, - description: $scope.description, - custom_virtualenv: $scope.custom_virtualenv - }) + Rest.post(params) .then(({data}) => { const organization_id = data.id, instance_group_url = data.related.instance_groups; @@ -73,7 +74,7 @@ export default ['$scope', '$rootScope', '$location', '$stateParams', let explanation = _.has(data, "name") ? data.name[0] : ""; ProcessErrors($scope, data, status, OrganizationForm, { hdr: 'Error!', - msg: `Failed to save organization. PUT status: ${status}. ${explanation}` + msg: `Failed to save organization. POST status: ${status}. ${explanation}` }); }); }; diff --git a/awx/ui/client/src/organizations/edit/organizations-edit.controller.js b/awx/ui/client/src/organizations/edit/organizations-edit.controller.js index e58b19aa6d..a847068d49 100644 --- a/awx/ui/client/src/organizations/edit/organizations-edit.controller.js +++ b/awx/ui/client/src/organizations/edit/organizations-edit.controller.js @@ -38,7 +38,6 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup', } }); - $scope.$emit("HideOrgListHeader"); $scope.instance_groups = InstanceGroupsData; const virtualEnvs = ConfigData.custom_virtualenvs || []; $scope.custom_virtualenvs_visible = virtualEnvs.length > 1; @@ -57,7 +56,7 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup', $scope.organization_name = data.name; for (fld in form.fields) { - if (data[fld]) { + if (typeof data[fld] !== 'undefined') { $scope[fld] = data[fld]; master[fld] = data[fld]; } @@ -98,6 +97,9 @@ export default ['$scope', '$location', '$stateParams', 'OrgAdminLookup', for (fld in form.fields) { params[fld] = $scope[fld]; } + if (!params.max_hosts || params.max_hosts === '') { + params.max_hosts = 0; + } Rest.setUrl(defaultUrl + id + '/'); Rest.put(params) .then(() => { diff --git a/awx/ui/client/src/organizations/organizations.form.js b/awx/ui/client/src/organizations/organizations.form.js index 56cfbebeae..a00770f7ba 100644 --- a/awx/ui/client/src/organizations/organizations.form.js +++ b/awx/ui/client/src/organizations/organizations.form.js @@ -54,6 +54,21 @@ export default ['NotificationsList', 'i18n', dataPlacement: 'right', ngDisabled: '!(organization_obj.summary_fields.user_capabilities.edit || canAdd)', ngShow: 'custom_virtualenvs_visible' + }, + max_hosts: { + label: i18n._('Max Hosts'), + type: 'number', + integer: true, + min: 0, + max: 2147483647, + default: 0, + spinner: true, + dataTitle: i18n._('Max Hosts'), + dataPlacement: 'right', + dataContainer: 'body', + awPopOver: "

" + i18n._("The maximum number of hosts allowed to be managed by this organization. Value defaults to 0 which means no limit. Refer to the Ansible documentation for more details.") + "

", + ngDisabled: '!current_user.is_superuser', + ngShow: 'BRAND_NAME === "Tower"' } },