mirror of
https://github.com/ansible/awx.git
synced 2026-01-19 21:51:26 -03:30
Basic support for job start/cancel via REST API with supporting tests.
This commit is contained in:
parent
a20573a68d
commit
fe5a454441
@ -517,6 +517,10 @@ class JobAccess(BaseAccess):
|
||||
|
||||
model = Job
|
||||
|
||||
def can_change(self, obj, data):
|
||||
print 'CAN_CHANGE', obj, data
|
||||
return self.user.is_superuser and obj.status == 'new'
|
||||
|
||||
def can_start(self, obj):
|
||||
return False # FIXME
|
||||
|
||||
|
||||
@ -359,9 +359,9 @@ class JobAdmin(BaseModelAdmin):
|
||||
fsets = [fs for fs in fsets if
|
||||
'created' not in fs[1]['fields'] and
|
||||
'celery_task_id' not in fs[1]['fields']]
|
||||
if not obj or (obj and obj.pk and obj.status != 'new'):
|
||||
if not obj or (obj and obj.pk and not obj.can_start):
|
||||
fsets = [fs for fs in fsets if 'start_job' not in fs[1]['fields']]
|
||||
if not obj or (obj and obj.pk and obj.status not in ('pending', 'running')):
|
||||
if not obj or (obj and obj.pk and not obj.can_cancel):
|
||||
for fs in fsets:
|
||||
if 'celery_task_id' in fs[1]['fields']:
|
||||
fs[1]['fields'] = ('status', 'get_result_stdout_display',
|
||||
|
||||
@ -115,7 +115,7 @@ class JobAdminForm(JobTemplateAdminForm):
|
||||
if self.instance.pk and self.instance.status != 'new':
|
||||
self.fields.pop('playbook', None)
|
||||
if (not self.data or self.data.get('start_job', '')) and \
|
||||
self.instance.credential and self.instance.status == 'new':
|
||||
self.instance.credential and self.instance.can_start:
|
||||
for field in self.instance.get_passwords_needed_to_start():
|
||||
if field not in self.fields:
|
||||
continue
|
||||
@ -128,7 +128,7 @@ class JobAdminForm(JobTemplateAdminForm):
|
||||
return self.cleaned_data.get('cancel_job', False)
|
||||
|
||||
def clean(self):
|
||||
if self.instance.credential and self.instance.status == 'new':
|
||||
if self.instance.credential and self.instance.can_start:
|
||||
for field in self.instance.get_passwords_needed_to_start():
|
||||
if field in self.fields:
|
||||
self.fields[field].required = True
|
||||
@ -138,7 +138,7 @@ class JobAdminForm(JobTemplateAdminForm):
|
||||
instance = super(JobAdminForm, self).save(commit)
|
||||
save_m2m = getattr(self, 'save_m2m', lambda: None)
|
||||
should_start = bool(self.cleaned_data.get('start_job', '') and
|
||||
instance.status == 'new')
|
||||
instance.can_start)
|
||||
start_opts = {}
|
||||
for field in ('ssh_password', 'sudo_password', 'ssh_key_unlock'):
|
||||
value = self.cleaned_data.get(field, '')
|
||||
@ -146,7 +146,7 @@ class JobAdminForm(JobTemplateAdminForm):
|
||||
start_opts[field] = value
|
||||
#print 'should_start', should_start
|
||||
should_cancel = bool(self.cleaned_data.get('cancel_job', '') and
|
||||
instance.status in ('new', 'pending'))
|
||||
instance.can_cancel)
|
||||
def new_save_m2m():
|
||||
save_m2m()
|
||||
if should_start:
|
||||
|
||||
@ -644,6 +644,7 @@ class Job(CommonModel):
|
||||
)
|
||||
failed = models.BooleanField(
|
||||
default=False,
|
||||
editable=False,
|
||||
)
|
||||
result_stdout = models.TextField(
|
||||
blank=True,
|
||||
@ -703,9 +704,13 @@ class Job(CommonModel):
|
||||
needed.append(field)
|
||||
return needed
|
||||
|
||||
@property
|
||||
def can_start(self):
|
||||
return bool(self.status == 'new')
|
||||
|
||||
def start(self, **kwargs):
|
||||
from lib.main.tasks import RunJob
|
||||
if self.status != 'new':
|
||||
if not self.can_start:
|
||||
return False
|
||||
needed = self.get_passwords_needed_to_start()
|
||||
opts = dict([(field, kwargs.get(field, '')) for field in needed])
|
||||
@ -720,8 +725,12 @@ class Job(CommonModel):
|
||||
self.save(update_fields=['celery_task_id'])
|
||||
return True
|
||||
|
||||
@property
|
||||
def can_cancel(self):
|
||||
return bool(self.status in ('pending', 'running'))
|
||||
|
||||
def cancel(self):
|
||||
if self.status in ('pending', 'running'):
|
||||
if self.can_cancel:
|
||||
if not self.cancel_flag:
|
||||
self.cancel_flag = True
|
||||
self.save(update_fields=['cancel_flag'])
|
||||
|
||||
@ -70,6 +70,8 @@ class CustomRbac(permissions.BasePermission):
|
||||
return check_user_access(request.user, view.model, 'delete', obj)
|
||||
|
||||
def _check_permissions(self, request, view, obj=None):
|
||||
#if not obj and hasattr(view, 'get_object'):
|
||||
# obj = view.get_object()
|
||||
# Check that obj (if given) is active, otherwise raise a 404.
|
||||
active = getattr(obj, 'active', getattr(obj, 'is_active', True))
|
||||
if callable(active):
|
||||
@ -81,7 +83,7 @@ class CustomRbac(permissions.BasePermission):
|
||||
return False
|
||||
# Don't allow inactive users (and respond with a 403).
|
||||
if not request.user.is_active:
|
||||
raise PermissionDenied()
|
||||
raise PermissionDenied('your account is inactive')
|
||||
# Always allow superusers (as long as they are active).
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
|
||||
@ -407,6 +407,10 @@ class JobSerializer(BaseSerializer):
|
||||
))
|
||||
if obj.job_template:
|
||||
res['job_template'] = reverse('main:job_template_detail', args=(obj.job_template.pk,))
|
||||
if obj.can_start or True:
|
||||
res['start'] = reverse('main:job_start', args=(obj.pk,))
|
||||
if obj.can_cancel or True:
|
||||
res['cancel'] = reverse('main:job_cancel', args=(obj.pk,))
|
||||
return res
|
||||
|
||||
def from_native(self, data, files):
|
||||
|
||||
@ -203,6 +203,9 @@ class BaseTestMixin(object):
|
||||
def put(self, url, data, expect=200, auth=None):
|
||||
return self._generic_rest(url, data=data, expect=expect, auth=auth, method='put')
|
||||
|
||||
def patch(self, url, data, expect=200, auth=None):
|
||||
return self._generic_rest(url, data=data, expect=expect, auth=auth, method='patch')
|
||||
|
||||
def delete(self, url, expect=201, auth=None):
|
||||
return self._generic_rest(url, data=None, expect=expect, auth=auth, method='delete')
|
||||
|
||||
|
||||
@ -18,11 +18,13 @@ import datetime
|
||||
import json
|
||||
from django.contrib.auth.models import User as DjangoUser
|
||||
from django.core.urlresolvers import reverse
|
||||
import django.test
|
||||
from django.test.client import Client
|
||||
from django.test.utils import override_settings
|
||||
from lib.main.models import *
|
||||
from lib.main.tests.base import BaseTest
|
||||
from lib.main.tests.base import BaseTestMixin
|
||||
|
||||
__all__ = ['JobTemplateTest', 'JobTest']
|
||||
__all__ = ['JobTemplateTest', 'JobTest', 'JobStartCancelTest']
|
||||
|
||||
TEST_PLAYBOOK = '''- hosts: all
|
||||
gather_facts: false
|
||||
@ -31,7 +33,7 @@ TEST_PLAYBOOK = '''- hosts: all
|
||||
command: test 1 = 1
|
||||
'''
|
||||
|
||||
class BaseJobTest(BaseTest):
|
||||
class BaseJobTestMixin(BaseTestMixin):
|
||||
''''''
|
||||
|
||||
def _create_inventory(self, name, organization, created_by,
|
||||
@ -273,7 +275,7 @@ class BaseJobTest(BaseTest):
|
||||
)
|
||||
self.cred_iris = self.user_iris.credentials.create(
|
||||
ssh_username='iris',
|
||||
ssh_password='',
|
||||
ssh_password='ASK',
|
||||
created_by=self.user_sue,
|
||||
)
|
||||
|
||||
@ -414,15 +416,9 @@ class BaseJobTest(BaseTest):
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
super(BaseJobTest, self).setUp()
|
||||
super(BaseJobTestMixin, self).setUp()
|
||||
self.populate()
|
||||
|
||||
|
||||
class JobTemplateTest(BaseJobTest):
|
||||
|
||||
def setUp(self):
|
||||
super(JobTemplateTest, self).setUp()
|
||||
|
||||
def _test_invalid_creds(self, url, data=None, methods=None):
|
||||
data = data or {}
|
||||
methods = methods or ('options', 'head', 'get')
|
||||
@ -430,11 +426,13 @@ class JobTemplateTest(BaseJobTest):
|
||||
with self.current_user(*auth):
|
||||
for method in methods:
|
||||
f = getattr(self, method)
|
||||
if method in ('post', 'put'):
|
||||
if method in ('post', 'put', 'patch'):
|
||||
f(url, data, expect=401)
|
||||
else:
|
||||
f(url, expect=401)
|
||||
|
||||
class JobTemplateTest(BaseJobTestMixin, django.test.TestCase):
|
||||
|
||||
def test_get_job_template_list(self):
|
||||
url = reverse('main:job_template_list')
|
||||
|
||||
@ -556,12 +554,15 @@ class JobTemplateTest(BaseJobTest):
|
||||
url = reverse('main:job_template_detail', args=(jt.pk,))
|
||||
|
||||
# Test with no auth and with invalid login.
|
||||
self._test_invalid_creds(url, methods=('put',))
|
||||
self._test_invalid_creds(url, methods=('put',))# 'patch'))
|
||||
|
||||
# sue can update the job template detail.
|
||||
with self.current_user(self.user_sue):
|
||||
data = self.get(url)
|
||||
data['name'] = '%s-updated' % data['name']
|
||||
response = self.put(url, data)
|
||||
#patch_data = dict(name='%s-changed' % data['name'])
|
||||
#response = self.patch(url, patch_data)
|
||||
|
||||
# FIXME: Check other credentials and optional fields.
|
||||
|
||||
@ -600,40 +601,100 @@ class JobTemplateTest(BaseJobTest):
|
||||
|
||||
# FIXME: Check other credentials and optional fields.
|
||||
|
||||
class JobTest(BaseJobTest):
|
||||
|
||||
def setUp(self):
|
||||
super(JobTest, self).setUp()
|
||||
class JobTest(BaseJobTestMixin, django.test.TestCase):
|
||||
|
||||
def test_get_job_list(self):
|
||||
pass
|
||||
url = reverse('main:job_list')
|
||||
|
||||
# Test with no auth and with invalid login.
|
||||
self._test_invalid_creds(url)
|
||||
|
||||
# sue's credentials (superuser) == 200, full list
|
||||
with self.current_user(self.user_sue):
|
||||
self.options(url)
|
||||
self.head(url)
|
||||
response = self.get(url)
|
||||
qs = Job.objects.all()
|
||||
self.check_pagination_and_size(response, qs.count())
|
||||
self.check_list_ids(response, qs)
|
||||
|
||||
# FIXME: Check individual job result fields.
|
||||
# FIXME: Check with other credentials.
|
||||
|
||||
def test_post_job_list(self):
|
||||
pass
|
||||
url = reverse('main:job_list')
|
||||
data = dict(
|
||||
name='new job without template',
|
||||
job_type=PERM_INVENTORY_DEPLOY,
|
||||
inventory=self.inv_ops_east.pk,
|
||||
project=self.proj_prod.pk,
|
||||
playbook=self.proj_prod.playbooks[0],
|
||||
credential=self.cred_ops_east.pk,
|
||||
)
|
||||
|
||||
# Test with no auth and with invalid login.
|
||||
self._test_invalid_creds(url, data, methods=('post',))
|
||||
|
||||
# sue can create a new job without a template.
|
||||
with self.current_user(self.user_sue):
|
||||
response = self.post(url, data, expect=201)
|
||||
|
||||
# sue can also create a job here from a template
|
||||
jt = self.jt_ops_east_run
|
||||
data = dict(
|
||||
name='new job from template',
|
||||
job_template=jt.pk,
|
||||
)
|
||||
with self.current_user(self.user_sue):
|
||||
response = self.post(url, data, expect=201)
|
||||
|
||||
# FIXME: Check with other credentials and optional fields.
|
||||
|
||||
def test_get_job_detail(self):
|
||||
pass
|
||||
job = self.job_ops_east_run
|
||||
url = reverse('main:job_detail', args=(job.pk,))
|
||||
|
||||
# Test with no auth and with invalid login.
|
||||
self._test_invalid_creds(url)
|
||||
|
||||
# sue can read the job detail.
|
||||
with self.current_user(self.user_sue):
|
||||
self.options(url)
|
||||
self.head(url)
|
||||
response = self.get(url)
|
||||
self.assertEqual(response['url'], url)
|
||||
|
||||
# FIXME: Check with other credentials and optional fields.
|
||||
|
||||
def test_put_job_detail(self):
|
||||
pass
|
||||
job = self.job_ops_west_run
|
||||
url = reverse('main:job_detail', args=(job.pk,))
|
||||
|
||||
def test_get_job_start(self):
|
||||
pass
|
||||
# Test with no auth and with invalid login.
|
||||
self._test_invalid_creds(url, methods=('put',))# 'patch'))
|
||||
|
||||
def test_post_job_start(self):
|
||||
pass
|
||||
# sue can update the job detail only if the job is new.
|
||||
self.assertEqual(job.status, 'new')
|
||||
with self.current_user(self.user_sue):
|
||||
data = self.get(url)
|
||||
data['name'] = '%s-updated' % data['name']
|
||||
response = self.put(url, data)
|
||||
#patch_data = dict(name='%s-changed' % data['name'])
|
||||
#response = self.patch(url, patch_data)
|
||||
|
||||
def test_get_job_cancel(self):
|
||||
pass
|
||||
# sue cannot update the job detail if it is in any other state.
|
||||
for status in ('pending', 'running', 'successful', 'failed', 'error',
|
||||
'canceled'):
|
||||
job.status = status
|
||||
job.save()
|
||||
with self.current_user(self.user_sue):
|
||||
data = self.get(url)
|
||||
data['name'] = '%s-updated' % data['name']
|
||||
self.put(url, data, expect=405)
|
||||
#patch_data = dict(name='%s-changed' % data['name'])
|
||||
#self.patch(url, patch_data, expect=405)
|
||||
|
||||
def test_post_job_cancel(self):
|
||||
pass
|
||||
|
||||
def test_get_job_host_list(self):
|
||||
pass
|
||||
|
||||
def test_get_job_job_event_list(self):
|
||||
pass
|
||||
# FIXME: Check with other credentials and readonly fields.
|
||||
|
||||
def _test_mainline(self):
|
||||
url = reverse('main:job_list')
|
||||
@ -683,4 +744,66 @@ class JobTest(BaseJobTest):
|
||||
# and that jobs come back nicely serialized with related resources and so on ...
|
||||
# that we can drill all the way down and can get at host failure lists, etc ...
|
||||
|
||||
@override_settings(CELERY_ALWAYS_EAGER=True,
|
||||
CELERY_EAGER_PROPAGATES_EXCEPTIONS=True,
|
||||
ANSIBLE_TRANSPORT='local')
|
||||
class JobStartCancelTest(BaseJobTestMixin, django.test.TransactionTestCase):
|
||||
'''Job API tests that need to use the celery task backend.'''
|
||||
|
||||
def setUp(self):
|
||||
super(JobStartCancelTest, self).setUp()
|
||||
# Pass test database name in environment for use by the inventory script.
|
||||
os.environ['ACOM_TEST_DATABASE_NAME'] = settings.DATABASES['default']['NAME']
|
||||
|
||||
def tearDown(self):
|
||||
super(JobStartCancelTest, self).tearDown()
|
||||
os.environ.pop('ACOM_TEST_DATABASE_NAME', None)
|
||||
|
||||
def test_job_start(self):
|
||||
job = self.job_ops_east_run
|
||||
url = reverse('main:job_start', args=(job.pk,))
|
||||
|
||||
# Test with no auth and with invalid login.
|
||||
self._test_invalid_creds(url)
|
||||
self._test_invalid_creds(url, methods=('post',))
|
||||
|
||||
self.assertEqual(job.status, 'new')
|
||||
with self.current_user(self.user_sue):
|
||||
response = self.get(url)
|
||||
self.assertTrue(response['can_start'])
|
||||
self.assertFalse(response['passwords_needed_to_start'])
|
||||
response = self.post(url, {}, expect=202)
|
||||
|
||||
# FIXME: Test with other users, test when passwords are required.
|
||||
|
||||
def test_job_cancel(self):
|
||||
job = self.job_ops_east_run
|
||||
url = reverse('main:job_cancel', args=(job.pk,))
|
||||
|
||||
# Test with no auth and with invalid login.
|
||||
self._test_invalid_creds(url)
|
||||
self._test_invalid_creds(url, methods=('post',))
|
||||
|
||||
# sue can cancel the job, but only when it is pending or running.
|
||||
for status in [x[0] for x in Job.STATUS_CHOICES]:
|
||||
job.status = status
|
||||
job.save()
|
||||
with self.current_user(self.user_sue):
|
||||
response = self.get(url)
|
||||
if status in ('pending', 'running'):
|
||||
self.assertTrue(response['can_cancel'])
|
||||
response = self.post(url, {}, expect=202)
|
||||
else:
|
||||
self.assertFalse(response['can_cancel'])
|
||||
response = self.post(url, {}, expect=405)
|
||||
|
||||
# FIXME: Test with other users.
|
||||
|
||||
def test_get_job_host_list(self):
|
||||
pass
|
||||
|
||||
def test_get_job_job_event_list(self):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
@ -954,15 +954,57 @@ class JobDetail(BaseDetail):
|
||||
serializer_class = JobSerializer
|
||||
permission_classes = (CustomRbac,)
|
||||
|
||||
class JobStart(BaseDetail):
|
||||
def update(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
# Only allow changes (PUT/PATCH) when job status is "new".
|
||||
if obj.status != 'new':
|
||||
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||
return super(JobDetail, self).update(request, *args, **kwargs)
|
||||
|
||||
class JobStart(generics.RetrieveAPIView):
|
||||
|
||||
model = Job
|
||||
permission_classes = (CustomRbac,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
data = dict(
|
||||
can_start=obj.can_start,
|
||||
)
|
||||
if obj.can_start:
|
||||
data['passwords_needed_to_start'] = obj.get_passwords_needed_to_start()
|
||||
return Response(data)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
pass # FIXME
|
||||
obj = self.get_object()
|
||||
if obj.can_start:
|
||||
result = obj.start(**request.DATA)
|
||||
if not result:
|
||||
return Response(status=400)
|
||||
else:
|
||||
return Response(status=202)
|
||||
else:
|
||||
return Response(status=405)
|
||||
|
||||
class JobCancel(BaseDetail):
|
||||
class JobCancel(generics.RetrieveAPIView):
|
||||
|
||||
model = Job
|
||||
permission_classes = (CustomRbac,)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
data = dict(
|
||||
can_cancel=obj.can_cancel,
|
||||
)
|
||||
return Response(data)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
pass # FIXME
|
||||
obj = self.get_object()
|
||||
if obj.can_cancel:
|
||||
result = obj.cancel()
|
||||
return Response(status=202)
|
||||
else:
|
||||
return Response(status=405)
|
||||
|
||||
class JobHostsList(BaseSubList):
|
||||
pass
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user