Async test execution

- Сreate job with user's tests set
  POST /v1/jobs/create

- List of all jobs
  GET /v1/jobs

- Execute job
  GET /v1/jobs/execute/<job_id>

- Get status with report for executed job
  GET /v1/jobs/<job_id>

- Delete job
  DELETE /v1/jobs/<job_id>

Change-Id: Ie34e7cc3e5f3a318c9521f8375e6fce6e5df7a26
This commit is contained in:
Svetlana Shturm 2015-04-08 11:37:49 +03:00
parent dbf2fe17ab
commit 0b24e8521e
8 changed files with 410 additions and 2 deletions

View File

@ -147,6 +147,7 @@ Example of config
server_host=127.0.0.1
server_port=8777
log_file=/var/log/ostf.log
jobs_dir=/var/log/ostf
debug=False
List of supported operations
@ -171,7 +172,36 @@ List of supported operations
POST /v1/plugins/<plugin_name>/suites/<suite>
- run test for plugin
/v1/plugins/<plugin_name>/suites/tests/<test>
POST /v1/plugins/<plugin_name>/suites/tests/<test>
- create job with user's tests set
POST /v1/jobs/create
Example of JSON:
.. code-block:: bash
{
"job": {
"name": "fake",
"description": "description",
"tests": [
"fuel_health.tests.sanity.test_sanity_compute.SanityComputeTest:test_list_flavors"]
}
}
- list of all jobs
GET /v1/jobs
- execute job
GET /v1/jobs/execute/<job_id>
- get status with report for executed job
GET /v1/jobs/<job_id>
- delete job
DELETE /v1/jobs/<job_id>
=====================
REST API Client usage

View File

@ -13,6 +13,7 @@
# under the License.
from cloudv_ostf_adapter.common import cfg
from cloudv_client import jobs
from cloudv_client import plugins
from cloudv_client import suites
from cloudv_client import tests
@ -31,3 +32,4 @@ class Client(object):
self.plugins = plugins.Plugins(**kwargs)
self.suites = suites.Suites(**kwargs)
self.tests = tests.Tests(**kwargs)
self.jobs = jobs.Jobs(**kwargs)

77
cloudv_client/jobs.py Normal file
View File

@ -0,0 +1,77 @@
# Copyright 2015 Mirantis, Inc
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
try:
import simplejson as json
except ImportError:
import json
import requests
from cloudv_ostf_adapter.common import exception
class Jobs(object):
_jobs_create_route = ("http://%(host)s:%(port)d/%(api_version)s"
"/jobs/create")
_jobs_route = ("http://%(host)s:%(port)d/%(api_version)s/jobs")
_jobs_execute_route = ("http://%(host)s:%(port)d/%(api_version)s"
"/jobs/execute/%(job_id)s")
def __init__(self, **kwargs):
self.kwargs = kwargs
def list(self):
jobs_url = self._jobs_route % self.kwargs
response = requests.get(jobs_url)
if not response.ok:
raise exception.exception_mapping.get(response.status_code)()
return json.loads(response.content)['jobs']
def create(self, name, description, tests):
data = {'job': {'name': name,
'description': description,
'tests': tests}}
jobs_url = self._jobs_create_route % self.kwargs
headers = {'Content-Type': 'application/json'}
response = requests.post(jobs_url,
headers=headers,
data=json.dumps(data))
if not response.ok:
raise exception.exception_mapping.get(response.status_code)()
return json.loads(response.content)['job']
def get(self, job_id):
jobs_url = self._jobs_route % self.kwargs
jobs_url += '/%s' % job_id
response = requests.get(jobs_url)
if not response.ok:
raise exception.exception_mapping.get(response.status_code)()
return json.loads(response.content)['job']
def delete(self, job_id):
jobs_url = self._jobs_route % self.kwargs
jobs_url += '/%s' % job_id
response = requests.delete(jobs_url)
if not response.ok:
raise exception.exception_mapping.get(response.status_code)()
def execute(self, job_id):
self.kwargs.update({'job_id': job_id})
jobs_url = self._jobs_execute_route % self.kwargs
response = requests.post(jobs_url)
if not response.ok:
raise exception.exception_mapping.get(response.status_code)()
return json.loads(response.content)['job']

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
import os
import signal
import sys
@ -42,8 +43,21 @@ api.add_resource(wsgi.Tests,
'/v1/plugins/<plugin>/suites/tests/<test>')
api.add_resource(wsgi.JobsCreation,
'/v1/jobs/create')
api.add_resource(wsgi.Jobs,
'/v1/jobs')
api.add_resource(wsgi.Execute,
'/v1/jobs/execute/<job_id>')
api.add_resource(wsgi.Job,
'/v1/jobs/<job_id>')
def main():
config.parse_args(sys.argv)
jobs_dir = CONF.rest.jobs_dir
if not os.path.exists(jobs_dir):
os.mkdir(jobs_dir)
host, port = CONF.rest.server_host, CONF.rest.server_port
try:

View File

@ -75,6 +75,9 @@ rest_opts = [
cfg.StrOpt('debug',
default=False,
help="Debug for REST API."),
cfg.StrOpt('jobs_dir',
default='/var/log/ostf',
help="Directory where jobs will be stored."),
]
rest_client_opts = [

View File

@ -13,7 +13,12 @@
# under the License.
import json
import os
import shutil
import uuid
import mock
from oslo_config import cfg
import testtools
from cloudv_ostf_adapter.cmd import server
@ -21,18 +26,46 @@ from cloudv_ostf_adapter.tests.unittests.fakes.fake_plugin import health_plugin
from cloudv_ostf_adapter import wsgi
CONF = cfg.CONF
class TestServer(testtools.TestCase):
def setUp(self):
self.jobs_dir = '/tmp/ostf_tests_%s' % uuid.uuid1()
CONF.rest.jobs_dir = self.jobs_dir
if not os.path.exists(self.jobs_dir):
os.mkdir(self.jobs_dir)
self.plugin = health_plugin.FakeValidationPlugin()
server.app.config['TESTING'] = True
self.app = server.app.test_client()
self.actual_plugins = wsgi.validation_plugin.VALIDATION_PLUGINS
wsgi.validation_plugin.VALIDATION_PLUGINS = [self.plugin.__class__]
data = {'job': {'name': 'fake',
'tests': self.plugin.tests,
'description': 'description'}}
rv = self.app.post(
'/v1/jobs/create', content_type='application/json',
data=json.dumps(data)).data
self.job_id = self._resp_to_dict(rv)['job']['id']
rv2 = self.app.post(
'/v1/jobs/create', content_type='application/json',
data=json.dumps(data)).data
self.job_id2 = self._resp_to_dict(rv2)['job']['id']
p = mock.patch('cloudv_ostf_adapter.wsgi.uuid.uuid4')
self.addCleanup(p.stop)
m = p.start()
m.return_value = 'fake_uuid'
execute = mock.patch('cloudv_ostf_adapter.wsgi.Execute._execute_job')
self.addCleanup(execute.stop)
execute.start()
super(TestServer, self).setUp()
def tearDown(self):
wsgi.validation_plugin.VALIDATION_PLUGINS = self.actual_plugins
shutil.rmtree(self.jobs_dir)
super(TestServer, self).tearDown()
def test_urlmap(self):
@ -43,7 +76,12 @@ class TestServer(testtools.TestCase):
'/v1/plugins/<plugin>/suites/<suite>/tests',
'/v1/plugins/<plugin>/suites/tests',
'/v1/plugins/<plugin>/suites/<suite>',
'/v1/plugins/<plugin>/suites'
'/v1/plugins/<plugin>/suites',
'/v1/plugins/<plugin>/suites/tests/<test>',
'/v1/jobs/create',
'/v1/jobs',
'/v1/jobs/execute/<job_id>',
'/v1/jobs/<job_id>'
]
for rule in server.app.url_map.iter_rules():
links.append(str(rule))
@ -176,3 +214,111 @@ class TestServer(testtools.TestCase):
'/v1/plugins/fake/suites/tests/fake_test').data
check = {u'message': u'Test fake_test not found.'}
self.assertEqual(self._resp_to_dict(rv), check)
def test_job_create_json_not_found(self):
rv = self.app.post(
'/v1/jobs/create').data
check = {u'message': u'JSON is missing.'}
self.assertEqual(self._resp_to_dict(rv), check)
def test_job_create_job_key_found(self):
data = {'fake': {}}
rv = self.app.post(
'/v1/jobs/create', content_type='application/json',
data=json.dumps(data)).data
check = {u'message': u"JSON doesn't have `job` key."}
self.assertEqual(self._resp_to_dict(rv), check)
def test_job_create_fields_not_found(self):
data = {'job': {'name': 'fake'}}
rv = self.app.post(
'/v1/jobs/create', content_type='application/json',
data=json.dumps(data)).data
check = {u'message': u'Fields description,tests are not specified.'}
self.assertEqual(self._resp_to_dict(rv), check)
def test_job_create_tests_not_found(self):
data = {'job': {'name': 'fake',
'tests': ['a', 'b'],
'description': 'description'}}
rv = self.app.post(
'/v1/jobs/create', content_type='application/json',
data=json.dumps(data)).data
check = {u'message': u'Tests not found (a,b).'}
self.assertEqual(self._resp_to_dict(rv), check)
def test_job_create(self):
data = {'job': {'name': 'fake',
'tests': self.plugin.tests,
'description': 'description'}}
rv = self.app.post(
'/v1/jobs/create', content_type='application/json',
data=json.dumps(data)).data
check = {u'job': {u'description': u'description',
u'id': u'fake_uuid',
u'name': u'fake',
u'status': u'CREATED',
u'tests': self.plugin.tests}}
self.assertEqual(self._resp_to_dict(rv), check)
def test_execute_job_not_found(self):
rv = self.app.post('/v1/jobs/execute/fake').data
check = {u'message': u'Job not found.'}
self.assertEqual(self._resp_to_dict(rv), check)
def test_execute_job(self):
rv = self.app.post('/v1/jobs/execute/%s' % self.job_id).data
check = {u'job': {u'description': u'description',
u'id': self.job_id,
u'name': u'fake',
u'report': [],
u'status': u'IN PROGRESS',
u'tests': self.plugin.tests}}
self.assertEqual(self._resp_to_dict(rv), check)
def test_get_list_jobs(self):
rv = self.app.get('/v1/jobs').data
resp_dict = self._resp_to_dict(rv)['jobs']
check = [
{u'description': u'description',
u'id': self.job_id,
u'name': u'fake',
u'status': u'CREATED',
u'tests': self.plugin.tests},
{u'description': u'description',
u'id': self.job_id2,
u'name': u'fake',
u'status': u'CREATED',
u'tests': self.plugin.tests}]
for job in resp_dict:
self.assertIn(job, check)
def test_get_job_not_found(self):
rv = self.app.get('/v1/jobs/fake').data
check = {u'message': u'Job not found.'}
self.assertEqual(self._resp_to_dict(rv), check)
def test_get_job(self):
rv = self.app.get('/v1/jobs/%s' % self.job_id).data
check = {'job': {u'description': u'description',
u'id': self.job_id,
u'name': u'fake',
u'status': u'CREATED',
u'tests': self.plugin.tests}}
self.assertEqual(self._resp_to_dict(rv), check)
def test_delete_job_not_found(self):
rv = self.app.delete('/v1/jobs/fake').data
check = {u'message': u'Job not found.'}
self.assertEqual(self._resp_to_dict(rv), check)
def test_delete_job(self):
before = self._resp_to_dict(
self.app.get('/v1/jobs').data)
jobs_id_before = [j['id'] for j in before['jobs']]
self.assertEqual(len(jobs_id_before), 2)
self.app.delete('/v1/jobs/%s' % self.job_id)
after = self._resp_to_dict(
self.app.get('/v1/jobs').data)
jobs_id_after = [j['id'] for j in after['jobs']]
self.assertEqual(len(jobs_id_after), 1)

View File

@ -12,15 +12,25 @@
# License for the specific language governing permissions and limitations
# under the License.
import json
import multiprocessing
import os
import os.path
import uuid
from flask.ext import restful
from flask.ext.restful import abort
from flask.ext.restful import reqparse
from flask import request
from oslo_config import cfg
from cloudv_ostf_adapter import validation_plugin
CONF = cfg.CONF
CREATED = 'CREATED'
IN_PROGRESS = 'IN PROGRESS'
COMPLETED = 'COMPLETED'
class BaseTests(restful.Resource):
@ -48,6 +58,20 @@ class BaseTests(restful.Resource):
message='Unknown suite %s.' % suite)
return suite
def path_from_job_name(self, job_id):
return '/'.join((CONF.rest.jobs_dir, job_id))
def get_job(self, **kwargs):
job_id = kwargs.pop('job_id', None)
if job_id is None:
abort(400,
message="Job id is missing.")
file_name = self.path_from_job_name(job_id)
if not os.path.exists(file_name):
abort(404,
message="Job not found.")
return (job_id, file_name)
class Plugins(BaseTests):
@ -136,3 +160,114 @@ class Tests(BaseTests):
return {"plugin": {"name": plugin.name,
"test": test,
"report": report}}
class JobsCreation(BaseTests):
def post(self, **kwargs):
try:
data = request.json
except Exception:
abort(400,
message="JSON is missing.")
if data is None:
abort(400,
message="JSON is missing.")
job = data.get('job', None)
if job is None:
abort(400,
message="JSON doesn't have `job` key.")
mandatory = ['name',
'tests',
'description']
missing = set(mandatory) - set(job.keys())
missing = list(missing)
missing.sort()
if missing:
abort(400,
message="Fields %s are not specified." % ','.join(missing))
self.load_tests()
filtered_tests = []
for p in self.plugins.values():
tests_in_plugin = set(p.tests) & set(job['tests'])
filtered_tests.extend(tests_in_plugin)
not_found = set(job['tests']) - set(filtered_tests)
not_found = list(not_found)
not_found.sort()
if not_found:
abort(400,
message="Tests not found (%s)." % ','.join(not_found))
job_uuid = str(uuid.uuid4())
file_name = self.path_from_job_name(job_uuid)
job['status'] = CREATED
with open(file_name, 'w') as f:
f.write(json.dumps(job))
job['id'] = job_uuid
return {'job': job}
class Execute(BaseTests):
def post(self, **kwargs):
job_id, file_name = self.get_job(**kwargs)
data = {}
with open(file_name, 'r') as f:
data = json.loads(f.read())
with open(file_name, 'w') as f:
data['status'] = IN_PROGRESS
data['report'] = []
f.write(json.dumps(data))
p = multiprocessing.Process(target=self._execute_job,
args=(data, job_id))
p.start()
job = data.copy()
job['id'] = job_id
return {'job': job}
def _execute_job(self, data, job_id):
tests = data['tests']
self.load_tests()
reports = []
for name, plugin in self.plugins.iteritems():
tests_in_plugin = set(plugin.tests) & set(tests)
for test in tests_in_plugin:
results = plugin.run_test(test)
report = [r.description for r in results].pop()
report['test'] = test
reports.append(report)
data['status'] = COMPLETED
data['report'] = reports
file_name = self.path_from_job_name(job_id)
with open(file_name, 'w') as f:
f.write(json.dumps(data))
class Job(BaseTests):
def get(self, **kwargs):
job_id, file_name = self.get_job(**kwargs)
data = {}
with open(file_name, 'r') as f:
data = json.loads(f.read())
job = data.copy()
job['id'] = job_id
return {'job': job}
def delete(self, **kwargs):
job_id, file_name = self.get_job(**kwargs)
os.remove(file_name)
return {}
class Jobs(BaseTests):
def get(self):
res = []
jobs = [f for (dp, dn, f) in os.walk(CONF.rest.jobs_dir)][0]
for job in jobs:
file_name = self.path_from_job_name(job)
with open(file_name, 'r') as f:
data = json.loads(f.read())
data['id'] = job
res.append(data)
return {'jobs': res}

View File

@ -6,6 +6,7 @@ server_host=127.0.0.1
server_port=8777
log_file=/var/log/ostf.log
debug=False
jobs_dir=/var/log/ostf
[sanity]