mirror of
https://github.com/ansible/awx.git
synced 2026-01-14 03:10:42 -03:30
Merge pull request #1536 from chrismeyersfsu/fix-protect_instance_groups
prevent instance group delete if running jobs
This commit is contained in:
commit
2640ef8b1c
18
awx/api/exceptions.py
Normal file
18
awx/api/exceptions.py
Normal 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
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -34,3 +34,4 @@ class _AwxTaskError():
|
||||
|
||||
AwxTaskError = _AwxTaskError()
|
||||
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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):
|
||||
'''
|
||||
|
||||
@ -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):
|
||||
'''
|
||||
|
||||
@ -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')]
|
||||
|
||||
|
||||
@ -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):
|
||||
'''
|
||||
|
||||
@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
73
awx/main/tests/functional/api/test_instance_group.py
Normal file
73
awx/main/tests/functional/api/test_instance_group.py
Normal 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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
12
awx/main/utils/polymorphic.py
Normal file
12
awx/main/utils/polymorphic.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user