diff --git a/awx/api/views.py b/awx/api/views.py index 20a6681d36..5c177dbebf 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -282,10 +282,16 @@ class DashboardView(APIView): user_hosts = get_user_queryset(request.user, Host) user_hosts_failed = user_hosts.filter(has_active_failures=True) + try: + user_hosts_count = user_hosts.distinct('name').count() + user_hosts_failed_count = user_hosts_failed.distinct('name').count() + except NotImplementedError: # For unit tests only, SQLite doesn't support distinct('name') + user_hosts_count = len(set(user_hosts.values_list('name', flat=True))) + user_hosts_failed_count = len(set(user_hosts_failed.values_list('name', flat=True))) data['hosts'] = {'url': reverse('api:host_list'), 'failures_url': reverse('api:host_list') + "?has_active_failures=True", - 'total': user_hosts.count(), - 'failed': user_hosts_failed.count()} + 'total': user_hosts_count, + 'failed': user_hosts_failed_count} user_projects = get_user_queryset(request.user, Project) user_projects_failed = user_projects.filter(last_job_failed=True) @@ -2352,7 +2358,7 @@ class HostAdHocCommandsList(AdHocCommandList, SubListCreateAPIView): relationship = 'ad_hoc_commands' -class AdHocCommandDetail(RetrieveAPIView): +class AdHocCommandDetail(RetrieveDestroyAPIView): model = AdHocCommand serializer_class = AdHocCommandSerializer @@ -2399,6 +2405,21 @@ class AdHocCommandRelaunch(GenericAPIView): if not request.user.can_access(self.model, 'start', obj): raise PermissionDenied() + # Re-validate ad hoc command against serializer to check if module is + # still allowed. + data = {} + for field in ('job_type', 'inventory_id', 'limit', 'credential_id', + 'module_name', 'module_args', 'forks', 'verbosity', + 'become_enabled'): + if field.endswith('_id'): + data[field[:-3]] = getattr(obj, field) + else: + data[field] = getattr(obj, field) + serializer = self.get_serializer(data=data) + if not serializer.is_valid(): + return Response(serializer.errors, + status=status.HTTP_400_BAD_REQUEST) + # Check for passwords needed before copying ad hoc command. needed = obj.passwords_needed_to_start provided = dict([(field, request.DATA.get(field, '')) for field in needed]) diff --git a/awx/main/access.py b/awx/main/access.py index c487187941..1cbc50bf09 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1208,7 +1208,7 @@ class AdHocCommandAccess(BaseAccess): # If a credential is provided, the user should have read access to it. credential_pk = get_pk_from_dict(data, 'credential') if credential_pk: - credential = get_object_or_400(Credential, pk=credential_pk) + credential = get_object_or_400(Credential, pk=credential_pk, active=True) if not self.user.can_access(Credential, 'read', credential): return False @@ -1216,7 +1216,7 @@ class AdHocCommandAccess(BaseAccess): # given inventory. inventory_pk = get_pk_from_dict(data, 'inventory') if inventory_pk: - inventory = get_object_or_400(Inventory, pk=inventory_pk) + inventory = get_object_or_400(Inventory, pk=inventory_pk, active=True) if not self.user.can_access(Inventory, 'run_ad_hoc_commands', inventory): return False @@ -1226,7 +1226,7 @@ class AdHocCommandAccess(BaseAccess): return False def can_delete(self, obj): - return False + return self.can_read(obj) def can_start(self, obj): return self.can_add({ diff --git a/awx/main/management/commands/cleanup_deleted.py b/awx/main/management/commands/cleanup_deleted.py index af0266a2be..4c84a695cd 100644 --- a/awx/main/management/commands/cleanup_deleted.py +++ b/awx/main/management/commands/cleanup_deleted.py @@ -40,6 +40,29 @@ class Command(BaseCommand): yield submodel def cleanup_model(self, model): + + n_deleted_items = 0 + pks_to_delete = set() + for asobj in ActivityStream.objects.iterator(): + asobj_disp = '"%s" id: %s' % (unicode(asobj), asobj.id) + if asobj.timestamp >= self.cutoff: + if self.dry_run: + self.logger.info("would skip %s" % asobj_disp) + else: + if self.dry_run: + self.logger.info("would delete %s" % asobj_disp) + else: + pks_to_delete.add(asobj.pk) + # Cleanup objects in batches instead of deleting each one individually. + if len(pks_to_delete) >= 500: + ActivityStream.objects.filter(pk__in=pks_to_delete).delete() + n_deleted_items += len(pks_to_delete) + pks_to_delete.clear() + if len(pks_to_delete): + ActivityStream.objects.filter(pk__in=pks_to_delete).delete() + n_deleted_items += len(pks_to_delete) + print("Removed %s items" % str(n_deleted_items)) + name_field = None active_field = None n_deleted_items = 0 @@ -63,7 +86,8 @@ class Command(BaseCommand): '%s__startswith' % name_field: name_prefix, }) self.logger.debug('cleaning up model %s', model) - for instance in qs: + pks_to_delete = set() + for instance in qs.iterator(): dt = parse_datetime(getattr(instance, name_field).split('_')[2]) if not is_aware(dt): dt = make_aware(dt, self.cutoff.tzinfo) @@ -76,10 +100,17 @@ class Command(BaseCommand): else: action_text = 'would delete' if self.dry_run else 'deleting' self.logger.info('%s %s', action_text, instance) - n_deleted_items += 1 if not self.dry_run: - instance.delete() + pks_to_delete.add(instance.pk) + # Cleanup objects in batches instead of deleting each one individually. + if len(pks_to_delete) >= 500: + model.objects.filter(pk__in=pks_to_delete).delete() + n_deleted_items += len(pks_to_delete) + pks_to_delete.clear() + if len(pks_to_delete): + model.objects.filter(pk__in=pks_to_delete).delete() + n_deleted_items += len(pks_to_delete) return n_deleted_items def init_logging(self): diff --git a/awx/main/tests/ad_hoc.py b/awx/main/tests/ad_hoc.py index 2b729a17ba..b5155ed155 100644 --- a/awx/main/tests/ad_hoc.py +++ b/awx/main/tests/ad_hoc.py @@ -560,12 +560,14 @@ class AdHocCommandApiTest(BaseAdHocCommandTest): def test_ad_hoc_command_detail(self): with self.current_user('admin'): - response = self.run_test_ad_hoc_command() + response1 = self.run_test_ad_hoc_command() + response2 = self.run_test_ad_hoc_command() + response3 = self.run_test_ad_hoc_command() # Retrieve detail for ad hoc command. Only GET is supported. - url = reverse('api:ad_hoc_command_detail', args=(response['id'],)) - self.assertEqual(url, response['url']) with self.current_user('admin'): + url = reverse('api:ad_hoc_command_detail', args=(response1['id'],)) + self.assertEqual(url, response1['url']) response = self.get(url, expect=200) self.assertEqual(response['credential'], self.credential.pk) self.assertEqual(response['related']['credential'], @@ -580,22 +582,28 @@ class AdHocCommandApiTest(BaseAdHocCommandTest): self.assertTrue(response['related']['activity_stream']) self.put(url, {}, expect=405) self.patch(url, {}, expect=405) - self.delete(url, expect=405) + self.delete(url, expect=204) + self.delete(url, expect=404) with self.current_user('normal'): + url = reverse('api:ad_hoc_command_detail', args=(response2['id'],)) + self.assertEqual(url, response2['url']) response = self.get(url, expect=200) self.put(url, {}, expect=405) self.patch(url, {}, expect=405) - self.delete(url, expect=405) + self.delete(url, expect=204) + self.delete(url, expect=404) + url = reverse('api:ad_hoc_command_detail', args=(response3['id'],)) + self.assertEqual(url, response3['url']) with self.current_user('other'): response = self.get(url, expect=403) self.put(url, {}, expect=405) self.patch(url, {}, expect=405) - self.delete(url, expect=405) + self.delete(url, expect=403) with self.current_user('nobody'): response = self.get(url, expect=403) self.put(url, {}, expect=405) self.patch(url, {}, expect=405) - self.delete(url, expect=405) + self.delete(url, expect=403) with self.current_user(None): response = self.get(url, expect=401) self.put(url, {}, expect=401) @@ -603,13 +611,16 @@ class AdHocCommandApiTest(BaseAdHocCommandTest): self.delete(url, expect=401) # Verify that the credential and inventory are null when they have - # been deleted. + # been deleted, can delete an ad hoc command without inventory or + # credential. self.credential.mark_inactive() self.inventory.mark_inactive() with self.current_user('admin'): response = self.get(url, expect=200) self.assertEqual(response['credential'], None) self.assertEqual(response['inventory'], None) + self.delete(url, expect=204) + self.delete(url, expect=404) def test_ad_hoc_command_cancel(self): # Override setting so that ad hoc command isn't actually started. @@ -708,6 +719,21 @@ class AdHocCommandApiTest(BaseAdHocCommandTest): self.patch(url, {}, expect=401) self.delete(url, expect=401) + # Try to relaunch ad hoc command when module has been removed from + # allowed list of modules. + with self.settings(AD_HOC_COMMANDS=[]): + with self.current_user('admin'): + response = self.get(url, expect=200) + self.assertEqual(response['passwords_needed_to_start'], []) + response = self.post(url, {}, expect=400) + + # Try to relaunch after the inventory has been marked inactive. + self.inventory.mark_inactive() + with self.current_user('admin'): + response = self.get(url, expect=200) + self.assertEqual(response['passwords_needed_to_start'], []) + response = self.post(url, {}, expect=400) + def test_ad_hoc_command_events_list(self): with self.current_user('admin'): response = self.run_test_ad_hoc_command() diff --git a/awx/main/tests/commands/commands_monolithic.py b/awx/main/tests/commands/commands_monolithic.py index 8988bdae30..972ca8ac9b 100644 --- a/awx/main/tests/commands/commands_monolithic.py +++ b/awx/main/tests/commands/commands_monolithic.py @@ -252,6 +252,28 @@ class CleanupDeletedTest(BaseCommandMixin, BaseTest): self.assertNotEqual(counts_before, counts_after) self.assertFalse(sum(x[1] for x in counts_after.values())) + # Create lots of hosts already marked as deleted. + t = time.time() + dtnow = now() + for x in xrange(1000): + hostname = "_deleted_%s_host-%d" % (dtnow.isoformat(), x) + host = self.inventories[0].hosts.create(name=hostname, active=False) + create_elapsed = time.time() - t + + # Time how long it takes to cleanup deleted items, should be no more + # then the time taken to create them. + counts_before = self.get_model_counts() + self.assertTrue(sum(x[1] for x in counts_before.values())) + t = time.time() + result, stdout, stderr = self.run_command('cleanup_deleted', days=0) + cleanup_elapsed = time.time() - t + self.assertEqual(result, None) + counts_after = self.get_model_counts() + self.assertNotEqual(counts_before, counts_after) + self.assertFalse(sum(x[1] for x in counts_after.values())) + self.assertTrue(cleanup_elapsed < create_elapsed, + 'create took %0.3fs, cleanup took %0.3fs, expected < %0.3fs' % (create_elapsed, cleanup_elapsed, create_elapsed)) + def get_user_counts(self): active = User.objects.filter(is_active=True).count() inactive = User.objects.filter(is_active=False).count() diff --git a/awx/main/tests/fact/fact_api.py b/awx/main/tests/fact/fact_api.py index 55156d1436..7447f04e6d 100644 --- a/awx/main/tests/fact/fact_api.py +++ b/awx/main/tests/fact/fact_api.py @@ -93,7 +93,7 @@ class FactVersionApiTest(FactApiBaseTest): response = self.get(url, expect=200) for entry in response['results']: self.assertIn('fact_view', entry['related']) - r = self.get(entry['related']['fact_view'], expect=200) + self.get(entry['related']['fact_view'], expect=200) def test_list(self): self.setup_facts(2) @@ -207,7 +207,7 @@ class SingleFactApiTest(FactApiBaseTest): for entry in response['results']: self.assertIn('single_fact', entry['related']) # Requires fields - r = self.get(entry['related']['single_fact'], expect=400) + self.get(entry['related']['single_fact'], expect=400) def test_related_host_list(self): self.setup_facts(2) diff --git a/awx/main/tests/inventory.py b/awx/main/tests/inventory.py index 62741c1678..8771501062 100644 --- a/awx/main/tests/inventory.py +++ b/awx/main/tests/inventory.py @@ -1089,6 +1089,45 @@ class InventoryTest(BaseTest): self.assertEqual(set(h_e.all_groups.values_list('pk', flat=True)), set([g_e.pk])) + def test_dashboard_hosts_count(self): + url = reverse('api:dashboard_view') + + # Test with zero hosts. + with self.current_user(self.super_django_user): + response = self.get(url, expect=200) + self.assertEqual(response['hosts']['total'], 0) + self.assertEqual(response['hosts']['failed'], 0) + + # Create hosts with the same name in different inventories. + for x in xrange(4): + hostname = 'host-%d' % x + self.inventory_a.hosts.create(name=hostname) + self.inventory_b.hosts.create(name=hostname) + with self.current_user(self.super_django_user): + response = self.get(url, expect=200) + self.assertEqual(response['hosts']['total'], 4) + self.assertEqual(response['hosts']['failed'], 0) + + # Mark all hosts in one inventory as failed. Failed count should + # reflect unique hostnames. + for host in self.inventory_a.hosts.all(): + host.has_active_failures = True + host.save() + with self.current_user(self.super_django_user): + response = self.get(url, expect=200) + self.assertEqual(response['hosts']['total'], 4) + self.assertEqual(response['hosts']['failed'], 4) + + # Mark all hosts in the other inventory as failed. Failed count + # should reflect unique hostnames and never be greater than total. + for host in self.inventory_b.hosts.all(): + host.has_active_failures = True + host.save() + with self.current_user(self.super_django_user): + response = self.get(url, expect=200) + self.assertEqual(response['hosts']['total'], 4) + self.assertEqual(response['hosts']['failed'], 4) + def test_dashboard_inventory_graph_view(self): url = reverse('api:dashboard_inventory_graph_view') # Test with zero hosts. diff --git a/awx/ui/static/js/controllers/JobTemplates.js b/awx/ui/static/js/controllers/JobTemplates.js index 7563b987c4..443ab095ee 100644 --- a/awx/ui/static/js/controllers/JobTemplates.js +++ b/awx/ui/static/js/controllers/JobTemplates.js @@ -346,6 +346,25 @@ export function JobTemplatesAdd($scope, $rootScope, $compile, $location, $log, $ // this sets the default options for the selects as specified by the controller. $scope.verbosity = $scope.verbosity_options[$scope.verbosity_field.default]; $scope.job_type = $scope.job_type_options[$scope.job_type_field.default]; + + // if you're getting to the form from the scan job section on inventories, + // set the job type select to be scan + if ($routeParams.inventory_id) { + // This means that the job template form was accessed via inventory prop's + // This also means the job is a scan job. + $scope.job_type.value = 'scan'; + $scope.jobTypeChange(); + $scope.inventory = $routeParams.inventory_id; + Rest.setUrl(GetBasePath('inventory') + $routeParams.inventory_id + '/'); + Rest.get() + .success(function (data) { + $scope.inventory_name = data.name; + }) + .error(function (data, status) { + ProcessErrors($scope, data, status, form, { hdr: 'Error!', + msg: 'Failed to lookup inventory: ' + data.id + '. GET returned status: ' + status }); + }); + } $scope.$emit('lookUpInitialize'); } }); @@ -424,23 +443,6 @@ export function JobTemplatesAdd($scope, $rootScope, $compile, $location, $log, $ } }; - if ($routeParams.inventory_id) { - // This means that the job template form was accessed via inventory prop's - // This also means the job is a scan job. - $scope.job_type.value = 'scan'; - $scope.jobTypeChange(); - $scope.inventory = $routeParams.inventory_id; - Rest.setUrl(GetBasePath('inventory') + $routeParams.inventory_id + '/'); - Rest.get() - .success(function (data) { - $scope.inventory_name = data.name; - }) - .error(function (data, status) { - ProcessErrors($scope, data, status, form, { hdr: 'Error!', - msg: 'Failed to lookup inventory: ' + data.id + '. GET returned status: ' + status }); - }); - } - // Detect and alert user to potential SCM status issues checkSCMStatus = function (oldValue, newValue) { if (oldValue !== newValue && !Empty($scope.project)) { diff --git a/awx/ui/static/js/filters/sanitize/xss-sanitizer.filter.js b/awx/ui/static/js/filters/sanitize/xss-sanitizer.filter.js index 40689805d1..0ed767aacb 100644 --- a/awx/ui/static/js/filters/sanitize/xss-sanitizer.filter.js +++ b/awx/ui/static/js/filters/sanitize/xss-sanitizer.filter.js @@ -1,6 +1,6 @@ angular.module('sanitizeFilter', []).filter('sanitize', function() { return function(input) { - input = input.replace(//g, ">"); + input = input.replace(//g, ">").replace(/'/g, "'").replace(/"/g, """); return input; }; }); diff --git a/awx/ui/static/js/helpers/EventViewer.js b/awx/ui/static/js/helpers/EventViewer.js index 6e8c13802a..ff76bffc3d 100644 --- a/awx/ui/static/js/helpers/EventViewer.js +++ b/awx/ui/static/js/helpers/EventViewer.js @@ -437,8 +437,7 @@ export default else { if( typeof itm === "string"){ if(itm.indexOf('<') > -1 || itm.indexOf('>') > -1){ - itm = itm.replace(//g, ">"); + itm = $filter('sanitize')(itm); } } html += "" + itm + ""; @@ -547,15 +546,14 @@ export default }; }]) - .factory('EventAddPreFormattedText', [function() { + .factory('EventAddPreFormattedText', ['$filter', function($filter) { return function(params) { var id = params.id, val = params.val, html; if( typeof val === "string"){ if(val.indexOf('<') > -1 || val.indexOf('>') > -1){ - val = val.replace(//g, ">"); + val = $filter('sanitize')(val); } } html = "
" + val + "\n"; diff --git a/awx/ui/static/js/helpers/JobSubmission.js b/awx/ui/static/js/helpers/JobSubmission.js index f0d9f4bc83..3ec4b2f8ef 100644 --- a/awx/ui/static/js/helpers/JobSubmission.js +++ b/awx/ui/static/js/helpers/JobSubmission.js @@ -497,9 +497,9 @@ function($compile, Rest, GetBasePath, TextareaResize,CreateDialog, GenerateForm, }; }]) - .factory('PromptForSurvey', ['$compile', 'Wait', 'Alert', 'CredentialForm', 'CreateLaunchDialog', 'SurveyControllerInit' , 'GetBasePath', 'Rest' , 'Empty', + .factory('PromptForSurvey', ['$filter', '$compile', 'Wait', 'Alert', 'CredentialForm', 'CreateLaunchDialog', 'SurveyControllerInit' , 'GetBasePath', 'Rest' , 'Empty', 'GenerateForm', 'ShowSurveyModal', 'ProcessErrors', '$routeParams' , - function($compile, Wait, Alert, CredentialForm, CreateLaunchDialog, SurveyControllerInit, GetBasePath, Rest, Empty, + function($filter, $compile, Wait, Alert, CredentialForm, CreateLaunchDialog, SurveyControllerInit, GetBasePath, Rest, Empty, GenerateForm, ShowSurveyModal, ProcessErrors, $routeParams) { return function(params) { var html = params.html || "", @@ -519,10 +519,8 @@ function($compile, Rest, GetBasePath, TextareaResize,CreateDialog, GenerateForm, function buildHtml(question, index){ question.index = index; - question.question_name = question.question_name.replace(//g, ">"); - question.question_description = (question.question_description) ? question.question_description.replace(//g, ">") : undefined; + question.question_name = $filter('sanitize')(question.question_name); + question.question_description = (question.question_description) ? $filter('sanitize')(question.question_description) : undefined; requiredAsterisk = (question.required===true) ? "prepend-asterisk" : ""; @@ -603,8 +601,7 @@ function($compile, Rest, GetBasePath, TextareaResize,CreateDialog, GenerateForm, html+='