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
commit 2640ef8b1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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.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

View File

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

View File

@ -34,3 +34,4 @@ class _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.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'

View File

@ -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):
'''

View File

@ -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):
'''

View File

@ -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')]

View File

@ -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):
'''

View File

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

View File

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

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

View File

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

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