Merge pull request #1536 from chrismeyersfsu/fix-protect_instance_groups

prevent instance group delete if running jobs
This commit is contained in:
Chris Meyers
2018-03-15 14:57:45 -04:00
committed by GitHub
15 changed files with 337 additions and 129 deletions

18
awx/api/exceptions.py Normal file
View File

@@ -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
})

View File

@@ -84,6 +84,7 @@ from awx.api.serializers import * # noqa
from awx.api.metadata import RoleMetadata, JobTypeMetadata from awx.api.metadata import RoleMetadata, JobTypeMetadata
from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.models.unified_jobs import ACTIVE_STATES
from awx.main.scheduler.tasks import run_job_complete from awx.main.scheduler.tasks import run_job_complete
from awx.api.exceptions import ActiveJobConflict
logger = logging.getLogger('awx.api.views') logger = logging.getLogger('awx.api.views')
@@ -194,6 +195,14 @@ class InstanceGroupMembershipMixin(object):
return response 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): class ApiRootView(APIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
@@ -637,7 +646,7 @@ class InstanceGroupList(ListCreateAPIView):
serializer_class = InstanceGroupSerializer serializer_class = InstanceGroupSerializer
class InstanceGroupDetail(RetrieveUpdateDestroyAPIView): class InstanceGroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
view_name = _("Instance Group Detail") view_name = _("Instance Group Detail")
model = InstanceGroup model = InstanceGroup
@@ -922,7 +931,7 @@ class OrganizationList(OrganizationCountsMixin, ListCreateAPIView):
return super(OrganizationList, self).create(request, *args, **kwargs) return super(OrganizationList, self).create(request, *args, **kwargs)
class OrganizationDetail(RetrieveUpdateDestroyAPIView): class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = Organization model = Organization
serializer_class = OrganizationSerializer serializer_class = OrganizationSerializer
@@ -1230,20 +1239,11 @@ class ProjectList(ListCreateAPIView):
return projects_qs return projects_qs
class ProjectDetail(RetrieveUpdateDestroyAPIView): class ProjectDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = Project model = Project
serializer_class = ProjectSerializer 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): class ProjectPlaybooks(RetrieveAPIView):
@@ -2038,7 +2038,7 @@ class ControlledByScmMixin(object):
return obj return obj
class InventoryDetail(ControlledByScmMixin, RetrieveUpdateDestroyAPIView): class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
model = Inventory model = Inventory
serializer_class = InventoryDetailSerializer serializer_class = InventoryDetailSerializer
@@ -2493,7 +2493,7 @@ class GroupActivityStreamList(ActivityStreamEnforcementMixin, SubListAPIView):
return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all())) return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all()))
class GroupDetail(ControlledByScmMixin, RetrieveUpdateDestroyAPIView): class GroupDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView):
model = Group model = Group
serializer_class = GroupSerializer serializer_class = GroupSerializer
@@ -2693,20 +2693,11 @@ class InventorySourceList(ListCreateAPIView):
return methods return methods
class InventorySourceDetail(RetrieveUpdateDestroyAPIView): class InventorySourceDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = InventorySource model = InventorySource
serializer_class = InventorySourceSerializer 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): class InventorySourceSchedulesList(SubListCreateAPIView):
@@ -2881,7 +2872,7 @@ class JobTemplateList(ListCreateAPIView):
return ret return ret
class JobTemplateDetail(RetrieveUpdateDestroyAPIView): class JobTemplateDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPIView):
model = JobTemplate model = JobTemplate
metadata_class = JobTypeMetadata metadata_class = JobTypeMetadata
@@ -3628,7 +3619,7 @@ class WorkflowJobTemplateList(WorkflowsEnforcementMixin, ListCreateAPIView):
always_allow_superuser = False always_allow_superuser = False
class WorkflowJobTemplateDetail(WorkflowsEnforcementMixin, RetrieveUpdateDestroyAPIView): class WorkflowJobTemplateDetail(RelatedJobsPreventDeleteMixin, WorkflowsEnforcementMixin, RetrieveUpdateDestroyAPIView):
model = WorkflowJobTemplate model = WorkflowJobTemplate
serializer_class = WorkflowJobTemplateSerializer serializer_class = WorkflowJobTemplateSerializer

View File

@@ -15,7 +15,7 @@ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
# Django REST Framework # Django REST Framework
from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError from rest_framework.exceptions import ParseError, PermissionDenied
# Django OAuth Toolkit # Django OAuth Toolkit
from awx.main.models.oauth import OAuth2Application, OAuth2AccessToken from awx.main.models.oauth import OAuth2Application, OAuth2AccessToken
@@ -28,14 +28,13 @@ from awx.main.utils import (
get_licenser, get_licenser,
) )
from awx.main.models import * # noqa from awx.main.models import * # noqa
from awx.main.models.unified_jobs import ACTIVE_STATES
from awx.main.models.mixins import ResourceMixin from awx.main.models.mixins import ResourceMixin
from awx.conf.license import LicenseForbids, feature_enabled from awx.conf.license import LicenseForbids, feature_enabled
__all__ = ['get_user_queryset', 'check_user_access', 'check_user_access_with_errors', __all__ = ['get_user_queryset', 'check_user_access', 'check_user_access_with_errors',
'user_accessible_objects', 'consumer_access', 'user_accessible_objects', 'consumer_access',
'user_admin_role', 'ActiveJobConflict',] 'user_admin_role',]
logger = logging.getLogger('awx.main.access') 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)) 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): def register_access(model_class, access_class):
access_registry[model_class] = access_class access_registry[model_class] = access_class
@@ -473,9 +462,6 @@ class InstanceGroupAccess(BaseAccess):
def can_change(self, obj, data): def can_change(self, obj, data):
return self.user.is_superuser return self.user.is_superuser
def can_delete(self, obj):
return self.user.is_superuser
class UserAccess(BaseAccess): class UserAccess(BaseAccess):
''' '''
@@ -658,15 +644,6 @@ class OrganizationAccess(BaseAccess):
is_change_possible = self.can_change(obj, None) is_change_possible = self.can_change(obj, None)
if not is_change_possible: if not is_change_possible:
return False 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 return True
def can_attach(self, obj, sub_obj, relationship, *args, **kwargs): def can_attach(self, obj, sub_obj, relationship, *args, **kwargs):
@@ -749,19 +726,7 @@ class InventoryAccess(BaseAccess):
return self.user in obj.update_role return self.user in obj.update_role
def can_delete(self, obj): def can_delete(self, obj):
is_can_admin = self.can_admin(obj, None) return 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
def can_run_ad_hoc_commands(self, obj): def can_run_ad_hoc_commands(self, obj):
return self.user in obj.adhoc_role return self.user in obj.adhoc_role
@@ -878,15 +843,7 @@ class GroupAccess(BaseAccess):
return True return True
def can_delete(self, obj): def can_delete(self, obj):
is_delete_allowed = bool(obj and self.user in obj.inventory.admin_role) return 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
def can_start(self, obj, validate_license=True): def can_start(self, obj, validate_license=True):
# TODO: Delete for 3.3, only used by v1 serializer # TODO: Delete for 3.3, only used by v1 serializer
@@ -935,9 +892,6 @@ class InventorySourceAccess(BaseAccess):
if not self.user.is_superuser and \ if not self.user.is_superuser and \
not (obj and obj.inventory and self.user.can_access(Inventory, 'admin', obj.inventory, None)): not (obj and obj.inventory and self.user.can_access(Inventory, 'admin', obj.inventory, None)):
return False 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 return True
@check_superuser @check_superuser
@@ -1199,23 +1153,13 @@ class ProjectAccess(BaseAccess):
return False return False
return self.user in obj.admin_role 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 @check_superuser
def can_start(self, obj, validate_license=True): def can_start(self, obj, validate_license=True):
return obj and self.user in obj.update_role return obj and self.user in obj.update_role
def can_delete(self, obj):
return self.can_change(obj, None)
class ProjectUpdateAccess(BaseAccess): class ProjectUpdateAccess(BaseAccess):
''' '''
@@ -1382,14 +1326,7 @@ class JobTemplateAccess(BaseAccess):
return True return True
def can_delete(self, obj): def can_delete(self, obj):
is_delete_allowed = self.user.is_superuser or self.user in obj.admin_role return 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
@check_superuser @check_superuser
def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): 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) self.user in obj.admin_role)
def can_delete(self, obj): def can_delete(self, obj):
is_delete_allowed = self.user.is_superuser or self.user in obj.admin_role return 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
class WorkflowJobAccess(BaseAccess): class WorkflowJobAccess(BaseAccess):

View File

@@ -34,3 +34,4 @@ class _AwxTaskError():
AwxTaskError = _AwxTaskError() AwxTaskError = _AwxTaskError()

View File

@@ -19,8 +19,12 @@ from awx.main.fields import JSONField
from awx.main.models.inventory import InventoryUpdate from awx.main.models.inventory import InventoryUpdate
from awx.main.models.jobs import Job from awx.main.models.jobs import Job
from awx.main.models.projects import ProjectUpdate 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.utils import get_cpu_capacity, get_mem_capacity, get_system_task_capacity
from awx.main.models.mixins import RelatedJobsMixin
__all__ = ('Instance', 'InstanceGroup', 'JobOrigin', 'TowerScheduleState',) __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.""" """A model representing a Queue/Group of AWX Instances."""
objects = InstanceGroupManager() objects = InstanceGroupManager()
@@ -152,6 +156,14 @@ class InstanceGroup(models.Model):
def capacity(self): def capacity(self):
return sum([inst.capacity for inst in self.instances.all()]) 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: class Meta:
app_label = 'main' app_label = 'main'

View File

@@ -33,7 +33,12 @@ from awx.main.managers import HostManager
from awx.main.models.base import * # noqa from awx.main.models.base import * # noqa
from awx.main.models.events import InventoryUpdateEvent from awx.main.models.events import InventoryUpdateEvent
from awx.main.models.unified_jobs import * # noqa 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 ( from awx.main.models.notifications import (
NotificationTemplate, NotificationTemplate,
JobNotificationMixin, JobNotificationMixin,
@@ -47,7 +52,7 @@ __all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate',
logger = logging.getLogger('awx.main.models.inventory') logger = logging.getLogger('awx.main.models.inventory')
class Inventory(CommonModelNameNotUnique, ResourceMixin): class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
''' '''
an inventory source contains lists and hosts. an inventory source contains lists and hosts.
''' '''
@@ -489,6 +494,19 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin):
self._update_host_smart_inventory_memeberships() self._update_host_smart_inventory_memeberships()
super(Inventory, self).delete(*args, **kwargs) 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): class SmartInventoryMembership(BaseModel):
''' '''
@@ -690,7 +708,7 @@ class Host(CommonModelNameNotUnique):
super(Host, self).delete(*args, **kwargs) 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 A group containing managed hosts. A group or host may belong to multiple
groups. groups.
@@ -942,6 +960,13 @@ class Group(CommonModelNameNotUnique):
from awx.main.models.ad_hoc_commands import AdHocCommand from awx.main.models.ad_hoc_commands import AdHocCommand
return AdHocCommand.objects.filter(hosts__in=self.all_hosts) 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): class InventorySourceOptions(BaseModel):
''' '''
@@ -1336,7 +1361,7 @@ class InventorySourceOptions(BaseModel):
return '' return ''
class InventorySource(UnifiedJobTemplate, InventorySourceOptions): class InventorySource(UnifiedJobTemplate, InventorySourceOptions, RelatedJobsMixin):
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'inventory')] 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.")) raise ValidationError(_("Cannot set source_path if not SCM type."))
return self.source_path 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): class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, TaskManagerInventoryUpdateMixin):
''' '''

View File

@@ -29,13 +29,21 @@ from awx.api.versioning import reverse
from awx.main.models.base import * # noqa from awx.main.models.base import * # noqa
from awx.main.models.events import JobEvent, SystemJobEvent from awx.main.models.events import JobEvent, SystemJobEvent
from awx.main.models.unified_jobs import * # noqa from awx.main.models.unified_jobs import * # noqa
from awx.main.models.unified_jobs import ACTIVE_STATES
from awx.main.models.notifications import ( from awx.main.models.notifications import (
NotificationTemplate, NotificationTemplate,
JobNotificationMixin, JobNotificationMixin,
) )
from awx.main.utils import parse_yaml_or_json from awx.main.utils import parse_yaml_or_json
from awx.main.fields import ImplicitRoleField 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 from awx.main.fields import JSONField, AskForField
@@ -216,7 +224,7 @@ class JobOptions(BaseModel):
return needed 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 A job template is a reusable job definition for applying a project (with
playbook) to an inventory source with a given credential. 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))) 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)) 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): class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskManagerJobMixin):
''' '''

View File

@@ -6,12 +6,14 @@ from copy import copy, deepcopy
import six import six
# Django # Django
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User # noqa from django.contrib.auth.models import User # noqa
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models.query import QuerySet
# AWX # AWX
from awx.main.models.base import prevent_search 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 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.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 from awx.main.fields import JSONField, AskForField
@@ -444,3 +447,28 @@ class CustomVirtualEnvMixin(models.Model):
if value: if value:
return os.path.join(value, '') return os.path.join(value, '')
return None 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')]

View File

@@ -6,6 +6,7 @@
# Django # Django
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import Q
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.sessions.models import Session from django.contrib.sessions.models import Session
from django.utils.timezone import now as tz_now 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_ADMINISTRATOR,
ROLE_SINGLETON_SYSTEM_AUDITOR, 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'] __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 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): def get_absolute_url(self, request=None):
return reverse('api:organization_detail', kwargs={'pk': self.pk}, request=request) 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): class Team(CommonModelNameNotUnique, ResourceMixin):
''' '''

View File

@@ -26,7 +26,12 @@ from awx.main.models.notifications import (
JobNotificationMixin, JobNotificationMixin,
) )
from awx.main.models.unified_jobs import * # noqa 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 import update_scm_url
from awx.main.utils.ansible import skip_directory, could_be_inventory, could_be_playbook from awx.main.utils.ansible import skip_directory, could_be_inventory, could_be_playbook
from awx.main.fields import ImplicitRoleField 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) 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. Internal job for tracking project updates from SCM.
''' '''
@@ -557,3 +562,16 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage
if not selected_groups: if not selected_groups:
return self.global_instance_groups return self.global_instance_groups
return selected_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)
)
)

View File

@@ -24,7 +24,13 @@ from awx.main.models.rbac import (
ROLE_SINGLETON_SYSTEM_AUDITOR ROLE_SINGLETON_SYSTEM_AUDITOR
) )
from awx.main.fields import ImplicitRoleField 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.jobs import LaunchTimeConfig
from awx.main.models.credential import Credential from awx.main.models.credential import Credential
from awx.main.redact import REPLACE_STR from awx.main.redact import REPLACE_STR
@@ -287,7 +293,7 @@ class WorkflowJobOptions(BaseModel):
return new_workflow_job return new_workflow_job
class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin): class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin, RelatedJobsMixin):
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
FIELDS_TO_PRESERVE_AT_COPY = [ FIELDS_TO_PRESERVE_AT_COPY = [
@@ -405,6 +411,12 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
node_list.append(node.pk) node_list.append(node.pk)
return node_list return node_list
'''
RelatedJobsMixin
'''
def _get_active_jobs(self):
return WorkflowJob.objects.filter(status__in=ACTIVE_STATES)
class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin): class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin):
class Meta: class Meta:

View File

@@ -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

View File

@@ -14,6 +14,40 @@ from awx.main.models import * # noqa
from awx.api.versioning import reverse 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 @pytest.mark.django_db
def test_organization_list_access_tests(options, head, get, admin, alice): def test_organization_list_access_tests(options, head, get, admin, alice):
options(reverse('api:organization_list'), user=admin, expect=200) 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}) url = reverse('api:organization_detail', kwargs={'pk': organization.id})
resp = patch(url, {'custom_virtualenv': value}, user=admin, expect=200) resp = patch(url, {'custom_virtualenv': value}, user=admin, expect=200)
assert resp.data['custom_virtualenv'] is None 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

View File

@@ -4,7 +4,6 @@ from awx.main.models import (
Host, Host,
CustomInventoryScript, CustomInventoryScript,
Schedule, Schedule,
AdHocCommand
) )
from awx.main.access import ( from awx.main.access import (
InventoryAccess, InventoryAccess,
@@ -13,18 +12,9 @@ from awx.main.access import (
InventoryUpdateAccess, InventoryUpdateAccess,
CustomInventoryScriptAccess, CustomInventoryScriptAccess,
ScheduleAccess, 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 @pytest.mark.django_db
def test_custom_inv_script_access(organization, user): def test_custom_inv_script_access(organization, user):
u = user('user', False) u = user('user', False)

View File

@@ -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