From 0b24e8521e1c971fa3373d169c1589d9d3a42b59 Mon Sep 17 00:00:00 2001 From: Svetlana Shturm Date: Wed, 8 Apr 2015 11:37:49 +0300 Subject: [PATCH] Async test execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Сreate job with user's tests set POST /v1/jobs/create - List of all jobs GET /v1/jobs - Execute job GET /v1/jobs/execute/ - Get status with report for executed job GET /v1/jobs/ - Delete job DELETE /v1/jobs/ Change-Id: Ie34e7cc3e5f3a318c9521f8375e6fce6e5df7a26 --- README.rst | 32 +++- cloudv_client/client.py | 2 + cloudv_client/jobs.py | 77 +++++++++ cloudv_ostf_adapter/cmd/server.py | 14 ++ cloudv_ostf_adapter/common/cfg.py | 3 + .../tests/unittests/test_server.py | 148 +++++++++++++++++- cloudv_ostf_adapter/wsgi/__init__.py | 135 ++++++++++++++++ .../cloudv_ostf_adapter.conf | 1 + 8 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 cloudv_client/jobs.py diff --git a/README.rst b/README.rst index a1c8ce0..d536734 100644 --- a/README.rst +++ b/README.rst @@ -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//suites/ - run test for plugin - /v1/plugins//suites/tests/ + POST /v1/plugins//suites/tests/ + + - 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/ + +- get status with report for executed job + GET /v1/jobs/ + +- delete job + DELETE /v1/jobs/ + ===================== REST API Client usage diff --git a/cloudv_client/client.py b/cloudv_client/client.py index efe523c..bec8dc7 100644 --- a/cloudv_client/client.py +++ b/cloudv_client/client.py @@ -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) diff --git a/cloudv_client/jobs.py b/cloudv_client/jobs.py new file mode 100644 index 0000000..d26de29 --- /dev/null +++ b/cloudv_client/jobs.py @@ -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'] diff --git a/cloudv_ostf_adapter/cmd/server.py b/cloudv_ostf_adapter/cmd/server.py index 76aee64..c38d8ce 100644 --- a/cloudv_ostf_adapter/cmd/server.py +++ b/cloudv_ostf_adapter/cmd/server.py @@ -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//suites/tests/') +api.add_resource(wsgi.JobsCreation, + '/v1/jobs/create') +api.add_resource(wsgi.Jobs, + '/v1/jobs') +api.add_resource(wsgi.Execute, + '/v1/jobs/execute/') +api.add_resource(wsgi.Job, + '/v1/jobs/') + + 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: diff --git a/cloudv_ostf_adapter/common/cfg.py b/cloudv_ostf_adapter/common/cfg.py index c4d815d..b979779 100644 --- a/cloudv_ostf_adapter/common/cfg.py +++ b/cloudv_ostf_adapter/common/cfg.py @@ -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 = [ diff --git a/cloudv_ostf_adapter/tests/unittests/test_server.py b/cloudv_ostf_adapter/tests/unittests/test_server.py index 35c201c..2edd4e6 100644 --- a/cloudv_ostf_adapter/tests/unittests/test_server.py +++ b/cloudv_ostf_adapter/tests/unittests/test_server.py @@ -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//suites//tests', '/v1/plugins//suites/tests', '/v1/plugins//suites/', - '/v1/plugins//suites' + '/v1/plugins//suites', + '/v1/plugins//suites/tests/', + '/v1/jobs/create', + '/v1/jobs', + '/v1/jobs/execute/', + '/v1/jobs/' ] 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) diff --git a/cloudv_ostf_adapter/wsgi/__init__.py b/cloudv_ostf_adapter/wsgi/__init__.py index e00a0dd..4b9465f 100644 --- a/cloudv_ostf_adapter/wsgi/__init__.py +++ b/cloudv_ostf_adapter/wsgi/__init__.py @@ -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} diff --git a/etc/cloudv_ostf_adapter/cloudv_ostf_adapter.conf b/etc/cloudv_ostf_adapter/cloudv_ostf_adapter.conf index c4bc80c..5f95842 100644 --- a/etc/cloudv_ostf_adapter/cloudv_ostf_adapter.conf +++ b/etc/cloudv_ostf_adapter/cloudv_ostf_adapter.conf @@ -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]