From e6234c3013d87bf6ffde26cfc36e2dfae3d34297 Mon Sep 17 00:00:00 2001 From: Roman Vasilets Date: Fri, 25 Dec 2015 16:04:10 +0200 Subject: [PATCH] Add task exporter to the file system This is the first task exporter plugin. It used to export rally task results to the file in json format. Change-Id: I25117933ac0881453a001d96600d4c95e6064fff --- etc/rally.bash_completion | 3 +- rally/cli/commands/task.py | 50 ++++++++++ rally/exceptions.py | 7 +- rally/plugins/common/exporter/__init__.py | 0 rally/plugins/common/exporter/file_system.py | 98 +++++++++++++++++++ rally/task/exporter.py | 18 ++-- tests/functional/test_cli_task.py | 53 ++++++++++ tests/unit/cli/commands/test_task.py | 33 +++++++ .../unit/plugins/common/exporter/__init__.py | 0 .../common/exporter/test_file_system.py | 96 ++++++++++++++++++ tests/unit/task/test_exporter.py | 9 +- 11 files changed, 355 insertions(+), 12 deletions(-) create mode 100644 rally/plugins/common/exporter/__init__.py create mode 100644 rally/plugins/common/exporter/file_system.py create mode 100644 tests/unit/plugins/common/exporter/__init__.py create mode 100644 tests/unit/plugins/common/exporter/test_file_system.py diff --git a/etc/rally.bash_completion b/etc/rally.bash_completion index cd8bf1ac09..3cc70b8f33 100644 --- a/etc/rally.bash_completion +++ b/etc/rally.bash_completion @@ -36,6 +36,7 @@ _rally() OPTS["task_abort"]="--uuid --soft" OPTS["task_delete"]="--force --uuid" OPTS["task_detailed"]="--uuid --iterations-data" + OPTS["task_export"]="--uuid --connection" OPTS["task_list"]="--deployment --all-deployments --status --uuids-only" OPTS["task_report"]="--tasks --out --open --html --html-static --junit" OPTS["task_results"]="--uuid" @@ -86,4 +87,4 @@ _rally() return 0 } -complete -o filenames -F _rally rally \ No newline at end of file +complete -o filenames -F _rally rally diff --git a/rally/cli/commands/task.py b/rally/cli/commands/task.py index 0ac3965dc0..d61ee03b1a 100644 --- a/rally/cli/commands/task.py +++ b/rally/cli/commands/task.py @@ -24,6 +24,7 @@ import webbrowser import jsonschema from oslo_utils import uuidutils import six +from six.moves.urllib import parse as urlparse import yaml from rally import api @@ -37,12 +38,15 @@ from rally.common import utils as rutils from rally import consts from rally import exceptions from rally import plugins +from rally.task import exporter from rally.task.processing import plot class FailedToLoadTask(exceptions.RallyException): msg_fmt = _("Failed to load task") +LOG = logging.getLogger(__name__) + class TaskCommands(object): """Set of commands that allow you to manage benchmarking tasks and results. @@ -699,3 +703,49 @@ class TaskCommands(object): print("Using task: %s" % task_id) api.Task.get(task_id) fileutils.update_globals_file("RALLY_TASK", task_id) + + @cliutils.args("--uuid", dest="uuid", type=str, + required=True, + help="UUID of a the task.") + @cliutils.args("--connection", dest="connection_string", type=str, + required=True, + help="Connection url to the task export system.") + @plugins.ensure_plugins_are_loaded + def export(self, uuid, connection_string): + """Export task results to the custom task's exporting system. + + :param uuid: UUID of the task + :param connection_string: string used to connect to the system + """ + + parsed_obj = urlparse.urlparse(connection_string) + try: + client = exporter.TaskExporter.get(parsed_obj.scheme)( + connection_string) + except exceptions.InvalidConnectionString as e: + if logging.is_debug(): + LOG.exception(e) + print (e) + return 1 + except exceptions.PluginNotFound as e: + if logging.is_debug(): + LOG.exception(e) + msg = ("\nPlease check your connection string. The format of " + "`connection` should be plugin-name://" + ":@:/.") + print (str(e) + msg) + return 1 + + try: + client.export(uuid) + except (IOError, exceptions.RallyException) as e: + if logging.is_debug(): + LOG.exception(e) + print (e) + return 1 + print(_("Task %(uuid)s results was successfully exported to %(" + "connection)s using %(name)s plugin.") % { + "uuid": uuid, + "connection": connection_string, + "name": parsed_obj.scheme + }) diff --git a/rally/exceptions.py b/rally/exceptions.py index d3b4c5e3f9..2273cfe5d9 100644 --- a/rally/exceptions.py +++ b/rally/exceptions.py @@ -81,7 +81,7 @@ class ThreadTimeoutException(RallyException): class PluginNotFound(NotFoundException): - msg_fmt = _("There is no plugin with name: %(name)s in " + msg_fmt = _("There is no plugin with name: `%(name)s` in " "%(namespace)s namespace.") @@ -246,3 +246,8 @@ class SSHTimeout(RallyException): class SSHError(RallyException): pass + + +class InvalidConnectionString(RallyException): + msg_fmt = _("The connection string is not valid: %(message)s. Please " + "check your connection string.") \ No newline at end of file diff --git a/rally/plugins/common/exporter/__init__.py b/rally/plugins/common/exporter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/rally/plugins/common/exporter/file_system.py b/rally/plugins/common/exporter/file_system.py new file mode 100644 index 0000000000..92c8de7118 --- /dev/null +++ b/rally/plugins/common/exporter/file_system.py @@ -0,0 +1,98 @@ +# Copyright 2016: Mirantis Inc. +# All Rights Reserved. +# +# 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 os + +from six.moves.urllib import parse as urlparse + +from rally import api +from rally.common import logging +from rally.common.plugin import plugin +from rally import exceptions +from rally.task import exporter + + +def configure(name, namespace="default"): + return plugin.configure(name=name, namespace=namespace) + +LOG = logging.getLogger(__name__) + + +@configure(name="file-exporter") +class FileExporter(exporter.TaskExporter): + + def validate(self): + """Validate connection string. + + The format of connection string in file plugin is + file:///. + """ + + parse_obj = urlparse.urlparse(self.connection_string) + + available_formats = ("json",) + available_formats_str = ", ".join(available_formats) + if self.connection_string is None or parse_obj.path == "": + raise exceptions.InvalidConnectionString( + "It should be `file-exporter:///.`.") + if self.type not in available_formats: + raise exceptions.InvalidConnectionString( + "Type of the exported task is not available. The available " + "formats are %s." % + available_formats_str) + + def __init__(self, connection_string): + super(FileExporter, self).__init__(connection_string) + self.path = os.path.expanduser(urlparse.urlparse( + connection_string).path[1:]) + self.type = connection_string.split(".")[-1] + self.validate() + + def export(self, uuid): + """Export results of the task to the file. + + :param uuid: uuid of the task object + """ + task = api.Task.get(uuid) + + LOG.debug("Got the task object by it's uuid %s. " % uuid) + + task_results = [{"key": x["key"], "result": x["data"]["raw"], + "sla": x["data"]["sla"], + "load_duration": x["data"]["load_duration"], + "full_duration": x["data"]["full_duration"]} + for x in task.get_results()] + + if self.type == "json": + if task_results: + res = json.dumps(task_results, sort_keys=True, indent=4) + LOG.debug("Got the task %s results." % uuid) + else: + msg = ("Task %s results would be available when it will " + "finish." % uuid) + raise exceptions.RallyException(msg) + + if os.path.dirname(self.path) and (not os.path.exists(os.path.dirname( + self.path))): + raise IOError("There is no such directory: %s" % + os.path.dirname(self.path)) + with open(self.path, "w") as f: + LOG.debug("Writing task %s results to the %s." % ( + uuid, self.connection_string)) + f.write(res) + LOG.debug("Task %s results was written to the %s." % ( + uuid, self.connection_string)) diff --git a/rally/task/exporter.py b/rally/task/exporter.py index 3f43ab3d6d..30c0260449 100644 --- a/rally/task/exporter.py +++ b/rally/task/exporter.py @@ -31,15 +31,19 @@ def configure(name, namespace="default"): @six.add_metaclass(abc.ABCMeta) -@configure(name="base_task_exporter") +@configure(name="base-exporter") class TaskExporter(plugin.Plugin): + def __init__(self, connection_string): + self.connection_string = connection_string + @abc.abstractmethod - def export(self, task_uuid, connection_string): - """ - Export results of the task to the task storage. + def export(self, task_uuid): + """Export results of the task to the task storage. :param task_uuid: uuid of task results - :param connection_string: string used to connect - to the external system - """ \ No newline at end of file + """ + + @abc.abstractmethod + def validate(self): + """Used to validate connection string.""" \ No newline at end of file diff --git a/tests/functional/test_cli_task.py b/tests/functional/test_cli_task.py index 7282a81371..4a7aa42557 100644 --- a/tests/functional/test_cli_task.py +++ b/tests/functional/test_cli_task.py @@ -740,6 +740,59 @@ class TaskTestCase(unittest.TestCase): r"(?P[0-9a-f\-]{36}): started", output) self.assertIsNotNone(result) + def test_export(self): + rally = utils.Rally() + cfg = { + "Dummy.dummy": [ + { + "runner": { + "type": "constant", + "times": 100, + "concurrency": 5 + } + } + ] + } + config = utils.TaskConfig(cfg) + output = rally("task start --task %s" % config.filename) + uuid = re.search( + r"(?P[0-9a-f\-]{36}): started", output).group("uuid") + connection = ( + "file-exporter:///" + rally.gen_report_path(extension="json")) + output = rally("task export --uuid %s --connection %s" % ( + uuid, connection)) + expected = ( + "Task %(uuid)s results was successfully exported to %(" + "connection)s using file-exporter plugin." % { + "uuid": uuid, + "connection": connection, + }) + self.assertIn(expected, output) + + def test_export_with_wrong_connection(self): + rally = utils.Rally() + cfg = { + "Dummy.dummy": [ + { + "runner": { + "type": "constant", + "times": 100, + "concurrency": 5 + } + } + ] + } + config = utils.TaskConfig(cfg) + output = rally("task start --task %s" % config.filename) + uuid = re.search( + r"(?P[0-9a-f\-]{36}): started", output).group("uuid") + connection = ( + "fake:///" + rally.gen_report_path(extension="json")) + self.assertRaises(utils.RallyCliError, + rally, + "task export --uuid %s --connection %s" % ( + uuid, connection)) + class SLATestCase(unittest.TestCase): diff --git a/tests/unit/cli/commands/test_task.py b/tests/unit/cli/commands/test_task.py index 19005a990b..5e764818ab 100644 --- a/tests/unit/cli/commands/test_task.py +++ b/tests/unit/cli/commands/test_task.py @@ -742,3 +742,36 @@ class TaskCommandsTestCase(test.TestCase): task_id = "ddc3f8ba-082a-496d-b18f-72cdf5c10a14" mock_task_get.side_effect = exceptions.TaskNotFound(uuid=task_id) self.assertRaises(exceptions.TaskNotFound, self.task.use, task_id) + + @mock.patch("rally.task.exporter.TaskExporter.get") + def test_export(self, mock_task_exporter_get): + mock_client = mock.Mock() + mock_exporter_class = mock.Mock(return_value=mock_client) + mock_task_exporter_get.return_value = mock_exporter_class + self.task.export("fake_uuid", "file-exporter:///fake_path.json") + mock_task_exporter_get.assert_called_once_with("file-exporter") + mock_client.export.assert_called_once_with("fake_uuid") + + @mock.patch("rally.task.exporter.TaskExporter.get") + def test_export_exception(self, mock_task_exporter_get): + mock_client = mock.Mock() + mock_exporter_class = mock.Mock(return_value=mock_client) + mock_task_exporter_get.return_value = mock_exporter_class + mock_client.export.side_effect = IOError + self.task.export("fake_uuid", "file-exporter:///fake_path.json") + mock_task_exporter_get.assert_called_once_with("file-exporter") + mock_client.export.assert_called_once_with("fake_uuid") + + @mock.patch("rally.cli.commands.task.sys.stdout") + @mock.patch("rally.task.exporter.TaskExporter.get") + def test_export_InvalidConnectionString(self, mock_task_exporter_get, + mock_stdout): + mock_exporter_class = mock.Mock( + side_effect=exceptions.InvalidConnectionString) + mock_task_exporter_get.return_value = mock_exporter_class + self.task.export("fake_uuid", "file-exporter:///fake_path.json") + mock_stdout.write.assert_has_calls([ + mock.call("The connection string is not valid: None. " + "Please check your connection string."), + mock.call("\n")]) + mock_task_exporter_get.assert_called_once_with("file-exporter") diff --git a/tests/unit/plugins/common/exporter/__init__.py b/tests/unit/plugins/common/exporter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/plugins/common/exporter/test_file_system.py b/tests/unit/plugins/common/exporter/test_file_system.py new file mode 100644 index 0000000000..5b76a293bf --- /dev/null +++ b/tests/unit/plugins/common/exporter/test_file_system.py @@ -0,0 +1,96 @@ +# Copyright 2016: Mirantis Inc. +# All Rights Reserved. +# +# 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 ddt +import mock +import six +import six.moves.builtins as __builtin__ + +from rally import exceptions +from rally.plugins.common.exporter import file_system +from tests.unit import test + +if six.PY3: + import io + file = io.BytesIO + + +@ddt.ddt +class FileExporterTestCase(test.TestCase): + + @mock.patch("rally.plugins.common.exporter.file_system.os.path.exists") + @mock.patch.object(__builtin__, "open", autospec=True) + @mock.patch("rally.plugins.common.exporter.file_system.json.dumps") + @mock.patch("rally.api.Task.get") + def test_file_exporter_export(self, mock_task_get, mock_dumps, mock_open, + mock_exists): + mock_task = mock.Mock() + mock_exists.return_value = True + mock_task_get.return_value = mock_task + mock_task.get_results.return_value = [{ + "key": "fake_key", + "data": { + "raw": "bar_raw", + "sla": "baz_sla", + "load_duration": "foo_load_duration", + "full_duration": "foo_full_duration", + } + }] + mock_dumps.return_value = "fake_results" + input_mock = mock.MagicMock(spec=file) + mock_open.return_value = input_mock + + exporter = file_system.FileExporter("file-exporter:///fake_path.json") + exporter.export("fake_uuid") + + mock_open().__enter__().write.assert_called_once_with("fake_results") + mock_task_get.assert_called_once_with("fake_uuid") + expected_dict = [ + { + "load_duration": "foo_load_duration", + "full_duration": "foo_full_duration", + "result": "bar_raw", + "key": "fake_key", + "sla": "baz_sla" + } + ] + mock_dumps.assert_called_once_with(expected_dict, sort_keys=True, + indent=4) + + @mock.patch("rally.api.Task.get") + def test_file_exporter_export_running_task(self, mock_task_get): + mock_task = mock.Mock() + mock_task_get.return_value = mock_task + mock_task.get_results.return_value = [] + + exporter = file_system.FileExporter("file-exporter:///fake_path.json") + self.assertRaises(exceptions.RallyException, exporter.export, + "fake_uuid") + + @ddt.data( + {"connection": "", + "raises": exceptions.InvalidConnectionString}, + {"connection": "file-exporter:///fake_path.json", + "raises": None}, + {"connection": "file-exporter:///fake_path.fake", + "raises": exceptions.InvalidConnectionString}, + ) + @ddt.unpack + def test_file_exporter_validate(self, connection, raises): + print (connection) + if raises: + self.assertRaises(raises, file_system.FileExporter, connection) + else: + file_system.FileExporter(connection) diff --git a/tests/unit/task/test_exporter.py b/tests/unit/task/test_exporter.py index 70b7c0a8c7..4ac5fca735 100644 --- a/tests/unit/task/test_exporter.py +++ b/tests/unit/task/test_exporter.py @@ -16,9 +16,12 @@ from rally.task import exporter from tests.unit import test -@exporter.configure(name="test_exporter") +@exporter.configure(name="test-exporter") class TestExporter(exporter.TaskExporter): + def validate(self): + pass + def export(self, task, connection_string): pass @@ -26,7 +29,7 @@ class TestExporter(exporter.TaskExporter): class ExporterTestCase(test.TestCase): def test_task_export(self): - self.assertRaises(TypeError, exporter.TaskExporter) + self.assertRaises(TypeError, exporter.TaskExporter, "fake_connection") def test_task_export_instantiate(self): - TestExporter() \ No newline at end of file + TestExporter("fake_connection") \ No newline at end of file