diff --git a/awx/api/exceptions.py b/awx/api/exceptions.py new file mode 100644 index 0000000000..0c67be279b --- /dev/null +++ b/awx/api/exceptions.py @@ -0,0 +1,18 @@ +# Copyright (c) 2018 Ansible by Red Hat +# All Rights Reserved. + +# Django +from django.utils.translation import ugettext_lazy as _ + +# Django REST Framework +from rest_framework.exceptions import ValidationError + + +class ActiveJobConflict(ValidationError): + status_code = 409 + + def __init__(self, active_jobs): + super(ActiveJobConflict, self).__init__({ + "error": _("Resource is being used by running jobs."), + "active_jobs": active_jobs + }) diff --git a/awx/api/views.py b/awx/api/views.py index e6d6e6074d..91df862f4c 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -84,6 +84,7 @@ from awx.api.serializers import * # noqa from awx.api.metadata import RoleMetadata, JobTypeMetadata from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.scheduler.tasks import run_job_complete +from awx.api.exceptions import ActiveJobConflict logger = logging.getLogger('awx.api.views') @@ -194,6 +195,14 @@ class InstanceGroupMembershipMixin(object): return response +class RelatedJobsPreventDeleteMixin(object): + def perform_destroy(self, obj): + active_jobs = obj.get_active_jobs() + if len(active_jobs) > 0: + raise ActiveJobConflict(active_jobs) + return super(RelatedJobsPreventDeleteMixin, self).perform_destroy(obj) + + class ApiRootView(APIView): permission_classes = (AllowAny,) @@ -637,7 +646,7 @@ class InstanceGroupList(ListCreateAPIView): serializer_class = InstanceGroupSerializer -class InstanceGroupDetail(RetrieveUpdateDestroyAPIView): +class InstanceGroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): view_name = _("Instance Group Detail") model = InstanceGroup @@ -922,7 +931,7 @@ class OrganizationList(OrganizationCountsMixin, ListCreateAPIView): return super(OrganizationList, self).create(request, *args, **kwargs) -class OrganizationDetail(RetrieveUpdateDestroyAPIView): +class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): model = Organization serializer_class = OrganizationSerializer @@ -1230,20 +1239,11 @@ class ProjectList(ListCreateAPIView): return projects_qs -class ProjectDetail(RetrieveUpdateDestroyAPIView): +class ProjectDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): model = Project serializer_class = ProjectSerializer - def destroy(self, request, *args, **kwargs): - obj = self.get_object() - can_delete = request.user.can_access(Project, 'delete', obj) - if not can_delete: - raise PermissionDenied(_("Cannot delete project.")) - for pu in obj.project_updates.filter(status__in=['new', 'pending', 'waiting', 'running']): - pu.cancel() - return super(ProjectDetail, self).destroy(request, *args, **kwargs) - class ProjectPlaybooks(RetrieveAPIView): @@ -2038,7 +2038,7 @@ class ControlledByScmMixin(object): return obj -class InventoryDetail(ControlledByScmMixin, RetrieveUpdateDestroyAPIView): +class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView): model = Inventory serializer_class = InventoryDetailSerializer @@ -2493,7 +2493,7 @@ class GroupActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView): return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all())) -class GroupDetail(ControlledByScmMixin, RetrieveUpdateDestroyAPIView): +class GroupDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView): model = Group serializer_class = GroupSerializer @@ -2693,20 +2693,11 @@ class InventorySourceList(ListCreateAPIView): return methods -class InventorySourceDetail(RetrieveUpdateDestroyAPIView): +class InventorySourceDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): model = InventorySource serializer_class = InventorySourceSerializer - def destroy(self, request, *args, **kwargs): - obj = self.get_object() - can_delete = request.user.can_access(InventorySource, 'delete', obj) - if not can_delete: - raise PermissionDenied(_("Cannot delete inventory source.")) - for pu in obj.inventory_updates.filter(status__in=['new', 'pending', 'waiting', 'running']): - pu.cancel() - return super(InventorySourceDetail, self).destroy(request, *args, **kwargs) - class InventorySourceSchedulesList(SubListCreateAPIView): @@ -2881,7 +2872,7 @@ class JobTemplateList(ListCreateAPIView): return ret -class JobTemplateDetail(RetrieveUpdateDestroyAPIView): +class JobTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView): model = JobTemplate metadata_class = JobTypeMetadata @@ -3628,7 +3619,7 @@ class WorkflowJobTemplateList(WorkflowsEnforcementMixin, ListCreateAPIView): always_allow_superuser = False -class WorkflowJobTemplateDetail(WorkflowsEnforcementMixin, RetrieveUpdateDestroyAPIView): +class WorkflowJobTemplateDetail(RelatedJobsPreventDeleteMixin, WorkflowsEnforcementMixin, RetrieveUpdateDestroyAPIView): model = WorkflowJobTemplate serializer_class = WorkflowJobTemplateSerializer diff --git a/awx/main/access.py b/awx/main/access.py index 7a052fc790..28991af3e0 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -15,7 +15,7 @@ from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ObjectDoesNotExist # Django REST Framework -from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError +from rest_framework.exceptions import ParseError, PermissionDenied # Django OAuth Toolkit from awx.main.models.oauth import OAuth2Application, OAuth2AccessToken @@ -28,14 +28,13 @@ from awx.main.utils import ( get_licenser, ) from awx.main.models import * # noqa -from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.models.mixins import ResourceMixin from awx.conf.license import LicenseForbids, feature_enabled __all__ = ['get_user_queryset', 'check_user_access', 'check_user_access_with_errors', 'user_accessible_objects', 'consumer_access', - 'user_admin_role', 'ActiveJobConflict',] + 'user_admin_role',] logger = logging.getLogger('awx.main.access') @@ -75,16 +74,6 @@ def get_object_from_data(field, Model, data, obj=None): raise ParseError(_("Bad data found in related field %s." % field)) -class ActiveJobConflict(ValidationError): - status_code = 409 - - def __init__(self, active_jobs): - super(ActiveJobConflict, self).__init__({ - "conflict": _("Resource is being used by running jobs."), - "active_jobs": active_jobs - }) - - def register_access(model_class, access_class): access_registry[model_class] = access_class @@ -473,9 +462,6 @@ class InstanceGroupAccess(BaseAccess): def can_change(self, obj, data): return self.user.is_superuser - def can_delete(self, obj): - return self.user.is_superuser - class UserAccess(BaseAccess): ''' @@ -658,15 +644,6 @@ class OrganizationAccess(BaseAccess): is_change_possible = self.can_change(obj, None) if not is_change_possible: return False - active_jobs = [] - active_jobs.extend([dict(type="job", id=o.id) - for o in Job.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)]) - active_jobs.extend([dict(type="project_update", id=o.id) - for o in ProjectUpdate.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)]) - active_jobs.extend([dict(type="inventory_update", id=o.id) - for o in InventoryUpdate.objects.filter(inventory_source__inventory__organization=obj, status__in=ACTIVE_STATES)]) - if len(active_jobs) > 0: - raise ActiveJobConflict(active_jobs) return True def can_attach(self, obj, sub_obj, relationship, *args, **kwargs): @@ -749,19 +726,7 @@ class InventoryAccess(BaseAccess): return self.user in obj.update_role def can_delete(self, obj): - is_can_admin = self.can_admin(obj, None) - if not is_can_admin: - return False - active_jobs = [] - active_jobs.extend([dict(type="job", id=o.id) - for o in Job.objects.filter(inventory=obj, status__in=ACTIVE_STATES)]) - active_jobs.extend([dict(type="inventory_update", id=o.id) - for o in InventoryUpdate.objects.filter(inventory_source__inventory=obj, status__in=ACTIVE_STATES)]) - active_jobs.extend([dict(type="ad_hoc_command", id=o.id) - for o in AdHocCommand.objects.filter(inventory=obj, status__in=ACTIVE_STATES)]) - if len(active_jobs) > 0: - raise ActiveJobConflict(active_jobs) - return True + return self.can_admin(obj, None) def can_run_ad_hoc_commands(self, obj): return self.user in obj.adhoc_role @@ -878,15 +843,7 @@ class GroupAccess(BaseAccess): return True def can_delete(self, obj): - is_delete_allowed = bool(obj and self.user in obj.inventory.admin_role) - if not is_delete_allowed: - return False - active_jobs = [] - active_jobs.extend([dict(type="inventory_update", id=o.id) - for o in InventoryUpdate.objects.filter(inventory_source__in=obj.inventory_sources.all(), status__in=ACTIVE_STATES)]) - if len(active_jobs) > 0: - raise ActiveJobConflict(active_jobs) - return True + return bool(obj and self.user in obj.inventory.admin_role) def can_start(self, obj, validate_license=True): # TODO: Delete for 3.3, only used by v1 serializer @@ -935,9 +892,6 @@ class InventorySourceAccess(BaseAccess): if not self.user.is_superuser and \ not (obj and obj.inventory and self.user.can_access(Inventory, 'admin', obj.inventory, None)): return False - active_jobs_qs = InventoryUpdate.objects.filter(inventory_source=obj, status__in=ACTIVE_STATES) - if active_jobs_qs.exists(): - raise ActiveJobConflict([dict(type="inventory_update", id=o.id) for o in active_jobs_qs.all()]) return True @check_superuser @@ -1199,23 +1153,13 @@ class ProjectAccess(BaseAccess): return False return self.user in obj.admin_role - def can_delete(self, obj): - is_change_allowed = self.can_change(obj, None) - if not is_change_allowed: - return False - active_jobs = [] - active_jobs.extend([dict(type="job", id=o.id) - for o in Job.objects.filter(project=obj, status__in=ACTIVE_STATES)]) - active_jobs.extend([dict(type="project_update", id=o.id) - for o in ProjectUpdate.objects.filter(project=obj, status__in=ACTIVE_STATES)]) - if len(active_jobs) > 0: - raise ActiveJobConflict(active_jobs) - return True - @check_superuser def can_start(self, obj, validate_license=True): return obj and self.user in obj.update_role + def can_delete(self, obj): + return self.can_change(obj, None) + class ProjectUpdateAccess(BaseAccess): ''' @@ -1382,14 +1326,7 @@ class JobTemplateAccess(BaseAccess): return True def can_delete(self, obj): - is_delete_allowed = self.user.is_superuser or self.user in obj.admin_role - if not is_delete_allowed: - return False - active_jobs = [dict(type="job", id=o.id) - for o in obj.jobs.filter(status__in=ACTIVE_STATES)] - if len(active_jobs) > 0: - raise ActiveJobConflict(active_jobs) - return True + return self.user.is_superuser or self.user in obj.admin_role @check_superuser def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): @@ -1890,14 +1827,7 @@ class WorkflowJobTemplateAccess(BaseAccess): self.user in obj.admin_role) def can_delete(self, obj): - is_delete_allowed = self.user.is_superuser or self.user in obj.admin_role - if not is_delete_allowed: - return False - active_jobs = [dict(type="workflow_job", id=o.id) - for o in obj.workflow_jobs.filter(status__in=ACTIVE_STATES)] - if len(active_jobs) > 0: - raise ActiveJobConflict(active_jobs) - return True + return self.user.is_superuser or self.user in obj.admin_role class WorkflowJobAccess(BaseAccess): diff --git a/awx/main/exceptions.py b/awx/main/exceptions.py index 5de5782244..9b3ee247e1 100644 --- a/awx/main/exceptions.py +++ b/awx/main/exceptions.py @@ -34,3 +34,4 @@ class _AwxTaskError(): AwxTaskError = _AwxTaskError() + diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index bf1d7f8266..9c7cdc5e2d 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -19,8 +19,12 @@ from awx.main.fields import JSONField from awx.main.models.inventory import InventoryUpdate from awx.main.models.jobs import Job from awx.main.models.projects import ProjectUpdate -from awx.main.models.unified_jobs import UnifiedJob +from awx.main.models.unified_jobs import ( + UnifiedJob, + ACTIVE_STATES +) from awx.main.utils import get_cpu_capacity, get_mem_capacity, get_system_task_capacity +from awx.main.models.mixins import RelatedJobsMixin __all__ = ('Instance', 'InstanceGroup', 'JobOrigin', 'TowerScheduleState',) @@ -110,7 +114,7 @@ class Instance(models.Model): -class InstanceGroup(models.Model): +class InstanceGroup(models.Model, RelatedJobsMixin): """A model representing a Queue/Group of AWX Instances.""" objects = InstanceGroupManager() @@ -152,6 +156,14 @@ class InstanceGroup(models.Model): def capacity(self): return sum([inst.capacity for inst in self.instances.all()]) + ''' + RelatedJobsMixin + ''' + def _get_active_jobs(self): + return UnifiedJob.objects.filter(instance_group=self, + status__in=ACTIVE_STATES) + + class Meta: app_label = 'main' diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 027b24170e..0a2c254571 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -33,7 +33,12 @@ from awx.main.managers import HostManager from awx.main.models.base import * # noqa from awx.main.models.events import InventoryUpdateEvent from awx.main.models.unified_jobs import * # noqa -from awx.main.models.mixins import ResourceMixin, TaskManagerInventoryUpdateMixin +from awx.main.models.unified_jobs import ACTIVE_STATES +from awx.main.models.mixins import ( + ResourceMixin, + TaskManagerInventoryUpdateMixin, + RelatedJobsMixin, +) from awx.main.models.notifications import ( NotificationTemplate, JobNotificationMixin, @@ -47,7 +52,7 @@ __all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', logger = logging.getLogger('awx.main.models.inventory') -class Inventory(CommonModelNameNotUnique, ResourceMixin): +class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): ''' an inventory source contains lists and hosts. ''' @@ -489,6 +494,19 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin): self._update_host_smart_inventory_memeberships() super(Inventory, self).delete(*args, **kwargs) + ''' + RelatedJobsMixin + ''' + def _get_active_jobs(self): + return UnifiedJob.objects.non_polymorphic().filter( + Q(status__in=ACTIVE_STATES) & + ( + Q(Job___inventory=self) | + Q(InventoryUpdate___inventory_source__inventory=self) | + Q(AdHocCommand___inventory=self) + ) + ) + class SmartInventoryMembership(BaseModel): ''' @@ -690,7 +708,7 @@ class Host(CommonModelNameNotUnique): super(Host, self).delete(*args, **kwargs) -class Group(CommonModelNameNotUnique): +class Group(CommonModelNameNotUnique, RelatedJobsMixin): ''' A group containing managed hosts. A group or host may belong to multiple groups. @@ -942,6 +960,13 @@ class Group(CommonModelNameNotUnique): from awx.main.models.ad_hoc_commands import AdHocCommand return AdHocCommand.objects.filter(hosts__in=self.all_hosts) + ''' + RelatedJobsMixin + ''' + def _get_active_jobs(self): + return InventoryUpdate.objects.filter(status__in=ACTIVE_STATES, + inventory_source__in=self.inventory_sources.all()) + class InventorySourceOptions(BaseModel): ''' @@ -1336,7 +1361,7 @@ class InventorySourceOptions(BaseModel): return '' -class InventorySource(UnifiedJobTemplate, InventorySourceOptions): +class InventorySource(UnifiedJobTemplate, InventorySourceOptions, RelatedJobsMixin): SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'inventory')] @@ -1555,6 +1580,13 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions): raise ValidationError(_("Cannot set source_path if not SCM type.")) return self.source_path + ''' + RelatedJobsMixin + ''' + def _get_active_jobs(self): + return InventoryUpdate.objects.filter(status__in=ACTIVE_STATES, + inventory_source=self) + class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, TaskManagerInventoryUpdateMixin): ''' diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 43b74a84a7..6bae4b6a78 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -29,13 +29,21 @@ from awx.api.versioning import reverse from awx.main.models.base import * # noqa from awx.main.models.events import JobEvent, SystemJobEvent from awx.main.models.unified_jobs import * # noqa +from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.models.notifications import ( NotificationTemplate, JobNotificationMixin, ) from awx.main.utils import parse_yaml_or_json from awx.main.fields import ImplicitRoleField -from awx.main.models.mixins import ResourceMixin, SurveyJobTemplateMixin, SurveyJobMixin, TaskManagerJobMixin, CustomVirtualEnvMixin +from awx.main.models.mixins import ( + ResourceMixin, + SurveyJobTemplateMixin, + SurveyJobMixin, + TaskManagerJobMixin, + CustomVirtualEnvMixin, + RelatedJobsMixin, +) from awx.main.fields import JSONField, AskForField @@ -216,7 +224,7 @@ class JobOptions(BaseModel): return needed -class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin): +class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin): ''' A job template is a reusable job definition for applying a project (with playbook) to an inventory source with a given credential. @@ -444,6 +452,12 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour organization_notification_templates_for_any=self.project.organization))) return dict(error=list(error_notification_templates), success=list(success_notification_templates), any=list(any_notification_templates)) + ''' + RelatedJobsMixin + ''' + def _get_active_jobs(self): + return Job.objects.filter(status__in=ACTIVE_STATES) + class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskManagerJobMixin): ''' diff --git a/awx/main/models/mixins.py b/awx/main/models/mixins.py index ddbf16243b..338877874c 100644 --- a/awx/main/models/mixins.py +++ b/awx/main/models/mixins.py @@ -6,12 +6,14 @@ from copy import copy, deepcopy import six # Django +from django.apps import apps from django.conf import settings from django.db import models from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User # noqa from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ValidationError +from django.db.models.query import QuerySet # AWX from awx.main.models.base import prevent_search @@ -20,6 +22,7 @@ from awx.main.models.rbac import ( ) from awx.main.utils import parse_yaml_or_json, get_custom_venv_choices from awx.main.utils.encryption import decrypt_value, get_encryption_key, is_encrypted +from awx.main.utils.polymorphic import build_polymorphic_ctypes_map from awx.main.fields import JSONField, AskForField @@ -444,3 +447,28 @@ class CustomVirtualEnvMixin(models.Model): if value: return os.path.join(value, '') return None + + +class RelatedJobsMixin(object): + + ''' + This method is intended to be overwritten. + Called by get_active_jobs() + Returns a list of active jobs (i.e. running) associated with the calling + resource (self). Expected to return a QuerySet + ''' + def _get_active_jobs(self): + return [] + + ''' + Returns [{'id': '1', 'type': 'job'}, {'id': 2, 'type': 'project_update'}, ...] + ''' + def get_active_jobs(self): + UnifiedJob = apps.get_model('main', 'UnifiedJob') + mapping = build_polymorphic_ctypes_map(UnifiedJob) + jobs = self._get_active_jobs() + 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')] + diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 4973fa4778..14c50e2366 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -6,6 +6,7 @@ # Django from django.conf import settings from django.db import models +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 @@ -19,12 +20,16 @@ from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_ADMINISTRATOR, ROLE_SINGLETON_SYSTEM_AUDITOR, ) -from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin +from awx.main.models.unified_jobs import ( + UnifiedJob, + ACTIVE_STATES, +) +from awx.main.models.mixins import ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin __all__ = ['Organization', 'Team', 'Profile', 'UserSessionMembership'] -class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin): +class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVirtualEnvMixin, RelatedJobsMixin): ''' An organization is the basic unit of multi-tenancy divisions ''' @@ -74,6 +79,20 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi def get_absolute_url(self, request=None): return reverse('api:organization_detail', kwargs={'pk': self.pk}, request=request) + ''' + RelatedJobsMixin + ''' + def _get_active_jobs(self): + project_ids = self.projects.all().values_list('id') + return UnifiedJob.objects.non_polymorphic().filter( + Q(status__in=ACTIVE_STATES) & + ( + Q(Job___project__in=project_ids) | + Q(ProjectUpdate___project__in=project_ids) | + Q(InventoryUpdate___inventory_source__inventory__organization=self) + ) + ) + class Team(CommonModelNameNotUnique, ResourceMixin): ''' diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 06156d95f3..8c7ee0db35 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -26,7 +26,12 @@ from awx.main.models.notifications import ( JobNotificationMixin, ) from awx.main.models.unified_jobs import * # noqa -from awx.main.models.mixins import ResourceMixin, TaskManagerProjectUpdateMixin, CustomVirtualEnvMixin +from awx.main.models.mixins import ( + ResourceMixin, + TaskManagerProjectUpdateMixin, + CustomVirtualEnvMixin, + RelatedJobsMixin +) from awx.main.utils import update_scm_url from awx.main.utils.ansible import skip_directory, could_be_inventory, could_be_playbook from awx.main.fields import ImplicitRoleField @@ -443,7 +448,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn return reverse('api:project_detail', kwargs={'pk': self.pk}, request=request) -class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManagerProjectUpdateMixin): +class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManagerProjectUpdateMixin, RelatedJobsMixin): ''' Internal job for tracking project updates from SCM. ''' @@ -557,3 +562,16 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage if not selected_groups: return self.global_instance_groups return selected_groups + + ''' + RelatedJobsMixin + ''' + def _get_active_jobs(self): + return UnifiedJob.objects.non_polymorphic().filter( + Q(status__in=ACTIVE_STATES) & + ( + Q(Job___project=self) | + Q(ProjectUpdate___project=self) + ) + ) + diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 5c582683c6..73a56a2dea 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -24,7 +24,13 @@ from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_AUDITOR ) from awx.main.fields import ImplicitRoleField -from awx.main.models.mixins import ResourceMixin, SurveyJobTemplateMixin, SurveyJobMixin +from awx.main.models.mixins import ( + ResourceMixin, + SurveyJobTemplateMixin, + SurveyJobMixin, + RelatedJobsMixin, +) +from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.models.jobs import LaunchTimeConfig from awx.main.models.credential import Credential from awx.main.redact import REPLACE_STR @@ -287,7 +293,7 @@ class WorkflowJobOptions(BaseModel): return new_workflow_job -class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin): +class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin): SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] FIELDS_TO_PRESERVE_AT_COPY = [ @@ -405,6 +411,12 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl node_list.append(node.pk) return node_list + ''' + RelatedJobsMixin + ''' + def _get_active_jobs(self): + return WorkflowJob.objects.filter(status__in=ACTIVE_STATES) + class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin): class Meta: diff --git a/awx/main/tests/functional/api/test_instance_group.py b/awx/main/tests/functional/api/test_instance_group.py new file mode 100644 index 0000000000..7f5c8d5ba8 --- /dev/null +++ b/awx/main/tests/functional/api/test_instance_group.py @@ -0,0 +1,73 @@ +import pytest + +from awx.api.versioning import reverse +from awx.main.models import ( + InstanceGroup, + ProjectUpdate, +) + + +@pytest.fixture +def instance_group(job_factory): + ig = InstanceGroup(name="east") + ig.save() + return ig + + +@pytest.fixture +def create_job_factory(job_factory, instance_group): + def fn(status='running'): + j = job_factory() + j.status = status + j.instance_group = instance_group + j.save() + return j + return fn + + +@pytest.fixture +def create_project_update_factory(instance_group, project): + def fn(status='running'): + pu = ProjectUpdate(project=project) + pu.status = status + pu.instance_group = instance_group + pu.save() + return pu + return fn + + +@pytest.fixture +def instance_group_jobs_running(instance_group, create_job_factory, create_project_update_factory): + jobs_running = [create_job_factory(status='running') for i in xrange(0, 2)] + project_updates_running = [create_project_update_factory(status='running') for i in xrange(0, 2)] + return jobs_running + project_updates_running + + +@pytest.fixture +def instance_group_jobs_successful(instance_group, create_job_factory, create_project_update_factory): + jobs_successful = [create_job_factory(status='successful') for i in xrange(0, 2)] + project_updates_successful = [create_project_update_factory(status='successful') for i in xrange(0, 2)] + return jobs_successful + project_updates_successful + + +@pytest.mark.django_db +def test_delete_instance_group_jobs(delete, instance_group_jobs_successful, instance_group, admin): + url = reverse("api:instance_group_detail", kwargs={'pk': instance_group.pk}) + delete(url, None, admin, expect=204) + + +@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']) + + 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] + response_sorted = sorted(response.data['active_jobs'], key=sort_keys) + expect_sorted = sorted(expect_transformed, key=sort_keys) + + assert response.data['error'] == u"Resource is being used by running jobs." + assert response_sorted == expect_sorted + diff --git a/awx/main/tests/functional/api/test_organizations.py b/awx/main/tests/functional/api/test_organizations.py index 54d7df5fd2..9ec6787d53 100644 --- a/awx/main/tests/functional/api/test_organizations.py +++ b/awx/main/tests/functional/api/test_organizations.py @@ -14,6 +14,40 @@ from awx.main.models import * # noqa from awx.api.versioning import reverse +@pytest.fixture +def create_job_factory(job_factory, project): + def fn(status='running'): + j = job_factory() + j.status = status + j.project = project + j.save() + return j + return fn + + +@pytest.fixture +def create_project_update_factory(organization, project): + def fn(status='running'): + pu = ProjectUpdate(project=project) + pu.status = status + pu.organization = organization + pu.save() + return pu + return fn + + +@pytest.fixture +def organization_jobs_successful(create_job_factory, create_project_update_factory): + return [create_job_factory(status='successful') for i in xrange(0, 2)] + \ + [create_project_update_factory(status='successful') for i in xrange(0, 2)] + + +@pytest.fixture +def organization_jobs_running(create_job_factory, create_project_update_factory): + return [create_job_factory(status='running') for i in xrange(0, 2)] + \ + [create_project_update_factory(status='running') for i in xrange(0, 2)] + + @pytest.mark.django_db def test_organization_list_access_tests(options, head, get, admin, alice): options(reverse('api:organization_list'), user=admin, expect=200) @@ -215,3 +249,27 @@ def test_organization_unset_custom_virtualenv(get, patch, organization, admin, v url = reverse('api:organization_detail', kwargs={'pk': organization.id}) resp = patch(url, {'custom_virtualenv': value}, user=admin, expect=200) assert resp.data['custom_virtualenv'] is None + + +@pytest.mark.django_db +def test_organization_delete(delete, admin, organization, organization_jobs_successful): + url = reverse('api:organization_detail', kwargs={'pk': organization.id}) + delete(url, None, user=admin, expect=204) + + +@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']) + + 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] + resp_sorted = sorted(resp.data['active_jobs'], key=sort_keys) + expect_sorted = sorted(expect_transformed, key=sort_keys) + + assert resp.data['error'] == u"Resource is being used by running jobs." + assert resp_sorted == expect_sorted + + diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index fd195b10b3..dd471ebf3f 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -4,7 +4,6 @@ from awx.main.models import ( Host, CustomInventoryScript, Schedule, - AdHocCommand ) from awx.main.access import ( InventoryAccess, @@ -13,18 +12,9 @@ from awx.main.access import ( InventoryUpdateAccess, CustomInventoryScriptAccess, ScheduleAccess, - ActiveJobConflict ) -@pytest.mark.django_db -def test_running_job_protection(inventory, admin_user): - AdHocCommand.objects.create(inventory=inventory, status='running') - access = InventoryAccess(admin_user) - with pytest.raises(ActiveJobConflict): - access.can_delete(inventory) - - @pytest.mark.django_db def test_custom_inv_script_access(organization, user): u = user('user', False) diff --git a/awx/main/utils/polymorphic.py b/awx/main/utils/polymorphic.py new file mode 100644 index 0000000000..4eabba213e --- /dev/null +++ b/awx/main/utils/polymorphic.py @@ -0,0 +1,12 @@ + +from django.contrib.contenttypes.models import ContentType + + +def build_polymorphic_ctypes_map(cls): + # {'1': 'unified_job', '2': 'Job', '3': 'project_update', ...} + mapping = {} + for ct in ContentType.objects.filter(app_label='main'): + ct_model_class = ct.model_class() + if ct_model_class and issubclass(ct_model_class, cls): + mapping[ct.id] = ct_model_class._camel_to_underscore(ct_model_class.__name__) + return mapping