Basic support for job start/cancel via REST API with supporting tests.

This commit is contained in:
Chris Church
2013-05-13 10:43:43 -04:00
parent a20573a68d
commit fe5a454441
9 changed files with 235 additions and 48 deletions

View File

@@ -517,6 +517,10 @@ class JobAccess(BaseAccess):
model = Job 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): def can_start(self, obj):
return False # FIXME return False # FIXME

View File

@@ -359,9 +359,9 @@ class JobAdmin(BaseModelAdmin):
fsets = [fs for fs in fsets if fsets = [fs for fs in fsets if
'created' not in fs[1]['fields'] and 'created' not in fs[1]['fields'] and
'celery_task_id' not in fs[1]['fields']] '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']] 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: for fs in fsets:
if 'celery_task_id' in fs[1]['fields']: if 'celery_task_id' in fs[1]['fields']:
fs[1]['fields'] = ('status', 'get_result_stdout_display', fs[1]['fields'] = ('status', 'get_result_stdout_display',

View File

@@ -115,7 +115,7 @@ class JobAdminForm(JobTemplateAdminForm):
if self.instance.pk and self.instance.status != 'new': if self.instance.pk and self.instance.status != 'new':
self.fields.pop('playbook', None) self.fields.pop('playbook', None)
if (not self.data or self.data.get('start_job', '')) and \ 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(): for field in self.instance.get_passwords_needed_to_start():
if field not in self.fields: if field not in self.fields:
continue continue
@@ -128,7 +128,7 @@ class JobAdminForm(JobTemplateAdminForm):
return self.cleaned_data.get('cancel_job', False) return self.cleaned_data.get('cancel_job', False)
def clean(self): 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(): for field in self.instance.get_passwords_needed_to_start():
if field in self.fields: if field in self.fields:
self.fields[field].required = True self.fields[field].required = True
@@ -138,7 +138,7 @@ class JobAdminForm(JobTemplateAdminForm):
instance = super(JobAdminForm, self).save(commit) instance = super(JobAdminForm, self).save(commit)
save_m2m = getattr(self, 'save_m2m', lambda: None) save_m2m = getattr(self, 'save_m2m', lambda: None)
should_start = bool(self.cleaned_data.get('start_job', '') and should_start = bool(self.cleaned_data.get('start_job', '') and
instance.status == 'new') instance.can_start)
start_opts = {} start_opts = {}
for field in ('ssh_password', 'sudo_password', 'ssh_key_unlock'): for field in ('ssh_password', 'sudo_password', 'ssh_key_unlock'):
value = self.cleaned_data.get(field, '') value = self.cleaned_data.get(field, '')
@@ -146,7 +146,7 @@ class JobAdminForm(JobTemplateAdminForm):
start_opts[field] = value start_opts[field] = value
#print 'should_start', should_start #print 'should_start', should_start
should_cancel = bool(self.cleaned_data.get('cancel_job', '') and should_cancel = bool(self.cleaned_data.get('cancel_job', '') and
instance.status in ('new', 'pending')) instance.can_cancel)
def new_save_m2m(): def new_save_m2m():
save_m2m() save_m2m()
if should_start: if should_start:

View File

@@ -644,6 +644,7 @@ class Job(CommonModel):
) )
failed = models.BooleanField( failed = models.BooleanField(
default=False, default=False,
editable=False,
) )
result_stdout = models.TextField( result_stdout = models.TextField(
blank=True, blank=True,
@@ -703,9 +704,13 @@ class Job(CommonModel):
needed.append(field) needed.append(field)
return needed return needed
@property
def can_start(self):
return bool(self.status == 'new')
def start(self, **kwargs): def start(self, **kwargs):
from lib.main.tasks import RunJob from lib.main.tasks import RunJob
if self.status != 'new': if not self.can_start:
return False return False
needed = self.get_passwords_needed_to_start() needed = self.get_passwords_needed_to_start()
opts = dict([(field, kwargs.get(field, '')) for field in needed]) opts = dict([(field, kwargs.get(field, '')) for field in needed])
@@ -720,8 +725,12 @@ class Job(CommonModel):
self.save(update_fields=['celery_task_id']) self.save(update_fields=['celery_task_id'])
return True return True
@property
def can_cancel(self):
return bool(self.status in ('pending', 'running'))
def cancel(self): def cancel(self):
if self.status in ('pending', 'running'): if self.can_cancel:
if not self.cancel_flag: if not self.cancel_flag:
self.cancel_flag = True self.cancel_flag = True
self.save(update_fields=['cancel_flag']) self.save(update_fields=['cancel_flag'])

View File

@@ -70,6 +70,8 @@ class CustomRbac(permissions.BasePermission):
return check_user_access(request.user, view.model, 'delete', obj) return check_user_access(request.user, view.model, 'delete', obj)
def _check_permissions(self, request, view, obj=None): 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. # Check that obj (if given) is active, otherwise raise a 404.
active = getattr(obj, 'active', getattr(obj, 'is_active', True)) active = getattr(obj, 'active', getattr(obj, 'is_active', True))
if callable(active): if callable(active):
@@ -81,7 +83,7 @@ class CustomRbac(permissions.BasePermission):
return False return False
# Don't allow inactive users (and respond with a 403). # Don't allow inactive users (and respond with a 403).
if not request.user.is_active: if not request.user.is_active:
raise PermissionDenied() raise PermissionDenied('your account is inactive')
# Always allow superusers (as long as they are active). # Always allow superusers (as long as they are active).
if request.user.is_superuser: if request.user.is_superuser:
return True return True

View File

@@ -407,6 +407,10 @@ class JobSerializer(BaseSerializer):
)) ))
if obj.job_template: if obj.job_template:
res['job_template'] = reverse('main:job_template_detail', args=(obj.job_template.pk,)) 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 return res
def from_native(self, data, files): def from_native(self, data, files):

View File

@@ -203,6 +203,9 @@ class BaseTestMixin(object):
def put(self, url, data, expect=200, auth=None): def put(self, url, data, expect=200, auth=None):
return self._generic_rest(url, data=data, expect=expect, auth=auth, method='put') 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): def delete(self, url, expect=201, auth=None):
return self._generic_rest(url, data=None, expect=expect, auth=auth, method='delete') return self._generic_rest(url, data=None, expect=expect, auth=auth, method='delete')

View File

@@ -18,11 +18,13 @@ import datetime
import json import json
from django.contrib.auth.models import User as DjangoUser from django.contrib.auth.models import User as DjangoUser
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
import django.test
from django.test.client import Client from django.test.client import Client
from django.test.utils import override_settings
from lib.main.models import * 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 TEST_PLAYBOOK = '''- hosts: all
gather_facts: false gather_facts: false
@@ -31,7 +33,7 @@ TEST_PLAYBOOK = '''- hosts: all
command: test 1 = 1 command: test 1 = 1
''' '''
class BaseJobTest(BaseTest): class BaseJobTestMixin(BaseTestMixin):
'''''' ''''''
def _create_inventory(self, name, organization, created_by, def _create_inventory(self, name, organization, created_by,
@@ -273,7 +275,7 @@ class BaseJobTest(BaseTest):
) )
self.cred_iris = self.user_iris.credentials.create( self.cred_iris = self.user_iris.credentials.create(
ssh_username='iris', ssh_username='iris',
ssh_password='', ssh_password='ASK',
created_by=self.user_sue, created_by=self.user_sue,
) )
@@ -414,15 +416,9 @@ class BaseJobTest(BaseTest):
) )
def setUp(self): def setUp(self):
super(BaseJobTest, self).setUp() super(BaseJobTestMixin, self).setUp()
self.populate() self.populate()
class JobTemplateTest(BaseJobTest):
def setUp(self):
super(JobTemplateTest, self).setUp()
def _test_invalid_creds(self, url, data=None, methods=None): def _test_invalid_creds(self, url, data=None, methods=None):
data = data or {} data = data or {}
methods = methods or ('options', 'head', 'get') methods = methods or ('options', 'head', 'get')
@@ -430,11 +426,13 @@ class JobTemplateTest(BaseJobTest):
with self.current_user(*auth): with self.current_user(*auth):
for method in methods: for method in methods:
f = getattr(self, method) f = getattr(self, method)
if method in ('post', 'put'): if method in ('post', 'put', 'patch'):
f(url, data, expect=401) f(url, data, expect=401)
else: else:
f(url, expect=401) f(url, expect=401)
class JobTemplateTest(BaseJobTestMixin, django.test.TestCase):
def test_get_job_template_list(self): def test_get_job_template_list(self):
url = reverse('main:job_template_list') url = reverse('main:job_template_list')
@@ -556,12 +554,15 @@ class JobTemplateTest(BaseJobTest):
url = reverse('main:job_template_detail', args=(jt.pk,)) url = reverse('main:job_template_detail', args=(jt.pk,))
# Test with no auth and with invalid login. # 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. # sue can update the job template detail.
with self.current_user(self.user_sue): with self.current_user(self.user_sue):
data = self.get(url) data = self.get(url)
data['name'] = '%s-updated' % data['name']
response = self.put(url, data) 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. # FIXME: Check other credentials and optional fields.
@@ -600,40 +601,100 @@ class JobTemplateTest(BaseJobTest):
# FIXME: Check other credentials and optional fields. # FIXME: Check other credentials and optional fields.
class JobTest(BaseJobTest): class JobTest(BaseJobTestMixin, django.test.TestCase):
def setUp(self):
super(JobTest, self).setUp()
def test_get_job_list(self): 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): 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): 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): 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): # Test with no auth and with invalid login.
pass self._test_invalid_creds(url, methods=('put',))# 'patch'))
def test_post_job_start(self): # sue can update the job detail only if the job is new.
pass 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): # sue cannot update the job detail if it is in any other state.
pass 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): # FIXME: Check with other credentials and readonly fields.
pass
def test_get_job_host_list(self):
pass
def test_get_job_job_event_list(self):
pass
def _test_mainline(self): def _test_mainline(self):
url = reverse('main:job_list') 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 ... # 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 ... # 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

View File

@@ -954,15 +954,57 @@ class JobDetail(BaseDetail):
serializer_class = JobSerializer serializer_class = JobSerializer
permission_classes = (CustomRbac,) 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): 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): 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): class JobHostsList(BaseSubList):
pass pass