diff --git a/.gitignore b/.gitignore index 8890fce9..120a4cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ .idea -*.egg-info -dist *.pyc +*.log +nosetests.xml +*.egg-info +/*.egg .tox +build +dist +*.out +.coverage diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..60b9b4ab --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +recursive-include ostf_adapter *.ini +recursive-include ostf_adapter * \ No newline at end of file diff --git a/fuel_plugin/__init__.py b/fuel_plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fuel_plugin/bin/__init__.py b/fuel_plugin/bin/__init__.py new file mode 100644 index 00000000..141ca6ab --- /dev/null +++ b/fuel_plugin/bin/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. diff --git a/fuel_plugin/bin/adapter_api.py b/fuel_plugin/bin/adapter_api.py new file mode 100644 index 00000000..027217fe --- /dev/null +++ b/fuel_plugin/bin/adapter_api.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# Copyright 2013 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. + +import os +import logging +import signal + +from fuel_plugin.ostf_adapter import cli_config +from fuel_plugin.ostf_adapter import nailgun_hooks +from fuel_plugin.ostf_adapter import logger +from fuel_plugin.ostf_adapter.nose_plugin import nose_discovery +from gevent import pywsgi +from fuel_plugin.ostf_adapter.wsgi import app +import pecan + + +def main(): + + cli_args = cli_config.parse_cli() + + config = { + 'server': { + 'host': cli_args.host, + 'port': cli_args.port + }, + 'dbpath': cli_args.dbpath, + 'debug': cli_args.debug, + 'debug_tests': cli_args.debug_tests + } + + logger.setup(log_file=cli_args.log_file) + + log = logging.getLogger(__name__) + + root = app.setup_app(config=config) + nose_discovery.discovery(cli_args.debug_tests) + if getattr(cli_args, 'after_init_hook'): + return nailgun_hooks.after_initialization_environment_hook() + + host, port = pecan.conf.server.host, pecan.conf.server.port + srv = pywsgi.WSGIServer((host, int(port)), root) + + log.info('Starting server in PID %s', os.getpid()) + log.info("serving on http://%s:%s", host, port) + + try: + signal.signal(signal.SIGCHLD, signal.SIG_IGN) + srv.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == '__main__': + main() diff --git a/fuel_plugin/ostf_adapter/__init__.py b/fuel_plugin/ostf_adapter/__init__.py new file mode 100644 index 00000000..141ca6ab --- /dev/null +++ b/fuel_plugin/ostf_adapter/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. diff --git a/fuel_plugin/ostf_adapter/cli_config.py b/fuel_plugin/ostf_adapter/cli_config.py new file mode 100644 index 00000000..a49f6f9c --- /dev/null +++ b/fuel_plugin/ostf_adapter/cli_config.py @@ -0,0 +1,34 @@ +# Copyright 2013 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. + +import argparse +import sys + + +def parse_cli(): + parser = argparse.ArgumentParser() + parser.add_argument('--after-initialization-environment-hook', + action='store_true', dest='after_init_hook') + parser.add_argument('--debug', + action='store_true', dest='debug') + parser.add_argument( + '--dbpath', metavar='DB_PATH', + default='postgresql+psycopg2://adapter:demo@localhost/testing_adapter') + parser.add_argument('--host', default='127.0.0.1') + parser.add_argument('--port', default='8989') + parser.add_argument('--log_file', default=None, metavar='PATH') + parser.add_argument('--nailgun-host', default='127.0.0.1') + parser.add_argument('--nailgun-port', default='3232') + parser.add_argument('--debug_tests', default=None) + return parser.parse_args(sys.argv[1:]) diff --git a/fuel_plugin/ostf_adapter/logger.py b/fuel_plugin/ostf_adapter/logger.py new file mode 100644 index 00000000..96f0e1f2 --- /dev/null +++ b/fuel_plugin/ostf_adapter/logger.py @@ -0,0 +1,38 @@ +# Copyright 2013 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. + +import os +import logging +import logging.handlers + + +def setup(log_file=None): + formatter = logging.Formatter( + '[%(asctime)s] %(levelname)-8s %(message)s') + log = logging.getLogger(None) + stream_handler = logging.StreamHandler() + stream_handler.setLevel(logging.INFO) + stream_handler.setFormatter(formatter) + log.addHandler(stream_handler) + + if log_file: + log_file = os.path.abspath(log_file) + file_handler = logging.handlers.WatchedFileHandler(log_file) + file_handler.setLevel(logging.DEBUG) + mode = int('0644', 8) + os.chmod(log_file, mode) + file_handler.setFormatter(formatter) + log.addHandler(file_handler) + + log.setLevel(logging.INFO) diff --git a/fuel_plugin/ostf_adapter/nailgun_hooks.py b/fuel_plugin/ostf_adapter/nailgun_hooks.py new file mode 100644 index 00000000..d0092890 --- /dev/null +++ b/fuel_plugin/ostf_adapter/nailgun_hooks.py @@ -0,0 +1,29 @@ +# Copyright 2013 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. + +import logging +from fuel_plugin.ostf_adapter.nose_plugin import nose_discovery +from fuel_plugin.ostf_adapter.storage import alembic_cli +from pecan import conf + +LOG = logging.getLogger(__name__) + + +def after_initialization_environment_hook(): + """Expect 0 on success by nailgun + Exception is good enough signal that something goes wrong + """ + alembic_cli.do_apply_migrations() + nose_discovery.discovery(conf.debug_tests) + return 0 diff --git a/fuel_plugin/ostf_adapter/nose_plugin/__init__.py b/fuel_plugin/ostf_adapter/nose_plugin/__init__.py new file mode 100644 index 00000000..b01c97e4 --- /dev/null +++ b/fuel_plugin/ostf_adapter/nose_plugin/__init__.py @@ -0,0 +1,31 @@ +# Copyright 2013 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. + +from stevedore import extension + + +_PLUGIN_MANAGER = None + + +def get_plugin(plugin): + global _PLUGIN_MANAGER + plugin_manager = _PLUGIN_MANAGER + + if plugin_manager is None: + PLUGINS_NAMESPACE = 'plugins' + plugin_manager = extension.ExtensionManager(PLUGINS_NAMESPACE, + invoke_on_load=True) + + _PLUGIN_MANAGER = plugin_manager + return _PLUGIN_MANAGER[plugin].obj diff --git a/fuel_plugin/ostf_adapter/nose_plugin/nose_adapter.py b/fuel_plugin/ostf_adapter/nose_plugin/nose_adapter.py new file mode 100644 index 00000000..4eb76561 --- /dev/null +++ b/fuel_plugin/ostf_adapter/nose_plugin/nose_adapter.py @@ -0,0 +1,105 @@ +# Copyright 2013 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. + +import os +import logging + +from pecan import conf +from fuel_plugin.ostf_adapter.nose_plugin import nose_storage_plugin +from fuel_plugin.ostf_adapter.nose_plugin import nose_test_runner +from fuel_plugin.ostf_adapter.nose_plugin import nose_utils +from fuel_plugin.ostf_adapter.storage import storage_utils, engine, models + + +LOG = logging.getLogger(__name__) + + +class NoseDriver(object): + def __init__(self): + LOG.warning('WTF') + self._named_threads = {} + session = engine.get_session() + with session.begin(subtransactions=True): + storage_utils.update_all_running_test_runs(session) + + def check_current_running(self, unique_id): + return unique_id in self._named_threads + + def run(self, test_run, test_set, tests=None): + """ + remove unneceserry arguments + spawn processes and send them tasks as to workers + """ + tests = tests or test_run.enabled_tests + if tests: + argv_add = [nose_utils.modify_test_name_for_nose(test) for test in + tests] + else: + argv_add = [test_set.test_path] + test_set.additional_arguments + + self._named_threads[test_run.id] = nose_utils.run_proc( + self._run_tests, test_run.id, test_run.cluster_id, argv_add) + + def _run_tests(self, test_run_id, cluster_id, argv_add): + session = engine.get_session() + try: + nose_test_runner.SilentTestProgram( + addplugins=[nose_storage_plugin.StoragePlugin( + test_run_id, str(cluster_id))], + exit=False, + argv=['ostf_tests'] + argv_add) + self._named_threads.pop(int(test_run_id), None) + except Exception, e: + LOG.exception('Test run: %s\n', test_run_id) + finally: + models.TestRun.update_test_run( + session, test_run_id, status='finished') + + def kill(self, test_run_id, cluster_id, cleanup=None): + session = engine.get_session() + if test_run_id in self._named_threads: + + self._named_threads[test_run_id].terminate() + self._named_threads.pop(test_run_id, None) + + if cleanup: + nose_utils.run_proc( + self._clean_up, + test_run_id, + cluster_id, + cleanup) + else: + models.TestRun.update_test_run( + session, test_run_id, status='finished') + + return True + return False + + def _clean_up(self, test_run_id, external_id, cleanup): + session = engine.get_session() + try: + module_obj = __import__(cleanup, -1) + + os.environ['NAILGUN_HOST'] = str(conf.nailgun.host) + os.environ['NAILGUN_PORT'] = str(conf.nailgun.port) + os.environ['CLUSTER_ID'] = str(external_id) + + module_obj.cleanup.cleanup() + + except Exception: + LOG.exception('EXCEPTION IN CLEANUP') + + finally: + models.TestRun.update_test_run( + session, test_run_id, status='finished') diff --git a/fuel_plugin/ostf_adapter/nose_plugin/nose_discovery.py b/fuel_plugin/ostf_adapter/nose_plugin/nose_discovery.py new file mode 100644 index 00000000..c8259ec6 --- /dev/null +++ b/fuel_plugin/ostf_adapter/nose_plugin/nose_discovery.py @@ -0,0 +1,89 @@ +# Copyright 2013 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. + +import logging +import os + +from nose import plugins + +from fuel_plugin.ostf_adapter.nose_plugin import nose_test_runner +from fuel_plugin.ostf_adapter.nose_plugin import nose_utils +from fuel_plugin.ostf_adapter.storage import engine, models + + +CORE_PATH = 'fuel_health' + +LOG = logging.getLogger(__name__) + + +class DiscoveryPlugin(plugins.Plugin): + + enabled = True + name = 'discovery' + score = 15000 + + def __init__(self): + self.test_sets = {} + super(DiscoveryPlugin, self).__init__() + + def options(self, parser, env=os.environ): + pass + + def configure(self, options, conf): + pass + + def afterImport(self, filename, module): + module = __import__(module, fromlist=[module]) + LOG.info('Inspecting %s', filename) + if hasattr(module, '__profile__'): + session = engine.get_session() + with session.begin(subtransactions=True): + LOG.info('%s discovered.', module.__name__) + test_set = models.TestSet(**module.__profile__) + test_set = session.merge(test_set) + session.add(test_set) + self.test_sets[test_set.id] = test_set + + def addSuccess(self, test): + test_id = test.id() + for test_set_id in self.test_sets.keys(): + if test_set_id in test_id: + session = engine.get_session() + with session.begin(subtransactions=True): + LOG.info('%s added for %s', test_id, test_set_id) + data = dict() + data['title'], data['description'], data['duration'] = \ + nose_utils.get_description(test) + old_test_obj = session.query(models.Test).filter_by( + name=test_id, test_set_id=test_set_id, + test_run_id=None).\ + update(data, synchronize_session=False) + if not old_test_obj: + data.update({'test_set_id': test_set_id, + 'name': test_id}) + test_obj = models.Test(**data) + session.add(test_obj) + + +def discovery(path=None): + """ + function to automaticly discover any test packages + """ + + tests = [CORE_PATH, path] if path else [CORE_PATH] + LOG.info('Starting discovery for %r.', tests) + nose_test_runner.SilentTestProgram( + addplugins=[DiscoveryPlugin()], + exit=False, + argv=['tests_discovery', '--collect-only'] + tests) diff --git a/fuel_plugin/ostf_adapter/nose_plugin/nose_storage_plugin.py b/fuel_plugin/ostf_adapter/nose_plugin/nose_storage_plugin.py new file mode 100644 index 00000000..eda22d62 --- /dev/null +++ b/fuel_plugin/ostf_adapter/nose_plugin/nose_storage_plugin.py @@ -0,0 +1,111 @@ +# Copyright 2013 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. + +from time import time +import logging +import os + +from nose import plugins +from nose.suite import ContextSuite +from pecan import conf +from fuel_plugin.ostf_adapter.nose_plugin import nose_utils + +from fuel_plugin.ostf_adapter.storage import models, engine + + +LOG = logging.getLogger(__name__) + + +class StoragePlugin(plugins.Plugin): + enabled = True + name = 'storage' + score = 15000 + + def __init__( + self, test_run_id, cluster_id): + self.test_run_id = test_run_id + self.cluster_id = cluster_id + super(StoragePlugin, self).__init__() + self._start_time = None + + def options(self, parser, env=os.environ): + env['NAILGUN_HOST'] = str(conf.nailgun.host) + env['NAILGUN_PORT'] = str(conf.nailgun.port) + if self.cluster_id: + env['CLUSTER_ID'] = str(self.cluster_id) + + def configure(self, options, conf): + self.conf = conf + + def _add_message( + self, test, err=None, status=None): + data = { + 'status': status, + 'time_taken': self.taken + } + data['title'], data['description'], data['duration'] = \ + nose_utils.get_description(test) + if err: + exc_type, exc_value, exc_traceback = err + data['step'], data['message'] = None, u'' + if not status == 'error': + data['step'], data['message'] = \ + nose_utils.format_failure_message(exc_value) + data['traceback'] = u'' + else: + data['step'], data['message'] = None, u'' + data['traceback'] = u'' + + session = engine.get_session() + + with session.begin(subtransactions=True): + + if isinstance(test, ContextSuite): + for sub_test in test._tests: + data['title'], data['description'], data['duration'] = \ + nose_utils.get_description(test) + models.Test.add_result( + session, self.test_run_id, sub_test.id(), data) + else: + models.Test.add_result( + session, self.test_run_id, test.id(), data) + + def addSuccess(self, test, capt=None): + self._add_message(test, status='success') + + def addFailure(self, test, err): + LOG.error('%s', test.id(), exc_info=err) + self._add_message(test, err=err, status='failure') + + def addError(self, test, err): + if err[0] == AssertionError: + LOG.error('%s', test.id(), exc_info=err) + self._add_message( + test, err=err, status='failure') + else: + LOG.error('%s', test.id(), exc_info=err) + self._add_message(test, err=err, status='error') + + def beforeTest(self, test): + self._start_time = time() + self._add_message(test, status='running') + + def describeTest(self, test): + return test.test._testMethodDoc + + @property + def taken(self): + if self._start_time: + return time() - self._start_time + return 0 diff --git a/fuel_plugin/ostf_adapter/nose_plugin/nose_test_runner.py b/fuel_plugin/ostf_adapter/nose_plugin/nose_test_runner.py new file mode 100644 index 00000000..c2ba0f7a --- /dev/null +++ b/fuel_plugin/ostf_adapter/nose_plugin/nose_test_runner.py @@ -0,0 +1,36 @@ +# Copyright 2013 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. + +from nose import core + + +class SilentTestRunner(core.TextTestRunner): + def run(self, test): + """Overrides to provide plugin hooks and defer all output to + the test result class. + """ + result = self._makeResult() + test(result) + return result + + +class SilentTestProgram(core.TestProgram): + def runTests(self): + """Run Tests. Returns true on success, false on failure, and sets + self.success to the same value. + """ + self.testRunner = SilentTestRunner(stream=self.config.stream, + verbosity=0, + config=self.config) + return self.testRunner.run(self.test).wasSuccessful() diff --git a/fuel_plugin/ostf_adapter/nose_plugin/nose_utils.py b/fuel_plugin/ostf_adapter/nose_plugin/nose_utils.py new file mode 100644 index 00000000..1b87e75f --- /dev/null +++ b/fuel_plugin/ostf_adapter/nose_plugin/nose_utils.py @@ -0,0 +1,102 @@ +# Copyright 2013 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. + +import traceback +import re +import json +import os +import multiprocessing + +from nose import case + + +def parse_json_file(file_path): + current_directory = os.path.dirname(os.path.realpath(__file__)) + commands_path = os.path.join( + current_directory, file_path) + with open(commands_path, 'r') as f: + return json.load(f) + + +def get_exc_message(exception_value): + """ + @exception_value - Exception type object + """ + _exc_long = str(exception_value) + if isinstance(_exc_long, basestring): + return _exc_long.split('\n')[0] + return u"" + + +def get_description(test_obj): + if isinstance(test_obj, case.Test): + docstring = test_obj.shortDescription() + + if docstring: + duration_pattern = r'Duration:.?(?P.+)' + duration_matcher = re.search(duration_pattern, docstring) + if duration_matcher: + duration = duration_matcher.group(1) + docstring = docstring[:duration_matcher.start()] + else: + duration = None + docstring = docstring.split('\n') + name = docstring.pop(0) + description = u'\n'.join(docstring) if docstring else u"" + + return name, description, duration + return u"", u"", u"" + + +def modify_test_name_for_nose(test_path): + test_module, test_class, test_method = test_path.rsplit('.', 2) + return '{0}:{1}.{2}'.format(test_module, test_class, test_method) + + +def format_exception(exc_info): + ec, ev, tb = exc_info + + # formatError() may have turned our exception object into a string, and + # Python 3's traceback.format_exception() doesn't take kindly to that (it + # expects an actual exception object). So we work around it, by doing the + # work ourselves if ev is a string. + if isinstance(ev, basestring): + tb_data = ''.join(traceback.format_tb(tb)) + return tb_data + ev + else: + return ''.join(traceback.format_exception(*exc_info)) + + +def format_failure_message(message): + message = get_exc_message(message) + matcher = re.search( + r'^[a-zA-Z]+\s?(\d+)\s?[a-zA-Z]+\s?[\.:]\s?(.+)', + message) + if matcher: + step, msg = matcher.groups() + return int(step), msg + return None, message + + +def run_proc(func, *args): + proc = multiprocessing.Process( + target=func, + args=args) + proc.daemon = True + proc.start() + return proc + + +def get_module(module_path): + pass diff --git a/fuel_plugin/ostf_adapter/storage/__init__.py b/fuel_plugin/ostf_adapter/storage/__init__.py new file mode 100644 index 00000000..141ca6ab --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. diff --git a/fuel_plugin/ostf_adapter/storage/alembic.ini b/fuel_plugin/ostf_adapter/storage/alembic.ini new file mode 100644 index 00000000..3139d9c9 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/alembic.ini @@ -0,0 +1,49 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +sqlalchemy.url = postgresql+psycopg2://ostf:ostf@localhost/ostf + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/fuel_plugin/ostf_adapter/storage/alembic_cli.py b/fuel_plugin/ostf_adapter/storage/alembic_cli.py new file mode 100644 index 00000000..5e86cdca --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/alembic_cli.py @@ -0,0 +1,32 @@ +# Copyright 2013 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. + +import os +import logging + +from alembic import command, config +from pecan import conf + + +log = logging.getLogger(__name__) + + +def do_apply_migrations(): + alembic_conf = config.Config( + os.path.join(os.path.dirname(__file__), 'alembic.ini') + ) + alembic_conf.set_main_option('script_location', + 'fuel_plugin.ostf_adapter.storage:migrations') + alembic_conf.set_main_option('sqlalchemy.url', conf.dbpath) + command.upgrade(alembic_conf, 'head') diff --git a/fuel_plugin/ostf_adapter/storage/engine.py b/fuel_plugin/ostf_adapter/storage/engine.py new file mode 100644 index 00000000..bd53473f --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/engine.py @@ -0,0 +1,57 @@ +# Copyright 2013 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. + +from pecan import conf +from sqlalchemy import create_engine, orm, pool + + +_ENGINE = None +_MAKER = None + + +def get_session(autocommit=True, expire_on_commit=False): + """Return a SQLAlchemy session.""" + global _MAKER + global _SLAVE_MAKER + maker = _MAKER + + if maker is None: + engine = get_engine() + maker = get_maker(engine, autocommit, expire_on_commit) + + else: + _MAKER = maker + + session = maker() + return session + + +def get_engine(pool_type=None): + """Return a SQLAlchemy engine.""" + global _ENGINE + engine = _ENGINE + + if engine is None: + engine = create_engine(conf.dbpath, + poolclass=pool_type or pool.NullPool) + _ENGINE = engine + return engine + + +def get_maker(engine, autocommit=True, expire_on_commit=False): + """Return a SQLAlchemy sessionmaker using the given engine.""" + return orm.sessionmaker( + bind=engine, + autocommit=autocommit, + expire_on_commit=expire_on_commit) diff --git a/fuel_plugin/ostf_adapter/storage/fields.py b/fuel_plugin/ostf_adapter/storage/fields.py new file mode 100644 index 00000000..9797655a --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/fields.py @@ -0,0 +1,42 @@ +# Copyright 2013 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. + +import json + +from sqlalchemy.types import TypeDecorator, VARCHAR + + +class JsonField(TypeDecorator): + impl = VARCHAR + + def process_bind_param(self, value, dialect): + if value is not None: + value = json.dumps(value) + + return value + + def process_result_value(self, value, dialect): + if value is not None: + value = json.loads(value) + return value + + +class ListField(JsonField): + def process_bind_param(self, value, dialect): + value = list(value) if value else [] + super(ListField, self).process_bind_param(value, dialect) + + def process_result_value(self, value, dialect): + value = super(ListField, self).process_bind_param(value, dialect) + return list(value) if value else [] diff --git a/fuel_plugin/ostf_adapter/storage/migrations/README b/fuel_plugin/ostf_adapter/storage/migrations/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/fuel_plugin/ostf_adapter/storage/migrations/__init__.py b/fuel_plugin/ostf_adapter/storage/migrations/__init__.py new file mode 100644 index 00000000..141ca6ab --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. diff --git a/fuel_plugin/ostf_adapter/storage/migrations/env.py b/fuel_plugin/ostf_adapter/storage/migrations/env.py new file mode 100644 index 00000000..edc95a8f --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/env.py @@ -0,0 +1,90 @@ +# Copyright 2013 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. + +from __future__ import with_statement +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +from alembic import context +from fuel_plugin.ostf_adapter.storage import models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = models.BASE.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + engine = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/fuel_plugin/ostf_adapter/storage/migrations/script.py.mako b/fuel_plugin/ostf_adapter/storage/migrations/script.py.mako new file mode 100644 index 00000000..04cd3706 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +def upgrade(): +${upgrades if upgrades else "pass"} + + +def downgrade(): +${downgrades if downgrades else "pass"} diff --git a/fuel_plugin/ostf_adapter/storage/migrations/versions/12340edd992d_add_status_field_to_.py b/fuel_plugin/ostf_adapter/storage/migrations/versions/12340edd992d_add_status_field_to_.py new file mode 100644 index 00000000..0fbd4a19 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/versions/12340edd992d_add_status_field_to_.py @@ -0,0 +1,42 @@ +# Copyright 2013 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. + +"""Add status field to test run model + +Revision ID: 12340edd992d +Revises: 1e2c38f575fb +Create Date: 2013-07-03 17:38:42.632146 + +""" + +# revision identifiers, used by Alembic. +revision = '12340edd992d' +down_revision = '1e2c38f575fb' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column( + 'test_runs', + sa.Column('status', sa.String(length=128), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('test_runs', 'status') + ### end Alembic commands ### diff --git a/fuel_plugin/ostf_adapter/storage/migrations/versions/1b28f7bc6476_database_refactoring.py b/fuel_plugin/ostf_adapter/storage/migrations/versions/1b28f7bc6476_database_refactoring.py new file mode 100644 index 00000000..a443fe13 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/versions/1b28f7bc6476_database_refactoring.py @@ -0,0 +1,94 @@ +"""Database refactoring + +Revision ID: 1b28f7bc6476 +Revises: 4e9905279776 +Create Date: 2013-08-07 13:20:06.373200 + +""" + +# revision identifiers, used by Alembic. +from fuel_plugin.ostf_adapter.storage import fields + +revision = '1b28f7bc6476' +down_revision = '4e9905279776' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('test_runs', sa.Column('test_set_id', sa.String(length=128), + nullable=True)) + op.add_column('test_runs', + sa.Column('meta', fields.JsonField(), nullable=True)) + op.add_column('test_runs', + sa.Column('cluster_id', sa.Integer(), nullable=False)) + op.drop_column('test_runs', u'type') + op.drop_column('test_runs', u'stats') + op.drop_column('test_runs', u'external_id') + op.drop_column('test_runs', u'data') + op.alter_column('test_runs', 'status', + existing_type=sa.VARCHAR(length=128), + nullable=False) + op.add_column('test_sets', sa.Column('cleanup_path', sa.String(length=128), + nullable=True)) + op.add_column('test_sets', + sa.Column('meta', fields.JsonField(), nullable=True)) + op.add_column('test_sets', + sa.Column('driver', sa.String(length=128), nullable=True)) + op.add_column('test_sets', + sa.Column('additional_arguments', fields.ListField(), + nullable=True)) + op.add_column('test_sets', + sa.Column('test_path', sa.String(length=256), nullable=True)) + op.drop_column('test_sets', u'data') + op.add_column('tests', sa.Column('description', sa.Text(), nullable=True)) + op.add_column('tests', sa.Column('traceback', sa.Text(), nullable=True)) + op.add_column('tests', sa.Column('step', sa.Integer(), nullable=True)) + op.add_column('tests', sa.Column('meta', fields.JsonField(), + nullable=True)) + op.add_column('tests', + sa.Column('duration', sa.String(length=512), nullable=True)) + op.add_column('tests', sa.Column('message', sa.Text(), nullable=True)) + op.add_column('tests', + sa.Column('time_taken', sa.Float(), nullable=True)) + op.drop_column('tests', u'taken') + op.drop_column('tests', u'data') + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('tests', sa.Column(u'data', sa.TEXT(), nullable=True)) + op.add_column('tests', sa.Column(u'taken', + postgresql.DOUBLE_PRECISION(precision=53), + nullable=True)) + op.drop_column('tests', 'time_taken') + op.drop_column('tests', 'message') + op.drop_column('tests', 'duration') + op.drop_column('tests', 'meta') + op.drop_column('tests', 'step') + op.drop_column('tests', 'traceback') + op.drop_column('tests', 'description') + op.add_column('test_sets', sa.Column(u'data', sa.TEXT(), nullable=True)) + op.drop_column('test_sets', 'test_path') + op.drop_column('test_sets', 'additional_arguments') + op.drop_column('test_sets', 'driver') + op.drop_column('test_sets', 'meta') + op.drop_column('test_sets', 'cleanup_path') + op.alter_column('test_runs', 'status', + existing_type=sa.VARCHAR(length=128), + nullable=True) + op.add_column('test_runs', sa.Column(u'data', sa.TEXT(), nullable=True)) + op.add_column('test_runs', + sa.Column(u'external_id', sa.VARCHAR(length=128), + nullable=True)) + op.add_column('test_runs', sa.Column(u'stats', sa.TEXT(), nullable=True)) + op.add_column('test_runs', + sa.Column(u'type', sa.VARCHAR(length=128), nullable=True)) + op.drop_column('test_runs', 'cluster_id') + op.drop_column('test_runs', 'meta') + op.drop_column('test_runs', 'test_set_id') + ### end Alembic commands ### diff --git a/fuel_plugin/ostf_adapter/storage/migrations/versions/1e2c38f575fb_storage_refactor.py b/fuel_plugin/ostf_adapter/storage/migrations/versions/1e2c38f575fb_storage_refactor.py new file mode 100644 index 00000000..2befb724 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/versions/1e2c38f575fb_storage_refactor.py @@ -0,0 +1,54 @@ +# Copyright 2013 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. + +"""Storage refactor + +Revision ID: 1e2c38f575fb +Revises: 1fcb29d29e03 +Create Date: 2013-07-02 22:43:00.844574 + +""" + +# revision identifiers, used by Alembic. +revision = '1e2c38f575fb' +down_revision = '1fcb29d29e03' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'test_sets', + sa.Column('id', sa.String(length=128), nullable=False), + sa.Column('description', sa.String(length=128), nullable=True), + sa.Column('data', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.add_column(u'test_runs', sa.Column('external_id', sa.String(length=128), + nullable=True)) + op.add_column(u'test_runs', sa.Column('stats', sa.Text(), nullable=True)) + op.add_column(u'tests', sa.Column('test_set_id', sa.String(length=128), + nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column(u'tests', 'test_set_id') + op.drop_column(u'test_runs', 'stats') + op.drop_column(u'test_runs', 'external_id') + op.drop_table('test_sets') + ### end Alembic commands ### diff --git a/fuel_plugin/ostf_adapter/storage/migrations/versions/1fcb29d29e03_initial_migration.py b/fuel_plugin/ostf_adapter/storage/migrations/versions/1fcb29d29e03_initial_migration.py new file mode 100644 index 00000000..b7b30c74 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/versions/1fcb29d29e03_initial_migration.py @@ -0,0 +1,60 @@ +# Copyright 2013 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. + +"""initial migration + +Revision ID: 1fcb29d29e03 +Revises: None +Create Date: 2013-06-26 17:40:23.908062 + +""" + +# revision identifiers, used by Alembic. +revision = '1fcb29d29e03' +down_revision = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + + op.create_table( + 'test_runs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('type', sa.String(length=128), nullable=True), + sa.Column('data', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + op.create_table( + 'tests', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=512), nullable=True), + sa.Column('status', sa.String(length=128), nullable=True), + sa.Column('taken', sa.Float(), nullable=True), + sa.Column('data', sa.Text(), nullable=True), + sa.Column('test_run_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['test_run_id'], ['test_runs.id'], ), + sa.PrimaryKeyConstraint('id') + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('tests') + op.drop_table('test_runs') + ### end Alembic commands ### diff --git a/fuel_plugin/ostf_adapter/storage/migrations/versions/3e45add6471_add_title_column.py b/fuel_plugin/ostf_adapter/storage/migrations/versions/3e45add6471_add_title_column.py new file mode 100644 index 00000000..c25c9c9d --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/versions/3e45add6471_add_title_column.py @@ -0,0 +1,27 @@ +"""add title column + +Revision ID: 3e45add6471 +Revises: 1b28f7bc6476 +Create Date: 2013-08-07 17:01:59.306413 + +""" + +# revision identifiers, used by Alembic. +revision = '3e45add6471' +down_revision = '1b28f7bc6476' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): +### commands auto generated by Alembic - please adjust! ### + op.add_column('tests', sa.Column('title', sa.String(length=512), + nullable=True)) + ### end Alembic commands ### + + +def downgrade(): +### commands auto generated by Alembic - please adjust! ### + op.drop_column('tests', 'title') + ### end Alembic commands ### diff --git a/fuel_plugin/ostf_adapter/storage/migrations/versions/4e9905279776_add_started_at_ended.py b/fuel_plugin/ostf_adapter/storage/migrations/versions/4e9905279776_add_started_at_ended.py new file mode 100644 index 00000000..c40d79b9 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/versions/4e9905279776_add_started_at_ended.py @@ -0,0 +1,44 @@ +# Copyright 2013 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. + +"""Add started_at , ended_at on TestRun model + +Revision ID: 4e9905279776 +Revises: 12340edd992d +Create Date: 2013-07-04 12:10:49.219213 + +""" + +# revision identifiers, used by Alembic. +revision = '4e9905279776' +down_revision = '12340edd992d' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('test_runs', + sa.Column('started_at', sa.DateTime(), nullable=True)) + op.add_column('test_runs', + sa.Column('ended_at', sa.DateTime(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('test_runs', 'ended_at') + op.drop_column('test_runs', 'started_at') + ### end Alembic commands ### diff --git a/fuel_plugin/ostf_adapter/storage/migrations/versions/__init__.py b/fuel_plugin/ostf_adapter/storage/migrations/versions/__init__.py new file mode 100644 index 00000000..bb927c47 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/migrations/versions/__init__.py @@ -0,0 +1 @@ +__author__ = 'tleontovich' diff --git a/fuel_plugin/ostf_adapter/storage/models.py b/fuel_plugin/ostf_adapter/storage/models.py new file mode 100644 index 00000000..9d67e7a0 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/models.py @@ -0,0 +1,275 @@ +# Copyright 2013 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. + +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy import desc +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import joinedload, relationship, object_mapper +from fuel_plugin.ostf_adapter import nose_plugin +from fuel_plugin.ostf_adapter.storage import fields, engine + + +BASE = declarative_base() + + +class TestRun(BASE): + + __tablename__ = 'test_runs' + + STATES = ( + 'running', + 'finished' + ) + + id = sa.Column(sa.Integer(), primary_key=True) + cluster_id = sa.Column(sa.Integer(), nullable=False) + status = sa.Column(sa.Enum(*STATES, name='test_run_states'), + nullable=False) + meta = sa.Column(fields.JsonField()) + started_at = sa.Column(sa.DateTime, default=datetime.utcnow) + ended_at = sa.Column(sa.DateTime) + test_set_id = sa.Column(sa.String(128), sa.ForeignKey('test_sets.id')) + + test_set = relationship('TestSet', backref='test_runs') + tests = relationship('Test', backref='test_run', order_by='Test.name') + + def update(self, session, status): + self.status = status + if status == 'finished': + self.ended_at = datetime.utcnow() + session.add(self) + + @property + def enabled_tests(self): + return [test.name for test in self.tests if test.status != 'disabled'] + + def is_finished(self): + return self.status == 'finished' + + @property + def frontend(self): + test_run_data = { + 'id': self.id, + 'testset': self.test_set_id, + 'meta': self.meta, + 'cluster_id': self.cluster_id, + 'status': self.status, + 'started_at': self.started_at, + 'ended_at': self.ended_at, + 'tests': [] + } + if self.tests: + test_run_data['tests'] = [test.frontend for test in self.tests] + return test_run_data + + @classmethod + def add_test_run(cls, session, test_set, cluster_id, status='running', + tests=None): + predefined_tests = tests or [] + tests = session.query(Test).filter_by( + test_set_id=test_set, test_run_id=None) + test_run = cls(test_set_id=test_set, cluster_id=cluster_id, + status=status) + session.add(test_run) + for test in tests: + session.add(test.copy_test(test_run, predefined_tests)) + return test_run + + @classmethod + def get_last_test_run(cls, session, test_set, cluster_id): + test_run = session.query(cls). \ + filter_by(cluster_id=cluster_id, test_set_id=test_set). \ + order_by(desc(cls.id)).first() + return test_run + + @classmethod + def get_test_results(cls): + session = engine.get_session() + test_runs = session.query(cls). \ + options(joinedload('tests')). \ + order_by(desc(cls.id)) + session.commit() + session.close() + return test_runs + + @classmethod + def get_test_run(cls, session, test_run_id, joined=False): + if not joined: + test_run = session.query(cls). \ + filter_by(id=test_run_id).first() + else: + test_run = session.query(cls). \ + options(joinedload('tests')). \ + filter_by(id=test_run_id).first() + return test_run + + @classmethod + def update_test_run(cls, session, test_run_id, status=None): + updated_data = {} + if status: + updated_data['status'] = status + if status in ['finished']: + updated_data['ended_at'] = datetime.utcnow() + session.query(cls). \ + filter(cls.id == test_run_id). \ + update(updated_data, synchronize_session=False) + + @classmethod + def is_last_running(cls, session, test_set, cluster_id): + test_run = cls.get_last_test_run(session, test_set, cluster_id) + return not bool(test_run) or test_run.is_finished() + + @classmethod + def start(cls, session, test_set, metadata, tests): + plugin = nose_plugin.get_plugin(test_set.driver) + if cls.is_last_running(session, test_set.id, + metadata['cluster_id']): + test_run = cls.add_test_run( + session, test_set.id, + metadata['cluster_id'], tests=tests) + plugin.run(test_run, test_set) + return test_run.frontend + return {} + + def restart(self, session, tests=None): + """Restart test run with + if tests given they will be enabled + """ + if TestRun.is_last_running(session, + self.test_set_id, + self.cluster_id): + plugin = nose_plugin.get_plugin(self.test_set.driver) + self.update(session, 'running') + if tests: + Test.update_test_run_tests( + session, self.id, tests) + plugin.run(self, self.test_set, tests) + return self.frontend + return {} + + def stop(self, session): + """Stop test run if running + """ + plugin = nose_plugin.get_plugin(self.test_set.driver) + killed = plugin.kill( + self.id, self.cluster_id, + cleanup=self.test_set.cleanup_path) + if killed: + Test.update_running_tests( + session, self.id, status='stopped') + return self.frontend + + +class TestSet(BASE): + + __tablename__ = 'test_sets' + + id = sa.Column(sa.String(128), primary_key=True) + description = sa.Column(sa.String(256)) + test_path = sa.Column(sa.String(256)) + driver = sa.Column(sa.String(128)) + additional_arguments = sa.Column(fields.ListField()) + cleanup_path = sa.Column(sa.String(128)) + meta = sa.Column(fields.JsonField()) + + tests = relationship('Test', + backref='test_set', order_by='Test.name') + + @property + def frontend(self): + return {'id': self.id, 'name': self.description} + + @classmethod + def get_test_set(cls, session, test_set): + return session.query(cls).filter_by(id=test_set).first() + + +class Test(BASE): + + __tablename__ = 'tests' + + STATES = ( + 'wait_running', + 'running', + 'failure', + 'success', + 'error', + 'stopped' + ) + + id = sa.Column(sa.Integer(), primary_key=True) + name = sa.Column(sa.String(512)) + title = sa.Column(sa.String(512)) + description = sa.Column(sa.Text()) + duration = sa.Column(sa.String(512)) + message = sa.Column(sa.Text()) + traceback = sa.Column(sa.Text()) + status = sa.Column(sa.Enum(*STATES, name='test_states')) + step = sa.Column(sa.Integer()) + time_taken = sa.Column(sa.Float()) + meta = sa.Column(fields.JsonField()) + + test_set_id = sa.Column(sa.String(128), sa.ForeignKey('test_sets.id')) + test_run_id = sa.Column(sa.Integer(), sa.ForeignKey('test_runs.id')) + + @property + def frontend(self): + return { + 'id': self.name, + 'testset': self.test_set_id, + 'name': self.title, + 'description': self.description, + 'duration': self.duration, + 'message': self.message, + 'step': self.step, + 'status': self.status, + 'taken': self.time_taken + } + + @classmethod + def add_result(cls, session, test_run_id, test_name, data): + session.query(cls).\ + filter_by(name=test_name, test_run_id=test_run_id).\ + update(data, synchronize_session=False) + + @classmethod + def update_running_tests(cls, session, test_run_id, status='stopped'): + session.query(cls). \ + filter(cls.test_run_id == test_run_id, + cls.status.in_(('running', 'wait_running'))). \ + update({'status': status}, synchronize_session=False) + + @classmethod + def update_test_run_tests(cls, session, test_run_id, + tests_names, status='wait_running'): + session.query(cls). \ + filter(cls.name.in_(tests_names), + cls.test_run_id == test_run_id). \ + update({'status': status}, synchronize_session=False) + + def copy_test(self, test_run, predefined_tests): + new_test = self.__class__() + mapper = object_mapper(self) + primary_keys = set([col.key for col in mapper.primary_key]) + for column in mapper.iterate_properties: + if column.key not in primary_keys: + setattr(new_test, column.key, getattr(self, column.key)) + new_test.test_run_id = test_run.id + if predefined_tests and new_test.name not in predefined_tests: + new_test.status = 'disabled' + else: + new_test.status = 'wait_running' + return new_test diff --git a/fuel_plugin/ostf_adapter/storage/storage_utils.py b/fuel_plugin/ostf_adapter/storage/storage_utils.py new file mode 100644 index 00000000..62ff41c4 --- /dev/null +++ b/fuel_plugin/ostf_adapter/storage/storage_utils.py @@ -0,0 +1,25 @@ +# Copyright 2013 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. + + +from fuel_plugin.ostf_adapter.storage import models + + +def update_all_running_test_runs(session): + session.query(models.TestRun). \ + filter_by(status='running'). \ + update({'status': 'finished'}, synchronize_session=False) + session.query(models.Test). \ + filter(models.Test.status.in_(('running', 'wait_running'))). \ + update({'status': 'stopped'}, synchronize_session=False) diff --git a/fuel_plugin/ostf_adapter/wsgi/__init__.py b/fuel_plugin/ostf_adapter/wsgi/__init__.py new file mode 100644 index 00000000..141ca6ab --- /dev/null +++ b/fuel_plugin/ostf_adapter/wsgi/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. diff --git a/fuel_plugin/ostf_adapter/wsgi/app.py b/fuel_plugin/ostf_adapter/wsgi/app.py new file mode 100644 index 00000000..253a7293 --- /dev/null +++ b/fuel_plugin/ostf_adapter/wsgi/app.py @@ -0,0 +1,52 @@ +# Copyright 2013 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. + +import pecan +from fuel_plugin.ostf_adapter.wsgi import hooks + + +PECAN_DEFAULT = { + 'server': { + 'host': '0.0.0.0', + 'port': 8989 + }, + 'app': { + 'root': 'fuel_plugin.ostf_adapter.wsgi.root.RootController', + 'modules': ['fuel_plugin.ostf_adapter.wsgi'] + }, + 'nailgun': { + 'host': '127.0.0.1', + 'port': 8000 + }, + 'dbpath': 'postgresql+psycopg2://ostf:ostf@localhost/ostf', + 'debug': False, + 'debug_tests': 'functional/dummy_tests' +} + + +def setup_config(pecan_config): + pecan_config.update(PECAN_DEFAULT) + pecan.conf.update(pecan_config) + + +def setup_app(config=None): + setup_config(config or {}) + app_hooks = [hooks.SessionHook()] + app = pecan.make_app( + pecan.conf.app.root, + debug=pecan.conf.debug, + force_canonical=True, + hooks=app_hooks + ) + return app diff --git a/fuel_plugin/ostf_adapter/wsgi/controllers.py b/fuel_plugin/ostf_adapter/wsgi/controllers.py new file mode 100644 index 00000000..6e8df733 --- /dev/null +++ b/fuel_plugin/ostf_adapter/wsgi/controllers.py @@ -0,0 +1,136 @@ +# Copyright 2013 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. + +import json +import logging + +from sqlalchemy import func +from sqlalchemy.orm import joinedload + +from pecan import rest, expose, request +from fuel_plugin.ostf_adapter.storage import models + + +LOG = logging.getLogger(__name__) + + +class BaseRestController(rest.RestController): + def _handle_get(self, method, remainder): + if len(remainder): + method_name = remainder[0] + if method.upper() in self._custom_actions.get(method_name, []): + controller = self._find_controller( + 'get_%s' % method_name, + method_name + ) + if controller: + return controller, remainder[1:] + return super(BaseRestController, self)._handle_get(method, remainder) + + +class TestsController(BaseRestController): + + @expose('json') + def get_one(self, test_name): + raise NotImplementedError() + + @expose('json') + def get_all(self): + with request.session.begin(subtransactions=True): + return [item.frontend for item + in request.session.query(models.Test).all()] + + +class TestsetsController(BaseRestController): + + @expose('json') + def get_one(self, test_set): + with request.session.begin(subtransactions=True): + test_set = request.session.query(models.TestSet)\ + .filter_by(id=test_set).first() + if test_set and isinstance(test_set, models.TestSet): + return test_set.frontend + return {} + + @expose('json') + def get_all(self): + with request.session.begin(subtransactions=True): + return [item.frontend for item + in request.session.query(models.TestSet).all()] + + +class TestrunsController(BaseRestController): + + _custom_actions = { + 'last': ['GET'], + } + + @expose('json') + def get_all(self): + with request.session.begin(subtransactions=True): + return [item.frontend for item + in request.session.query(models.TestRun).all()] + + @expose('json') + def get_one(self, test_run_id): + with request.session.begin(subtransactions=True): + test_run = request.session.query(models.TestRun)\ + .filter_by(id=test_run_id).first() + if test_run and isinstance(test_run, models.TestRun): + return test_run.frontend + return {} + + @expose('json') + def get_last(self, cluster_id): + with request.session.begin(subtransactions=True): + test_run_ids = request.session.query(func.max(models.TestRun.id)) \ + .group_by(models.TestRun.test_set_id).\ + filter_by(cluster_id=cluster_id) + test_runs = request.session.query(models.TestRun). \ + options(joinedload('tests')). \ + filter(models.TestRun.id.in_(test_run_ids)) + return [item.frontend for item in test_runs] + + @expose('json') + def post(self): + test_runs = json.loads(request.body) + res = [] + with request.session.begin(subtransactions=True): + for test_run in test_runs: + test_set = test_run['testset'] + metadata = test_run['metadata'] + tests = test_run.get('tests', []) + + test_set = models.TestSet.get_test_set( + request.session, test_set) + test_run = models.TestRun.start( + request.session, test_set, metadata, tests) + res.append(test_run) + return res + + @expose('json') + def put(self): + test_runs = json.loads(request.body) + data = [] + with request.session.begin(subtransactions=True): + for test_run in test_runs: + status = test_run.get('status') + tests = test_run.get('tests', []) + test_run = models.TestRun.get_test_run(request.session, + test_run['id']) + if status == 'stopped': + data.append(test_run.stop(request.session)) + elif status == 'restarted': + data.append(test_run.restart(request.session, tests=tests)) + return data diff --git a/fuel_plugin/ostf_adapter/wsgi/hooks.py b/fuel_plugin/ostf_adapter/wsgi/hooks.py new file mode 100644 index 00000000..b08b7cb5 --- /dev/null +++ b/fuel_plugin/ostf_adapter/wsgi/hooks.py @@ -0,0 +1,55 @@ +# Copyright 2013 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. + +import logging + +from stevedore import extension + +from pecan import hooks +from fuel_plugin.ostf_adapter.storage import engine + + +LOG = logging.getLogger(__name__) + + +class ExceptionHandlingHook(hooks.PecanHook): + def on_error(self, state, e): + LOG.exception('Pecan state %s', state) + + +# class StorageHook(hooks.PecanHook): +# def __init__(self): +# super(StorageHook, self).__init__() +# self.storage = storage.get_storage() +# +# def before(self, state): +# state.request.storage = self.storage + + +class PluginsHook(hooks.PecanHook): + PLUGINS_NAMESPACE = 'plugins' + + def __init__(self): + super(PluginsHook, self).__init__() + self.plugin_manager = extension.ExtensionManager( + self.PLUGINS_NAMESPACE, invoke_on_load=True) + + def before(self, state): + state.request.plugin_manager = self.plugin_manager + + +class SessionHook(hooks.PecanHook): + + def before(self, state): + state.request.session = engine.get_session() diff --git a/fuel_plugin/ostf_adapter/wsgi/root.py b/fuel_plugin/ostf_adapter/wsgi/root.py new file mode 100644 index 00000000..a6a027a2 --- /dev/null +++ b/fuel_plugin/ostf_adapter/wsgi/root.py @@ -0,0 +1,34 @@ +# Copyright 2013 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. + +from pecan import expose +from fuel_plugin.ostf_adapter.wsgi import controllers + + +class V1Controller(object): + """ + TODO Rewrite it with wsme expose + """ + + tests = controllers.TestsController() + testsets = controllers.TestsetsController() + testruns = controllers.TestrunsController() + + +class RootController(object): + v1 = V1Controller() + + @expose('json', generic=True) + def index(self): + return {} diff --git a/fuel_plugin/ostf_client/__init__.py b/fuel_plugin/ostf_client/__init__.py new file mode 100644 index 00000000..bc8f0bef --- /dev/null +++ b/fuel_plugin/ostf_client/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. \ No newline at end of file diff --git a/fuel_plugin/ostf_client/client.py b/fuel_plugin/ostf_client/client.py new file mode 100644 index 00000000..f72c5e32 --- /dev/null +++ b/fuel_plugin/ostf_client/client.py @@ -0,0 +1,132 @@ +# Copyright 2013 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. + +import requests +from json import dumps +import time + + +class TestingAdapterClient(object): + def __init__(self, url): + self.url = url + + def _request(self, method, url, data=None): + headers = {'content-type': 'application/json'} + + r = requests.request(method, url, data=data, headers=headers, timeout=30.0) + if 2 != r.status_code/100: + raise AssertionError('{method} "{url}" responded with ' + '"{code}" status code'.format( + method=method.upper(), + url=url, code=r.status_code)) + return r + + def __getattr__(self, item): + getters = ['testsets', 'tests', 'testruns'] + if item in getters: + url = ''.join([self.url, '/', item]) + return lambda: self._request('GET', url) + + def testruns_last(self, cluster_id): + url = ''.join([self.url, '/testruns/last/', + str(cluster_id)]) + return self._request('GET', url) + + def start_testrun(self, testset, cluster_id): + return self.start_testrun_tests(testset, [], cluster_id) + + def start_testrun_tests(self, testset, tests, cluster_id): + url = ''.join([self.url, '/testruns']) + data = [{'testset': testset, + 'tests': tests, + 'metadata': {'cluster_id': str(cluster_id)}}] + return self._request('POST', url, data=dumps(data)) + + def stop_testrun(self, testrun_id): + url = ''.join([self.url, '/testruns']) + data = [{"id": testrun_id, + "status": "stopped"}] + return self._request("PUT", url, data=dumps(data)) + + def stop_testrun_last(self, testset, cluster_id): + latest = self.testruns_last(cluster_id).json() + testrun_id = [item['id'] for item in latest + if item['testset'] == testset][0] + return self.stop_testrun(testrun_id) + + def restart_tests(self, tests, testrun_id): + url = ''.join([self.url, '/testruns']) + body = [{'id': str(testrun_id), + 'tests': tests, + 'status': 'restarted'}] + return self._request('PUT', url, data=dumps(body)) + + def restart_tests_last(self, testset, tests, cluster_id): + latest = self.testruns_last(cluster_id).json() + testrun_id = [item['id'] for item in latest + if item['testset'] == testset][0] + return self.restart_tests(tests, testrun_id) + + def _with_timeout(self, action, testset, cluster_id, + timeout, polling=5, polling_hook=None): + start_time = time.time() + json = action().json() + + if json == [{}]: + self.stop_testrun_last(testset, cluster_id) + time.sleep(1) + action() + + while time.time() - start_time <= timeout: + time.sleep(polling) + + current_response = self.testruns_last(cluster_id) + if polling_hook: + polling_hook(current_response) + current_status, current_tests = \ + [(item['status'], item['tests']) for item + in current_response.json() if item['testset'] == testset][0] + + if current_status == 'finished': + break + else: + stopped_response = self.stop_testrun_last(testset, cluster_id) + if polling_hook: + polling_hook(stopped_response) + stopped_response = self.testruns_last(cluster_id) + stopped_status = [item['status'] for item in stopped_response.json() + if item['testset'] == testset][0] + + msg = '{0} is still in {1} state. Now the state is {2}'.format( + testset, current_status, stopped_status) + msg_tests = '\n'.join(['{0} -> {1}, {2}'.format( + item['id'], item['status'], item['taken']) + for item in current_tests]) + raise AssertionError('\n'.join([msg, msg_tests])) + return current_response + + def run_with_timeout(self, testset, tests, cluster_id, timeout, polling=5, + polling_hook=None): + action = lambda: self.start_testrun_tests(testset, tests, cluster_id) + return self._with_timeout(action, testset, cluster_id, timeout, + polling, polling_hook) + + def run_testset_with_timeout(self, testset, cluster_id, timeout, + polling=5, polling_hook=None): + return self.run_with_timeout(testset, [], cluster_id, timeout, + polling, polling_hook) + + def restart_with_timeout(self, testset, tests, cluster_id, timeout): + action = lambda: self.restart_tests_last(testset, tests, cluster_id) + return self._with_timeout(action, testset, cluster_id, timeout) diff --git a/fuel_plugin/ostf_client/ostf.py b/fuel_plugin/ostf_client/ostf.py new file mode 100755 index 00000000..e864564f --- /dev/null +++ b/fuel_plugin/ostf_client/ostf.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python +"""Openstack testing framework client + +Usage: ostf.py run [-q] [--id=] [--tests=] [--url=] [--timeout=] + ostf.py list [] + + -q Show test run result only after finish + -h --help Show this screen + --tests= Tests to run + --id= Cluster id to use, default: OSTF_CLUSTER_ID or "1" + --url= Ostf url, default: OSTF_URL or http://0.0.0.0:8989/v1 + --timeout= Amount of time after which test_run will be stopped [default: 60] + +""" +import os +import sys + +from requests import get + +from docopt import docopt +from clint.textui import puts, colored, columns, indent +from blessings import Terminal +from fuel_plugin.ostf_client.client import TestingAdapterClient + + +def get_cluster_id(): + try: + r = get('http://localhost:8000/api/clusters').json() + except: + return 0 + return next(item['id'] for item in r) + + +def main(): + t = Terminal() + args = docopt(__doc__, version='0.1') + test_set = args[''] + cluster_id = args['--id'] or os.environ.get('OSTF_CLUSTER_ID') \ + or get_cluster_id() or '1' + tests = args['--tests'] or [] + timeout = args['--timeout'] + quite = args['-q'] + url = args['--url'] or os.environ.get('OSTF_URL') \ + or 'http://0.0.0.0:8989/v1' + + client = TestingAdapterClient(url) + + def run(): + col = 60 + + statused = dict( + running='running', + wait_running='wait_running', + success=colored.green('success'), + finished=colored.blue('finished'), + failure=colored.red('failure'), + error=colored.red('error'), + stopped=colored.red('stopped') + ) + + tests = [test['id'].split('.')[-1] + for test in client.tests().json() + if test['testset'] == test_set] + + def print_results(item): + if isinstance(item, dict): + puts(columns([item['id'].split('.')[-1], col], + [statused[item['status']], col])) + else: + puts(columns([item[0], col], + [statused.get(item[1], item[1]), col])) + + def move_up(lines): + for _ in range(lines): + print t.move_up + t.move_left, + + def polling_hook(response): + current_status, current_tests = next( + (item['status'], item['tests']) for item in response.json() + if item['testset'] == test_set) + + move_up(len(current_tests) + 1) + + for test in current_tests: + print_results(test) + print_results(['General', current_status]) + + def quite_polling_hook(response): + if not quite_polling_hook.__dict__.get('published_tests'): + quite_polling_hook.__dict__['published_tests'] = [] + + current_status, current_tests = next( + (item['status'], item['tests']) for item in response.json() + if item['testset'] == test_set) + + finished_statuses = ['success', 'failure', 'stopped', 'error'] + + finished_tests = [item for item in current_tests + if item['status'] in finished_statuses + and item + not in quite_polling_hook.__dict__['published_tests']] + + for test in finished_tests: + print_results(test) + quite_polling_hook.__dict__['published_tests'].append(test) + + if current_status == 'finished': + print_results(['General', current_status]) + + if quite: + polling_hook = quite_polling_hook + else: + for test in tests: + print_results([test, 'wait_running']) + print_results(['General', 'running']) + + try: + r = client.run_testset_with_timeout(test_set, cluster_id, + timeout, 2, polling_hook) + except AssertionError as e: + return 1 + except KeyboardInterrupt as e: + r = client.stop_testrun_last(test_set, cluster_id) + print t.move_left + t.move_left, + polling_hook(r) + + tests = next(item['tests'] for item in r.json()) + return any(item['status'] != 'success' for item in tests) + + def list_tests(): + result = client.tests().json() + tests = (test for test in result if test['testset'] == test_set) + + col = 60 + puts(columns([(colored.red("ID")), col], + [(colored.red("NAME")), None])) + + for test in tests: + test_id = test['id'].split('.')[-1] + puts(columns([test_id, col], [test['name'], None])) + + return 0 + + def list_test_sets(): + result = client.testsets().json() + + col = 60 + puts(columns([(colored.red("ID")), col], + [(colored.red("NAME")), None])) + + for test_set in result: + puts(columns([test_set['id'], col], [test_set['name'], None])) + return 0 + + if args['run']: + return run() + if test_set: + return list_tests() + return list_test_sets() + +if __name__ == '__main__': + sys.exit(main()) diff --git a/fuel_plugin/tests/__init__.py b/fuel_plugin/tests/__init__.py new file mode 100644 index 00000000..bc8f0bef --- /dev/null +++ b/fuel_plugin/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. \ No newline at end of file diff --git a/fuel_plugin/tests/functional/__init__.py b/fuel_plugin/tests/functional/__init__.py new file mode 100644 index 00000000..aa0c8ba1 --- /dev/null +++ b/fuel_plugin/tests/functional/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2013 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. + + diff --git a/fuel_plugin/tests/functional/base.py b/fuel_plugin/tests/functional/base.py new file mode 100644 index 00000000..3215c2ad --- /dev/null +++ b/fuel_plugin/tests/functional/base.py @@ -0,0 +1,134 @@ +# Copyright 2013 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. + +from functools import wraps +from unittest import TestCase + +from fuel_plugin.ostf_client.client import TestingAdapterClient + + +class EmptyResponseError(Exception): + pass + + +class Response(object): + """This is testing_adapter response object""" + test_name_mapping = {} + + def __init__(self, response): + self.is_empty = False + if isinstance(response, list): + self._parse_json(response) + self.request = None + else: + self._parse_json(response.json()) + self.request = '{0} {1} \n with {2}'\ + .format(response.request.method, response.request.url, response.request.body) + + def __getattr__(self, item): + if item in self.test_sets or item in self._tests: + return self.test_sets.get(item) or self._tests.get(item) + else: + return super(type(self), self).__delattr__(item) + + def __str__(self): + if self.is_empty: + return "Empty" + return self.test_sets.__str__() + + @classmethod + def set_test_name_mapping(cls, mapping): + cls.test_name_mapping = mapping + + def _parse_json(self, json): + if json == [{}]: + self.is_empty = True + return + else: + self.is_empty = False + + self.test_sets = {} + self._tests = {} + for testset in json: + self.test_sets[testset.pop('testset')] = testset + self._tests = dict((self._friendly_name(item.get('id')), item) for item in testset['tests']) + + def _friendly_name(self, name): + return self.test_name_mapping.get(name, name) + + +class AdapterClientProxy(object): + + def __init__(self, url): + self.client = TestingAdapterClient(url) + + def __getattr__(self, item): + if item in TestingAdapterClient.__dict__: + call = getattr(self.client, item) + return self._decorate_call(call) + def _friendly_map(self, mapping): + Response.set_test_name_mapping(mapping) + + def _decorate_call(self, call): + @wraps(call) + def inner(*args, **kwargs): + r = call(*args, **kwargs) + return Response(r) + return inner + + + + +class SubsetException(Exception): + pass + + +class BaseAdapterTest(TestCase): + def compare(self, response, comparable): + if response.is_empty: + msg = '{0} is empty'.format(response.request) + raise AssertionError(msg) + if not isinstance(comparable, Response): + comparable = Response(comparable) + test_set = comparable.test_sets.keys()[0] + test_set_data = comparable.test_sets[test_set] + tests = comparable._tests + diff = [] + + for item in test_set_data: + if item == 'tests': + continue + if response.test_sets[test_set][item] != test_set_data[item]: + msg = 'Actual "{0}" != expected "{1}" in {2}.{3}'.format(response.test_sets[test_set][item], + test_set_data[item], test_set, item) + diff.append(msg) + + for test_name, test in tests.iteritems(): + for t in test: + if t == 'id': + continue + if response._tests[test_name][t] != test[t]: + msg = 'Actual "{0}" != expected"{1}" in {2}.{3}.{4}'.format(response._tests[test_name][t], + test[t], test_set, test_name, t) + diff.append(msg) + if diff: + raise AssertionError(diff) + + @staticmethod + def init_client(url, mapping): + ac = AdapterClientProxy(url) + ac._friendly_map(mapping) + return ac + + diff --git a/fuel_plugin/tests/functional/config.py b/fuel_plugin/tests/functional/config.py new file mode 100644 index 00000000..412eedc2 --- /dev/null +++ b/fuel_plugin/tests/functional/config.py @@ -0,0 +1,107 @@ +# Copyright 2013 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. + +CONFIG = {'compute-admin_password': 'nova', + 'compute-admin_tenant_name': '', + 'compute-admin_username': '', + 'compute_allow_tenant_isolation': 'True', + 'compute_allow_tenant_reuse': 'true', + 'compute_block_migrate_supports_cinder_iscsi': 'false', + 'compute_build_interval': '3', + 'compute_build_timeout': '300', + 'compute_catalog_type': 'compute', + 'compute_change_password_available': 'False', + 'compute_controller_node': '10.30.1.101', + 'compute_controller_node_name': 'fuel-controller-01.localdomain.', + 'compute_controller_node_ssh_password': 'r00tme', + 'compute_controller_node_ssh_user': 'root', + 'compute_create_image_enabled': 'true', + 'compute_disk_config_enabled_override': 'true', + 'compute_enabled_services': 'nova-cert, nova-consoleauth, nova-scheduler, nova-conductor, nova-cert, nova-consoleauth, nova-scheduler, nova-conductor, nova-cert, nova-consoleauth, nova-scheduler, nova-conductor, nova-compute', + 'compute_fixed_network_name': 'private', + 'compute_flavor_ref': '1', + 'compute_flavor_ref_alt': '2', + 'compute_image_alt_ssh_user': 'cirros', + 'compute_image_ref': '53734a0d-60a8-4689-b7c8-3c14917a7197', + 'compute_image_ref_alt': '53734a0d-60a8-4689-b7c8-3c14917a7197', + 'compute_image_ssh_user': 'cirros', + 'compute_ip_version_for_ssh': '4', + 'compute_live_migration_available': 'False', + 'compute_network_for_ssh': 'private', + 'compute_resize_available': 'true', + 'compute_run_ssh': 'false', + 'compute_ssh_channel_timeout': '60', + 'compute_ssh_timeout': '300', + 'compute_ssh_user': 'cirros', + 'compute_use_block_migration_for_live_migration': 'False', + 'identity_admin_password': 'nova', + 'identity_admin_tenant_name': 'admin', + 'identity_admin_username': 'admin', + 'identity_alt_password': 'nova', + 'identity_alt_tenant_name': 'alt_demo', + 'identity_alt_username': 'alt_demo', + 'identity_catalog_type': 'identity', + 'identity_disable_ssl_certificate_validation': 'False', + 'identity_password': 'nova', + 'identity_region': 'RegionOne', + 'identity_strategy': 'keystone', + 'identity_tenant_name': 'admin', + 'identity_uri': 'http://172.18.164.70:5000/v2.0/', + 'identity_url': 'http://172.18.164.70/', + 'identity_username': 'admin', + 'image_api_version': '1', + 'image_catalog_type': 'image', + 'image_http_image': 'http://download.cirros-cloud.net/0.3.1/cirros-0.3.1-x86_64-uec.tar.gz', + 'network_api_version': '2.0', + 'network_catalog_type': 'network', + 'network_public_network_id': 'cdb94175-2002-449f-be41-6b8afce8de13', + 'network_public_router_id': '2a6bf65b-01f7-4c91-840a-2b5f676e7016', + 'network_quantum_available': 'true', + 'network_tenant_network_cidr': '10.13.0.0/16', + 'network_tenant_network_mask_bits': '28', + 'network_tenant_networks_reachable': 'true', + 'object-storage_catalog_type': 'object-store', + 'object-storage_container_sync_interval': '5', + 'object-storage_container_sync_timeout': '120', + 'smoke_allow_tenant_isolation': 'True', + 'smoke_allow_tenant_reuse': 'true', + 'smoke_block_migrate_supports_cinder_iscsi': 'false', + 'smoke_build_interval': '3', + 'smoke_build_timeout': '300', + 'smoke_catalog_type': 'compute', + 'smoke_change_password_available': 'False', + 'smoke_create_image_enabled': 'true', + 'smoke_disk_config_enabled_override': 'true', + 'smoke_fixed_network_name': 'net04', + 'smoke_flavor_ref': '1', + 'smoke_flavor_ref_alt': '2', + 'smoke_image_alt_ssh_user': 'cirros', + 'smoke_image_ref': '53734a0d-60a8-4689-b7c8-3c14917a7197', + 'smoke_image_ref_alt': '53734a0d-60a8-4689-b7c8-3c14917a7197', + 'smoke_image_ssh_user': 'cirros', + 'smoke_ip_version_for_ssh': '4', + 'smoke_live_migration_available': 'False', + 'smoke_network_for_ssh': 'net04', + 'smoke_resize_available': 'true', + 'smoke_run_ssh': 'false', + 'smoke_ssh_channel_timeout': '60', + 'smoke_ssh_timeout': '320', + 'smoke_ssh_user': 'cirros', + 'smoke_use_block_migration_for_live_migration': 'False', + 'volume_backend1_name': 'BACKEND_1', + 'volume_backend2_name': 'BACKEND_2', + 'volume_build_interval': '3', + 'volume_build_timeout': '300', + 'volume_catalog_type': 'volume', + 'volume_multi_backend_enabled': 'false'} \ No newline at end of file diff --git a/fuel_plugin/tests/functional/dummy_tests/__init__.py b/fuel_plugin/tests/functional/dummy_tests/__init__.py new file mode 100644 index 00000000..bc8f0bef --- /dev/null +++ b/fuel_plugin/tests/functional/dummy_tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. \ No newline at end of file diff --git a/fuel_plugin/tests/functional/dummy_tests/config_test.py b/fuel_plugin/tests/functional/dummy_tests/config_test.py new file mode 100644 index 00000000..2a6429e1 --- /dev/null +++ b/fuel_plugin/tests/functional/dummy_tests/config_test.py @@ -0,0 +1,26 @@ +# Copyright 2013 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. + +from os import environ as env +from unittest import TestCase +from oslo.config import cfg + +opts = [ + cfg.StrOpt('quantum', default='fake') +] + +class Config(TestCase): + def test_config(self): + file_path = env['OSTF_CONF_PATH'] + cfg.CONF diff --git a/fuel_plugin/tests/functional/dummy_tests/general_test.py b/fuel_plugin/tests/functional/dummy_tests/general_test.py new file mode 100644 index 00000000..b7edcc70 --- /dev/null +++ b/fuel_plugin/tests/functional/dummy_tests/general_test.py @@ -0,0 +1,59 @@ +# Copyright 2013 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. + +__profile__ = { + "id": "general_test", + "driver": "nose", + "test_path": "fuel_plugin/tests/functional/dummy_tests/general_test.py", + "description": "General fake tests" +} + +import time +import httplib +import unittest + + +class Dummy_test(unittest.TestCase): + """Class docstring is required? + """ + + def test_fast_pass(self): + """fast pass test + This is a simple always pass test + Duration: 1sec + """ + self.assertTrue(True) + + def test_long_pass(self): + """Will sleep 5 sec + This is a simple test + it will run for 5 sec + Duration: 5sec + """ + time.sleep(5) + self.assertTrue(True) + + def test_fast_fail(self): + """Fast fail + """ + self.assertTrue(False, msg='Something goes wroooong') + + def test_fast_error(self): + """And fast error + """ + conn = httplib.HTTPSConnection('random.random/random') + conn.request("GET", "/random.aspx") + + def test_fail_with_step(self): + self.fail('Step 3 Failed: Fake fail message') diff --git a/fuel_plugin/tests/functional/dummy_tests/stopped_test.py b/fuel_plugin/tests/functional/dummy_tests/stopped_test.py new file mode 100644 index 00000000..f271df10 --- /dev/null +++ b/fuel_plugin/tests/functional/dummy_tests/stopped_test.py @@ -0,0 +1,45 @@ +# Copyright 2013 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. + +__profile__ = { + "id": "stopped_test", + "driver": "nose", + "test_path": "fuel_plugin/tests/functional/dummy_tests/stopped_test.py", + "description": "Long running 25 secs fake tests" +} + +import time +import unittest + + +class dummy_tests_stopped(unittest.TestCase): + + def test_really_long(self): + """This is long running tests + Duration: 25sec + """ + time.sleep(25) + self.assertTrue(True) + + def test_one_no_so_long(self): + """What i am doing here? You ask me???? + """ + time.sleep(5) + self.assertFalse(1 == 2) + + def test_not_long_at_all(self): + """You know.. for testing + Duration: 1sec + """ + self.assertTrue(True) diff --git a/fuel_plugin/tests/functional/manual/__init__.py b/fuel_plugin/tests/functional/manual/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fuel_plugin/tests/functional/manual/create_and_kill.py b/fuel_plugin/tests/functional/manual/create_and_kill.py new file mode 100644 index 00000000..ea33cda0 --- /dev/null +++ b/fuel_plugin/tests/functional/manual/create_and_kill.py @@ -0,0 +1,38 @@ +# Copyright 2013 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. + +import requests +import json +import time +import pprint + +def make_requests(claster_id, test_set): + body = [{'testset': test_set, + 'metadata': {'config': {'identity_uri': 'hommeee'}, + 'cluster_id': claster_id} + } + ] + headers = {'Content-Type': 'application/json'} + response = requests.post('http://172.18.164.37:8777/v1/testruns', data=json.dumps(body), headers=headers) + pprint.pprint(response.json()) + _id = response.json()[0]['id'] + time.sleep(1) + body = [{'id': _id, 'status': 'stopped'}] + update = requests.put('http://172.18.164.37:8777/v1/testruns', data=json.dumps(body), headers=headers) + get_resp = requests.get('http://172.18.164.37:8777/v1/testruns/last/%s' % claster_id) + data = get_resp.json() + pprint.pprint(data) + +if __name__ == '__main__': + make_requests(11, 'fuel_health') \ No newline at end of file diff --git a/fuel_plugin/tests/functional/manual/post_test_run_with_tests.py b/fuel_plugin/tests/functional/manual/post_test_run_with_tests.py new file mode 100644 index 00000000..b16ebb1a --- /dev/null +++ b/fuel_plugin/tests/functional/manual/post_test_run_with_tests.py @@ -0,0 +1,35 @@ +# Copyright 2013 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. + +import requests +import json +import time + +import pprint + +def make_requests(claster_id, test_set): + tests = ['functional.dummy_tests.general_test.Dummy_test.test_fast_pass', + 'functional.dummy_tests.general_test.Dummy_test.test_fast_error'] + body = [{'testset': test_set, + 'tests': tests, + 'metadata': { + 'cluster_id': claster_id}}] + headers = {'Content-Type': 'application/json'} + response = requests.post('http://127.0.0.1:8989/v1/testruns', + data=json.dumps(body), headers=headers) + pprint.pprint(response.json()) + + +if __name__ == '__main__': + make_requests('101', 'plugin_general') \ No newline at end of file diff --git a/fuel_plugin/tests/functional/manual/post_testrun.py b/fuel_plugin/tests/functional/manual/post_testrun.py new file mode 100644 index 00000000..c93b5741 --- /dev/null +++ b/fuel_plugin/tests/functional/manual/post_testrun.py @@ -0,0 +1,35 @@ +# Copyright 2013 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. + +import gevent +from gevent import monkey +monkey.patch_all() +import requests +import json +import time + +import pprint + +def make_requests(claster_id, test_set): + body = [{'testset': test_set, + 'metadata': {'config': {}, + 'cluster_id': claster_id}}] + headers = {'Content-Type': 'application/json'} + response = requests.post('http://127.0.0.1:8989/v1/testruns', + data=json.dumps(body), headers=headers) + pprint.pprint(response.json()) + + +if __name__ == '__main__': + make_requests('308', 'general_test') \ No newline at end of file diff --git a/fuel_plugin/tests/functional/manual/put_test_run_with_tests.py b/fuel_plugin/tests/functional/manual/put_test_run_with_tests.py new file mode 100644 index 00000000..0e907639 --- /dev/null +++ b/fuel_plugin/tests/functional/manual/put_test_run_with_tests.py @@ -0,0 +1,34 @@ +# Copyright 2013 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. + +import requests +import json +import time + +import pprint + +def make_requests(claster_id, test_set): + tests = ['fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_long_pass'] + body = [{'id': claster_id, + 'tests': tests, + 'status': 'restarted', + }] + headers = {'Content-Type': 'application/json'} + response = requests.put('http://127.0.0.1:8989/v1/testruns', + data=json.dumps(body), headers=headers) + pprint.pprint(response.json()) + + +if __name__ == '__main__': + make_requests(370, 'plugin_general') \ No newline at end of file diff --git a/fuel_plugin/tests/functional/manual/update_testrun.py b/fuel_plugin/tests/functional/manual/update_testrun.py new file mode 100644 index 00000000..12dd40cd --- /dev/null +++ b/fuel_plugin/tests/functional/manual/update_testrun.py @@ -0,0 +1,30 @@ +# Copyright 2013 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. + +import requests +import json +import time +import pprint + +def make_requests(claster_id, test_set): + body = [{'id': claster_id, 'status': 'stopped'}] + headers = {'Content-Type': 'application/json'} + update = requests.put( + 'http://localhost:8989/v1/testruns', + data=json.dumps(body), headers=headers) + data = update.json() + pprint.pprint(data) + +if __name__ == '__main__': + make_requests(378, 'plugin_stopped') \ No newline at end of file diff --git a/fuel_plugin/tests/functional/scenario.py b/fuel_plugin/tests/functional/scenario.py new file mode 100644 index 00000000..9246889e --- /dev/null +++ b/fuel_plugin/tests/functional/scenario.py @@ -0,0 +1,66 @@ +# Copyright 2013 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. + +from fuel_plugin.tests.functional.base import BaseAdapterTest + + +class ScenarioTests(BaseAdapterTest): + @classmethod + def setUpClass(cls): + + url = 'http://0.0.0.0:8989/v1' + mapping = {} + + cls.client = cls.init_client(url, mapping) + + def test_random_scenario(self): + testset = "fuel_sanity" + cluster_id = 3 + tests = [] + timeout = 60 + + from pprint import pprint + + for i in range(1): + r = self.client.run_with_timeout(testset, tests, cluster_id, timeout) + pprint([item for item in r.test_sets[testset]['tests']]) + if r.fuel_sanity['status'] == 'stopped': + running_tests = [test for test in r._tests + if r._tests[test]['status'] is 'stopped'] + print "restarting: ", running_tests + result = self.client.restart_with_timeout(testset, running_tests, cluster_id, timeout) + print 'Restart', result + + def test_run_fuel_sanity(self): + testset = "fuel_sanity" + cluster_id = 3 + tests = [] + + timeout = 240 + + r = self.client.run_with_timeout(testset, tests, cluster_id, timeout) + for item in r.fuel_sanity['tests']: + print item['id'].split('.').pop(), item + self.assertEqual(r.fuel_sanity['status'], 'finished') + + def test_run_fuel_smoke(self): + testset = "fuel_smoke" + cluster_id = 3 + tests = [] + timeout = 900 + + r = self.client.run_with_timeout(testset, tests, cluster_id, timeout) + for item in r.fuel_sanity['tests']: + print item['id'].split('.').pop(), item + self.assertEqual(r.fuel_smoke['status'], 'finished') diff --git a/fuel_plugin/tests/functional/tests.py b/fuel_plugin/tests/functional/tests.py new file mode 100644 index 00000000..389bca35 --- /dev/null +++ b/fuel_plugin/tests/functional/tests.py @@ -0,0 +1,255 @@ +# Copyright 2013 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. + +import time + + +from fuel_plugin.tests.functional.base import BaseAdapterTest, Response +from fuel_plugin.ostf_client.client import TestingAdapterClient as adapter + + +class AdapterTests(BaseAdapterTest): + + @classmethod + def setUpClass(cls): + + url = 'http://0.0.0.0:8989/v1' + + cls.mapping = { + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_pass': 'fast_pass', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_error': 'fast_error', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_fail': 'fast_fail', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_long_pass': 'long_pass', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fail_with_step': 'fail_step', + 'fuel_plugin.tests.functional.dummy_tests.stopped_test.dummy_tests_stopped.test_really_long': 'really_long', + 'fuel_plugin.tests.functional.dummy_tests.stopped_test.dummy_tests_stopped.test_not_long_at_all': 'not_long', + 'fuel_plugin.tests.functional.dummy_tests.stopped_test.dummy_tests_stopped.test_one_no_so_long': 'so_long' + } + cls.testsets = { + # "fuel_smoke": None, + # "fuel_sanity": None, + "general_test": ['fast_pass', 'fast_error', 'fast_fail', 'long_pass'], + "stopped_test": ['really_long', 'not_long', 'so_long'] + } + + cls.adapter = adapter(url) + cls.client = cls.init_client(url, cls.mapping) + + def test_list_testsets(self): + """Verify that self.testsets are in json response + """ + json = self.adapter.testsets().json() + response_testsets = [item['id'] for item in json] + for testset in self.testsets: + msg = '"{test}" not in "{response}"'.format(test=testset, response=response_testsets) + self.assertTrue(testset in response_testsets, msg) + + def test_list_tests(self): + """Verify that self.tests are in json response + """ + json = self.adapter.tests().json() + response_tests = [item['id'] for item in json] + + for test in self.mapping: + msg = '"{test}" not in "{response}"'.format(test=test.capitalize(), response=response_tests) + self.assertTrue(test in response_tests, msg) + + def test_run_testset(self): + """Verify that test status changes in time from running to success + """ + testset = "general_test" + cluster_id = 1 + + self.client.start_testrun(testset, cluster_id) + time.sleep(3) + + r = self.client.testruns_last(cluster_id) + + assertions = Response([{'status': 'running', + 'testset': 'general_test', + 'tests': [ + {'id': 'fast_pass', 'status': 'success', 'name': 'fast pass test', + 'description': """ This is a simple always pass test + """,}, + {'id': 'long_pass', 'status': 'running'}, + {'id': 'fail_step', 'message': 'MEssaasasas', 'status': 'failure'}, + {'id': 'fast_error', 'message': '', 'status': 'error'}, + {'id': 'fast_fail', 'message': 'Something goes wroooong', 'status': 'failure'}]}]) + + print r + print assertions + + self.compare(r, assertions) + time.sleep(10) + + r = self.client.testruns_last(cluster_id) + + assertions.general_test['status'] = 'finished' + assertions.long_pass['status'] = 'success' + + self.compare(r, assertions) + + def test_stop_testset(self): + """Verify that long running testrun can be stopped + """ + testset = "stopped_test" + cluster_id = 2 + + self.client.start_testrun(testset, cluster_id) + time.sleep(10) + r = self.client.testruns_last(cluster_id) + assertions = Response([ + {'status': 'running', + 'testset': 'stopped_test', + 'tests': [ + {'id': 'not_long', 'status': 'success'}, + {'id': 'so_long', 'status': 'success'}, + {'id': 'really_long', 'status': 'running'}]}]) + + self.compare(r, assertions) + + self.client.stop_testrun_last(testset, cluster_id) + r = self.client.testruns_last(cluster_id) + + assertions.stopped_test['status'] = 'finished' + assertions.really_long['status'] = 'stopped' + self.compare(r, assertions) + + def test_cant_start_while_running(self): + """Verify that you can't start new testrun for the same cluster_id while previous run is running""" + testsets = {"stopped_test": None, + "general_test": None} + cluster_id = 3 + + for testset in testsets: + self.client.start_testrun(testset, cluster_id) + self.client.testruns_last(cluster_id) + + for testset in testsets: + r = self.client.start_testrun(testset, cluster_id) + + msg = "Response {0} is not empty when you try to start testrun" \ + " with testset and cluster_id that are already running".format(r) + + self.assertTrue(r.is_empty, msg) + + def test_start_many_runs(self): + """Verify that you can start 20 testruns in a row with different cluster_id""" + testset = "general_test" + + for cluster_id in range(100, 105): + r = self.client.start_testrun(testset, cluster_id) + msg = '{0} was empty'.format(r.request) + self.assertFalse(r.is_empty, msg) + + '''TODO: Rewrite assertions to verity that all 5 testruns ended with appropriate status''' + + def test_run_single_test(self): + """Verify that you can run individual tests from given testset""" + testset = "general_test" + tests = ['fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_pass', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_fail'] + cluster_id = 50 + + r = self.client.start_testrun_tests(testset, tests, cluster_id) + assertions = Response([ + {'status': 'running', + 'testset': 'general_test', + 'tests': [ + {'status': 'disabled', 'id': 'fast_error'}, + {'status': 'wait_running', 'id': 'fast_fail'}, + {'status': 'wait_running', 'id': 'fast_pass'}, + {'status': 'disabled', 'id': 'long_pass'}]}]) + + self.compare(r, assertions) + time.sleep(2) + + r = self.client.testruns_last(cluster_id) + assertions.general_test['status'] = 'finished' + assertions.fast_fail['status'] = 'failure' + assertions.fast_pass['status'] = 'success' + self.compare(r, assertions) + + def test_single_test_restart(self): + """Verify that you restart individual tests for given testrun""" + testset = "general_test" + tests = ['fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_pass', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_fail'] + cluster_id = 60 + + self.client.run_testset_with_timeout(testset, cluster_id, 10) + + r = self.client.restart_tests_last(testset, tests, cluster_id) + assertions = Response([ + {'status': 'running', + 'testset': 'general_test', + 'tests': [ + {'id': 'fast_pass', 'status': 'wait_running'}, + {'id': 'long_pass', 'status': 'success'}, + {'id': 'fast_error', 'status': 'error'}, + {'id': 'fast_fail', 'status': 'wait_running'}]}]) + + self.compare(r, assertions) + time.sleep(5) + + r = self.client.testruns_last(cluster_id) + assertions.general_test['status'] = 'finished' + assertions.fast_pass['status'] = 'success' + assertions.fast_fail['status'] = 'failure' + + self.compare(r, assertions) + + def test_restart_combinations(self): + """Verify that you can restart both tests that ran and did not run during single test start""" + testset = "general_test" + tests = ['fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_pass', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_fail'] + disabled_test = ['functional.dummy_tests.general_test.Dummy_test.test_fast_error', ] + cluster_id = 70 + + self.client.run_with_timeout(testset, tests, cluster_id, 70) + self.client.restart_with_timeout(testset, tests, cluster_id, 10) + + r = self.client.restart_tests_last(testset, disabled_test, cluster_id) + assertions = Response([ + {'status': 'running', + 'testset': 'general_test', + 'tests': [ + {'status': 'wait_running', 'id': 'fast_error'}, + {'status': 'failure', 'id': 'fast_fail'}, + {'status': 'success', 'id': 'fast_pass'}, + {'status': 'disabled', 'id': 'long_pass'}]}]) + print r + self.compare(r, assertions) + time.sleep(5) + + r = self.client.testruns_last(cluster_id) + assertions.general_test['status'] = 'finished' + assertions.fast_error['status'] = 'error' + self.compare(r, assertions) + + def test_cant_restart_during_run(self): + testset = 'general_test' + tests = ['fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_pass', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_fail', + 'fuel_plugin.tests.functional.dummy_tests.general_test.Dummy_test.test_fast_pass'] + cluster_id = 999 + + self.client.start_testrun(testset, cluster_id) + time.sleep(2) + + r = self.client.restart_tests_last(testset, tests, cluster_id) + msg = 'Response was not empty after trying to restart running testset:\n {0}'.format(r.request) + self.assertTrue(r.is_empty, msg) + diff --git a/fuel_plugin/tests/test_utils/__init__.py b/fuel_plugin/tests/test_utils/__init__.py new file mode 100644 index 00000000..bc8f0bef --- /dev/null +++ b/fuel_plugin/tests/test_utils/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. \ No newline at end of file diff --git a/fuel_plugin/tests/test_utils/cluster_id.py b/fuel_plugin/tests/test_utils/cluster_id.py new file mode 100644 index 00000000..382b7485 --- /dev/null +++ b/fuel_plugin/tests/test_utils/cluster_id.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from requests import get +import sys + + +def main(): + try: + r = get('http://localhost:8000/api/clusters').json() + except IOError or ValueError as e: + print e.message + return 1 + + cluster_id = next(item['id'] for item in r) + print cluster_id + return 0 + +if __name__ == '__main__': + sys.exit(main()) + + diff --git a/fuel_plugin/tests/test_utils/commands b/fuel_plugin/tests/test_utils/commands new file mode 100755 index 00000000..35db0cb1 --- /dev/null +++ b/fuel_plugin/tests/test_utils/commands @@ -0,0 +1,67 @@ +#!/bin/bash + +function killapp { + netstat -nplt | grep 8989 | grep -o [0-9]*/python | grep -o [0-9]* &> /dev/null || { echo "Not running" && return 1; } + while netstat -nplt | grep 8989 &> /dev/null; do + declare app_pid=$(netstat -nplt | grep 8989 | grep -o [0-9]*/python | grep -o [0-9]*) + echo "Ostf-adapter pid is: $app_pid" + kill -9 $app_pid; done +} + +function stopapp { + if netstat -nplt | grep 8989 &> /dev/null; then + supervisorctl stop ostf && killapp + else + echo "OSTF-server is not running" + fi +} + +function startapp { + if netstat -nplt | grep 8989 | grep -o [0-9]*/python | grep -o [0-9]* &> /dev/null; then + echo "Server is already running" && return 1; fi + + supervisorctl start ostf + touch testing.log + sleep 5 + count=1 + while ! tail -1 testing.log | grep "serving on" &> /dev/null || ((count != 20)) ; do sleep 3; ((count+=1)) ; done + nc -xvw 2 0.0.0.0 8989 &> /dev/null && echo "Working like a charm" || echo "Not working" +} + +function update_tests { + if [[ -z $1 && -z $OSTF_TESTS_BRANCH ]] ; then echo "Please specify a branch"; return 1; fi + if [[ ! -z $1 ]] ; then + git ls-remote --heads git@github.com:Mirantis/fuel-ostf-tests.git $1 | grep $1 &> /dev/null || { echo "No branch" && return 1; } + export OSTF_TESTS_BRANCH=$1 + fi + ! pip freeze | grep ostf-tests &> /dev/null || pip uninstall -y ostf-tests + pip install -e git+ssh://git@github.com/Mirantis/fuel-ostf-tests.git@"$OSTF_TESTS_BRANCH"#egg=ostf-tests +} + +function update_adapter { + if [[ -z $1 && -z $OSTF_ADAPTER_BRANCH ]] ; then echo "Please specify a branch"; return 1; fi + if [[ ! -z $1 ]] ; then + git ls-remote --heads git@github.com:Mirantis/fuel-ostf-tests.git $1 | grep $1 &> /dev/null || { echo "No branch" && return 1; } + export OSTF_ADAPTER_BRANCH=$1 + fi + ! pip freeze | grep testing_adapter &> /dev/null || pip uninstall -y testing_adapter + pip install -e git+ssh://git@github.com/Mirantis/fuel-ostf-tests.git@"$OSTF_ADAPTER_BRANCH"#egg=ostf-plugin +} + +function migrate_db { + service postgresql restart + while ! service postgresql status | grep running &> /dev/null; do sleep 1; done + sleep 30 + export PGPASSWORD='ostf' + psql -U postgres -h localhost -c "drop database if exists testing_adapter" + psql -U postgres -h localhost -c "drop user adapter" + psql -U postgres -h localhost -c "create role adapter with nosuperuser createdb password 'demo' login" + psql -U postgres -h localhost -c "create database testing_adapter" + + ostf-server --after-initialization-environment-hook --dbpath=postgresql+psycopg2://adapter:demo@localhost/testing_adapter + } + +function run_functional_tests { + [[ ! -z $WORKSPACE ]] || export WORKSPACE=$(pwd) + nosetests -q fuel_plugin/functional/tests.py:AdapterTests --with-xunit --xunit-file=$WORKSPACE/reports/functional.xml +} \ No newline at end of file diff --git a/fuel_plugin/tests/test_utils/db_manage.sh b/fuel_plugin/tests/test_utils/db_manage.sh new file mode 100644 index 00000000..3cbbca3b --- /dev/null +++ b/fuel_plugin/tests/test_utils/db_manage.sh @@ -0,0 +1,5 @@ +export PGPASSWORD='ostf' +psql -U postgres -h localhost -c "drop user adapter" +psql -U postgres -h localhost -c "create role adapter with nosuperuser createdb password 'demo' login" +psql -U postgres -h localhost -c "drop database if exists testing_adapter" +psql -U postgres -h localhost -c "create database testing_adapter" \ No newline at end of file diff --git a/fuel_plugin/tests/test_utils/pylintrc b/fuel_plugin/tests/test_utils/pylintrc new file mode 100644 index 00000000..3f8e00b3 --- /dev/null +++ b/fuel_plugin/tests/test_utils/pylintrc @@ -0,0 +1,236 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Profiled execution. +profile=no + +# Add to the black list. It should be a base name, not a +# path. You may set this option multiple times. +ignore=CVS + +# Pickle collected data for later comparisons. +persistent=yes + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + + +[MESSAGES CONTROL] + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifier separated by comma (,) or put this option +# multiple time. +disable=F0401,R0201,W0311,C0111 + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html +output-format=parseable + +# Include message's id in output +include-ids=yes + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". +files-output=no + +# Tells whether to display a full report or only the messages +reports=yes + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (R0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Add a comment according to your evaluation note. This is used by the global +# evaluation report (R0004). +comment=no + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching names used for dummy variables (i.e. not used). +dummy-variables-rgx=_|dummy + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). +ignored-classes=SQLObject + +# When zope mode is activated, add a predefined set of Zope acquired attributes +# to generated-members. +zope=no + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E0201 when accessed. +generated-members=REQUEST,acl_users,aq_parent + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[BASIC] + +# Required attributes for module, separated by a comma +required-attributes= + +# List of builtins function names that should not be used, separated by a comma +bad-functions=map,filter,apply,input + +# Regular expression which should only match correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression which should only match correct module level names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression which should only match correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression which should only match correct function names +function-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct method names +method-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct instance attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression which should only match correct list comprehension / +# generator expression variable names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Good variable names which should always be accepted, separated by a comma +good-names=app,uwsgi,e,i,j,k,ex,Run,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,bar,baz,toto,tutu,tata + +# Regular expression which should only match functions or classes name which do +# not require a docstring +no-docstring-rgx=__.*__|[Tt]est.* + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=20 + +# Maximum number of return / yield for function / method body +max-returns=10 + +# Maximum number of branch for function / method body +max-branchs=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=10 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,string,TERMIOS,Bastion,rexec + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + + +[CLASSES] + +# List of interface methods to ignore, separated by a comma. This is used for +# instance to not check methods defines in Zope's Interface base class. +ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp \ No newline at end of file diff --git a/fuel_plugin/tests/test_utils/update_commands.py b/fuel_plugin/tests/test_utils/update_commands.py new file mode 100644 index 00000000..5fe3fbaf --- /dev/null +++ b/fuel_plugin/tests/test_utils/update_commands.py @@ -0,0 +1,37 @@ +# Copyright 2013 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. + + +from json import loads, dumps + + +def main(): + with open('fuel_plugin/ostf_adapter/commands.json', 'rw+') as commands: + data = loads(commands.read()) + for item in data: + if 'argv' in data[item] and item in ['fuel_sanity', 'fuel_smoke']: + if "--with-xunit" not in data[item]['argv']: + data[item]['argv'].extend(["--with-xunit", '--xunit-file={0}.xml'.format(item)]) + elif item in ['fuel_sanity', 'fuel_smoke']: + data[item]['argv'] = ["--with-xunit", ] + test_apps = {"plugin_general": {"test_path": "fuel_plugin/tests/functional/dummy_tests/general_test.py", "driver": "nose"}, + "plugin_stopped": {"test_path": "fuel_plugin/tests/functional/dummy_tests/stopped_test.py", "driver": "nose"}} + if 'plugin_general' not in data or 'plugin_stopped' not in data: + data.update(test_apps) + commands.seek(0) + commands.write(dumps(data)) + commands.truncate() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/fuel_plugin/tests/unit/__init__.py b/fuel_plugin/tests/unit/__init__.py new file mode 100644 index 00000000..bc8f0bef --- /dev/null +++ b/fuel_plugin/tests/unit/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2013 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. \ No newline at end of file diff --git a/fuel_plugin/tests/unit/test_nose_discovery.py b/fuel_plugin/tests/unit/test_nose_discovery.py new file mode 100644 index 00000000..247e43d0 --- /dev/null +++ b/fuel_plugin/tests/unit/test_nose_discovery.py @@ -0,0 +1,49 @@ +# Copyright 2013 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. + +from mock import patch +import unittest2 +from fuel_plugin.ostf_adapter.nose_plugin import nose_discovery + + +from fuel_plugin.ostf_adapter.storage import models + + +stopped__profile__ = { + "id": "stopped_test", + "driver": "nose", + "test_path": "functional/dummy_tests/stopped_test.py", + "description": "Long running 25 secs fake tests" +} +general__profile__ = { + "id": "general_test", + "driver": "nose", + "test_path": "functional/dummy_tests/general_test.py", + "description": "General fake tests" +} + + +@patch('fuel_plugin.ostf_adapter.nose_plugin.nose_discovery.engine') +class TestNoseDiscovery(unittest2.TestCase): + + def setUp(self): + self.fixtures = [models.TestSet(**stopped__profile__), + models.TestSet(**general__profile__)] + self.fixtures_iter = iter(self.fixtures) + + def test_discovery(self, engine): + engine.get_session().merge.return_value = \ + lambda *args, **kwargs: self.fixtures_iter.next() + nose_discovery.discovery(path='functional/dummy_tests') + self.assertEqual(engine.get_session().merge.call_count, 2) diff --git a/fuel_plugin/tests/unit/test_wsgi_controllers.py b/fuel_plugin/tests/unit/test_wsgi_controllers.py new file mode 100644 index 00000000..6181babf --- /dev/null +++ b/fuel_plugin/tests/unit/test_wsgi_controllers.py @@ -0,0 +1,125 @@ +# Copyright 2013 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. + +import json +from mock import patch, MagicMock +import unittest2 + +from fuel_plugin.ostf_adapter.wsgi import controllers +from fuel_plugin.ostf_adapter.storage import models + + +@patch('fuel_plugin.ostf_adapter.wsgi.controllers.request') +class TestTestsController(unittest2.TestCase): + + def setUp(self): + self.fixtures = [models.Test(), models.Test()] + self.controller = controllers.TestsController() + + def test_get_all(self, request): + request.session.query().all.return_value = self.fixtures + res = self.controller.get_all() + self.assertEqual(res, [f.frontend for f in self.fixtures]) + + +@patch('fuel_plugin.ostf_adapter.wsgi.controllers.request') +class TestTestSetsController(unittest2.TestCase): + + def setUp(self): + self.fixtures = [models.TestSet(), models.TestSet()] + self.controller = controllers.TestsetsController() + + def test_get_all(self, request): + request.session.query().all.return_value = self.fixtures + res = self.controller.get_all() + self.assertEqual(res, [f.frontend for f in self.fixtures]) + + +@patch('fuel_plugin.ostf_adapter.wsgi.controllers.request') +class TestTestRunsController(unittest2.TestCase): + + def setUp(self): + self.fixtures = [models.TestRun(status='finished'), + models.TestRun(status='running')] + self.fixtures[0].test_set = models.TestSet(driver='nose') + self.storage = MagicMock() + self.plugin = MagicMock() + self.session = MagicMock() + self.controller = controllers.TestrunsController() + + def test_get_all(self, request): + request.session.query().all.return_value = self.fixtures + res = self.controller.get_all() + self.assertEqual(res, [f.frontend for f in self.fixtures]) + + def test_get_one(self, request): + request.session.query().filter_by().first.return_value = \ + self.fixtures[0] + res = self.controller.get_one(1) + self.assertEqual(res, self.fixtures[0].frontend) + + @patch('fuel_plugin.ostf_adapter.wsgi.controllers.models') + def test_post(self, models, request): + request.storage = self.storage + testruns = [ + {'testset': 'test_simple', + 'metadata': {'cluster_id': 3} + }, + {'testset': 'test_simple', + 'metadata': {'cluster_id': 4} + }] + request.body = json.dumps(testruns) + fixtures_iterable = (f.frontend for f in self.fixtures) + + models.TestRun.start.side_effect = \ + lambda *args, **kwargs: fixtures_iterable.next() + res = self.controller.post() + self.assertEqual(res, [f.frontend for f in self.fixtures]) + + @patch('fuel_plugin.ostf_adapter.wsgi.controllers.models') + def test_put_stopped(self, models, request): + request.storage = self.storage + testruns = [ + {'id': 1, + 'metadata': {'cluster_id': 4}, + 'status': 'stopped' + }] + request.body = json.dumps(testruns) + + models.TestRun.get_test_run().stop.side_effect = \ + lambda *args, **kwargs: self.fixtures[0].frontend + res = self.controller.put() + self.assertEqual(res, [self.fixtures[0].frontend]) + + @patch('fuel_plugin.ostf_adapter.wsgi.controllers.models') + def test_put_restarted(self, models, request): + request.storage = self.storage + testruns = [ + {'id': 1, + 'metadata': {'cluster_id': 4}, + 'status': 'restarted' + }] + request.body = json.dumps(testruns) + + models.TestRun.get_test_run().restart.side_effect = \ + lambda *args, **kwargs: self.fixtures[0].frontend + res = self.controller.put() + self.assertEqual(res, [self.fixtures[0].frontend]) + + def test_get_last(self, request): + cluster_id = 1 + request.session.query().group_by().filter_by.return_value = [10, 11] + request.session.query().options().filter.return_value = self.fixtures + res = self.controller.get_last(cluster_id) + self.assertEqual(res, [f.frontend for f in self.fixtures]) diff --git a/fuel_plugin/tests/unit/tests_wsgi_interface.py b/fuel_plugin/tests/unit/tests_wsgi_interface.py new file mode 100644 index 00000000..f855489f --- /dev/null +++ b/fuel_plugin/tests/unit/tests_wsgi_interface.py @@ -0,0 +1,78 @@ +# Copyright 2013 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. + +import unittest2 +from mock import patch, MagicMock +import json + +from webtest import TestApp + +from fuel_plugin.ostf_adapter.wsgi import app + + +@patch('fuel_plugin.ostf_adapter.wsgi.controllers.request') +class WsgiInterfaceTests(unittest2.TestCase): + + def setUp(self): + self.app = TestApp(app.setup_app()) + + def test_get_all_tests(self, request): + self.app.get('/v1/tests') + + def test_get_one_test(self, request): + self.assertRaises(NotImplementedError, + self.app.get, + '/v1/tests/1') + + def test_get_all_testsets(self, request): + self.app.get('/v1/testsets') + + def test_get_one_testset(self, request): + self.app.get('/v1/testsets/plugin_test') + + def test_get_one_testruns(self, request): + self.app.get('/v1/testruns/1') + + def test_get_all_testruns(self, request): + self.app.get('/v1/testruns') + + @patch('fuel_plugin.ostf_adapter.wsgi.controllers.models') + def test_post_testruns(self, models, request): + testruns = [ + {'testset': 'test_simple', + 'metadata': {'cluster_id': 3} + }, + {'testset': 'test_simple', + 'metadata': {'cluster_id': 4} + }] + request.body = json.dumps(testruns) + models.TestRun.start.return_value = {} + self.app.post_json('/v1/testruns', testruns) + + def test_put_testruns(self, request): + testruns = [ + {'id': 2, + 'metadata': {'cluster_id': 3}, + 'status': 'non_exist' + }, + {'id': 1, + 'metadata': {'cluster_id': 4}, + 'status': 'non_exist' + }] + request.body = json.dumps(testruns) + request.storage.get_test_run.return_value = MagicMock(frontend={}) + self.app.put_json('/v1/testruns', testruns) + + def test_get_last_testruns(self, request): + self.app.get('/v1/testruns/last/101') diff --git a/setup.py b/setup.py index 23d620a0..536761cc 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,18 @@ +# Copyright 2013 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. + +import multiprocessing import setuptools @@ -17,7 +32,6 @@ requirements = [ 'nose==1.3.0', 'oslo.config==1.1.1', 'paramiko==1.10.1', - 'pbr>=0.5.21,<1.0', 'prettytable==0.7.2', 'pyOpenSSL==0.13', 'pycrypto==2.6', @@ -35,23 +49,78 @@ requirements = [ 'testresources==0.2.7', 'warlock==1.0.1', 'wsgiref==0.1.2', - 'pyyaml==3.10' + 'pyyaml==3.10', + 'Mako==0.8.1', + 'MarkupSafe==0.18', + 'SQLAlchemy==0.8.2', + 'WebOb==1.2.3', + 'WebTest==2.0.6', + 'alembic==0.5.0', + 'beautifulsoup4==4.2.1', + 'gevent==0.13.8', + 'greenlet==0.4.1', + 'pecan==0.3.0', + 'psycogreen==1.0', + 'psycopg2==2.5.1', + 'simplegeneric==0.8.1', + 'stevedore==0.10', + 'waitress==0.8.5', + 'WSME==0.5b2' +] + +test_requires = [ + 'mock==1.0.1', + 'pep8==1.4.6', + 'py==1.4.15', + 'six==1.3.0', + 'tox==1.5.0', + 'unittest2', + 'nose', + 'requests' ] setuptools.setup( - name='ostf_tests', + name='fuel_ostf', version='0.1', description='cloud computing testing', zip_safe=False, + test_suite='fuel_plugin/tests', + + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Framework :: Setuptools Plugin', + 'Environment :: OpenStack', + 'Intended Audience :: Information Technology', + 'Intended Audience :: System Administrators', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Topic :: System :: Testing', + ], + packages=setuptools.find_packages(), include_package_data=True, install_requires=requirements, + entry_points={ + 'plugins': [ + ('nose = fuel_plugin.ostf_adapter.' + 'nose_plugin.nose_adapter:NoseDriver') + ], + 'console_scripts': [ + 'ostf-server = fuel_plugin.bin.adapter_api:main', + ('update-commands = fuel_plugin.tests.' + 'test_utils.update_commands:main') + ] + }, + ) diff --git a/tools/test-requires b/tools/test-requires new file mode 100644 index 00000000..e7c615d5 --- /dev/null +++ b/tools/test-requires @@ -0,0 +1,9 @@ +WebTest==2.0.6 +mock==1.0.1 +pep8==1.4.6 +py==1.4.15 +six==1.3.0 +tox==1.5.0 +unittest2==0.5.1 +coverage==3.6 +requests==1.2.3 \ No newline at end of file diff --git a/tox.ini b/tox.ini index 8a258ce6..25cedd2c 100644 --- a/tox.ini +++ b/tox.ini @@ -4,8 +4,20 @@ # and then run "tox" from this directory. [tox] -envlist = pep8 +envlist = py27, pep8, cover + +[testenv] +commands = nosetests fuel_plugin.tests.unit +deps = -r{toxinidir}/tools/test-requires + [testenv:pep8] deps = pep8 -commands = pep8 --repeat --show-source --exclude=.venv,.tox,dist,doc,*egg . +commands = pep8 --repeat --show-source --exclude=.venv,.tox,dist,./build,doc,*egg,tests,functional,test_utils,ostf_client . + +[testenv:cover] +commands = nosetests fuel_plugin.tests.unit --no-path-adjustment --with-coverage --cover-erase --cover-package=fuel_plugin.ostf_adapter + +#[testenv:funct] +#deps = requests +#commands = nosetests functional/tests.py:AdapterTests