diff --git a/rally-jobs/extra/hook_example_script.sh b/rally-jobs/extra/hook_example_script.sh new file mode 100644 index 0000000000..c084c0f809 --- /dev/null +++ b/rally-jobs/extra/hook_example_script.sh @@ -0,0 +1,56 @@ +#!/bin/sh + +rand_int() { + od -An -tu -N1 /dev/urandom | tr -d ' ' +} + +cat << EOF +{ + "additive": [ + { + "title": "Statistics table from Hook", + "chart_plugin": "StatsTable", + "data": [ + ["Alice", $(rand_int)], + ["Bob", $(rand_int)], + ["Carol", $(rand_int)]] + }, + { + "title": "StackedArea chart from Hook", + "description": "This is generated by ${0}", + "chart_plugin": "StackedArea", + "data": [ + ["Alpha", $(rand_int)], + ["Beta", $(rand_int)], + ["Gamma", $(rand_int)]] + } + ], + "complete": [ + { + "title": "Lines chart from Hook", + "description": "Random data generated by ${0}", + "chart_plugin": "Lines", + "axis_label": "X-axis label", + "label": "Y-axis label", + "data": [ + ["Foo", [[1, $(rand_int)], [2, $(rand_int)], [3, $(rand_int)], [4, $(rand_int)], [5, $(rand_int)]]], + ["Bar", [[1, $(rand_int)], [2, $(rand_int)], [3, $(rand_int)], [4, $(rand_int)], [5, $(rand_int)]]], + ["Spam", [[1, $(rand_int)], [2, $(rand_int)], [3, $(rand_int)], [4, $(rand_int)], [5, $(rand_int)]]], + ["Quiz", [[1, $(rand_int)], [2, $(rand_int)], [3, $(rand_int)], [4, $(rand_int)], [5, $(rand_int)]]] + ] + }, + { + "title": "Pie chart from Hook", + "description": "Yet another data generated by ${0}", + "chart_plugin": "Pie", + "data": [ + ["Cat", $(rand_int)], + ["Tiger", $(rand_int)], + ["Jaguar", $(rand_int)], + ["Panther", $(rand_int)], + ["Lynx", $(rand_int)] + ] + } + ] +} +EOF diff --git a/rally-jobs/rally.yaml b/rally-jobs/rally.yaml index 7f4ce95a33..d160b790a0 100644 --- a/rally-jobs/rally.yaml +++ b/rally-jobs/rally.yaml @@ -561,20 +561,36 @@ - args: - sleep: 0.25 + sleep: 0.75 runner: type: "constant" - times: 10 + times: 20 concurrency: 2 hooks: - name: sys_call - description: test hook - args: /bin/true + description: Run script + args: sh /home/jenkins/.rally/extra/hook_example_script.sh trigger: name: event args: unit: iteration - at: [2, 4, 6, 8, 10] + at: [2, 5, 8, 13, 17] + - name: sys_call + description: Show time + args: date +%Y-%m-%dT%H:%M:%S + trigger: + name: event + args: + unit: time + at: [0, 2, 5, 6, 9] + - name: sys_call + description: Show system name + args: uname -a + trigger: + name: event + args: + unit: iteration + at: [2, 3, 4, 5, 6, 8, 10, 12, 13, 15, 17, 18] sla: failure_rate: max: 0 @@ -610,8 +626,8 @@ concurrency: 1 hooks: - name: sys_call - description: test hook - args: /bin/true + description: Get system name + args: uname -a trigger: name: event args: diff --git a/rally/common/objects/task.py b/rally/common/objects/task.py index f2cf09c29b..a35cf6adbb 100644 --- a/rally/common/objects/task.py +++ b/rally/common/objects/task.py @@ -82,7 +82,8 @@ OUTPUT_SCHEMA = { } }, "required": ["cols", "rows"], - "additionalProperties": False} + "additionalProperties": False}, + {"type": "array", "items": {"type": "string"}}, ]}, "label": {"type": "string"}, "axis_label": {"type": "string"} diff --git a/rally/plugins/common/hook/sys_call.py b/rally/plugins/common/hook/sys_call.py index 26dbe8f2ad..24e77ae4d0 100644 --- a/rally/plugins/common/hook/sys_call.py +++ b/rally/plugins/common/hook/sys_call.py @@ -13,11 +13,13 @@ # License for the specific language governing permissions and limitations # under the License. +import json import shlex import subprocess from rally.common import logging from rally import consts +from rally import exceptions from rally.task import hook @@ -37,15 +39,28 @@ class SysCallHook(hook.Hook): LOG.debug("sys_call hook: Running command %s", self.config) proc = subprocess.Popen(shlex.split(self.config), stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) - proc.wait() + stderr=subprocess.PIPE) + out, err = proc.communicate() LOG.debug("sys_call hook: Command %s returned %s", self.config, proc.returncode) - if proc.returncode == 0: - self.set_output(proc.stdout.read().decode()) - else: + if proc.returncode: self.set_error( exception_name="n/a", # no exception class description="Subprocess returned {}".format(proc.returncode), - details=proc.stdout.read().decode(), - ) + details=(err or "stdout: %s" % out)) + + # NOTE(amaretskiy): Try to load JSON for charts, + # otherwise save output as-is + try: + output = json.loads(out) + for arg in ("additive", "complete"): + for out_ in output.get(arg, []): + self.add_output(**{arg: out_}) + except (TypeError, ValueError, exceptions.RallyException): + self.add_output( + complete={"title": "System call", + "chart_plugin": "TextArea", + "description": "Args: %s" % self.config, + "data": ["RetCode: %i" % proc.returncode, + "StdOut: %s" % (out or "(empty)"), + "StdErr: %s" % (err or "(empty)")]}) diff --git a/rally/task/hook.py b/rally/task/hook.py index 598ca30653..2416fb413f 100644 --- a/rally/task/hook.py +++ b/rally/task/hook.py @@ -25,6 +25,8 @@ from rally.common import logging from rally.common.plugin import plugin from rally.common import utils as rutils from rally import consts +from rally import exceptions +from rally.task.processing import charts from rally.task import trigger from rally.task import utils @@ -152,13 +154,21 @@ class Hook(plugin.Plugin): """Set status to result.""" self._result["status"] = status - def set_output(self, output): - """Set output to result. + def add_output(self, additive=None, complete=None): + """Save custom output. - :param output: Diagram data in task.OUTPUT_SCHEMA format + :param additive: dict with additive output + :param complete: dict with complete output + :raises RallyException: if output has wrong format """ - if output: - self._result["output"] = output + if "output" not in self._result: + self._result["output"] = {"additive": [], "complete": []} + for key, value in (("additive", additive), ("complete", complete)): + if value: + message = charts.validate_output(key, value) + if message: + raise exceptions.RallyException(message) + self._result["output"][key].append(value) def run_async(self): """Run hook asynchronously.""" @@ -190,7 +200,7 @@ class Hook(plugin.Plugin): Optionally the following methods should be called: set_error - to indicate that there was an error; automatically sets hook execution status to 'failed' - set_output - to provide diagram data + add_output - provide data for report """ def result(self): diff --git a/rally/task/processing/plot.py b/rally/task/processing/plot.py index e40abb0032..152f71b1b1 100644 --- a/rally/task/processing/plot.py +++ b/rally/task/processing/plot.py @@ -14,6 +14,7 @@ # under the License. import collections +import datetime as dt import hashlib import json @@ -26,6 +27,61 @@ from rally.task.processing import charts from rally.ui import utils as ui_utils +def _process_hooks(hooks): + """Prepare hooks data for report.""" + hooks_ctx = [] + for hook in hooks: + hook_ctx = {"name": hook["config"]["name"], + "desc": hook["config"]["description"], + "additive": [], "complete": []} + + for res in hook["results"]: + started_at = dt.datetime.utcfromtimestamp(res["started_at"]) + finished_at = dt.datetime.utcfromtimestamp(res["finished_at"]) + triggered_by = "%(event_type)s: %(value)s" % res["triggered_by"] + + for i, data in enumerate(res.get("output", {}).get("additive")): + try: + hook_ctx["additive"][i] + except IndexError: + chart_cls = plugin.Plugin.get(data["chart_plugin"]) + hook_ctx["additive"].append([chart_cls]) + hook_ctx["additive"][i].append(data) + + complete_charts = [] + for data in res.get("output", {}).get("complete"): + chart_cls = plugin.Plugin.get(data.pop("chart_plugin")) + data["widget"] = chart_cls.widget + complete_charts.append(data) + + if complete_charts: + hook_ctx["complete"].append( + {"triggered_by": triggered_by, + "started_at": started_at.strftime("%Y-%m-%d %H:%M:%S"), + "finished_at": finished_at.strftime("%Y-%m-%d %H:%M:%S"), + "status": res["status"], + "charts": complete_charts}) + + for i in range(len(hook_ctx["additive"])): + chart_cls = hook_ctx["additive"][i].pop(0) + iters_count = len(hook_ctx["additive"][i]) + first = hook_ctx["additive"][i][0] + descr = first.get("description", "") + axis_label = first.get("axis_label", "") + chart = chart_cls({"iterations_count": iters_count}, + title=first["title"], + description=descr, + label=first.get("label", ""), + axis_label=axis_label) + for data in hook_ctx["additive"][i]: + chart.add_iteration(data["data"]) + hook_ctx["additive"][i] = chart.render() + + if hook_ctx["additive"] or hook_ctx["complete"]: + hooks_ctx.append(hook_ctx) + return hooks_ctx + + def _process_scenario(data, pos): main_area = charts.MainStackedAreaChart(data["info"]) main_hist = charts.MainHistogramChart(data["info"]) @@ -75,6 +131,7 @@ def _process_scenario(data, pos): cls, method = data["key"]["name"].split(".") additive_output = [chart.render() for chart in additive_output_charts] iterations_count = data["info"]["iterations_count"] + return { "cls": cls, "met": method, @@ -82,6 +139,7 @@ def _process_scenario(data, pos): "name": method + (pos and " [%d]" % (pos + 1) or ""), "runner": kw["runner"]["type"], "config": json.dumps({data["key"]["name"]: [kw]}, indent=2), + "hooks": _process_hooks(data["hooks"]), "iterations": { "iter": main_area.render(), "pie": [("success", (data["info"]["iterations_count"] @@ -95,6 +153,7 @@ def _process_scenario(data, pos): "table": main_stat.render(), "additive_output": additive_output, "complete_output": complete_output, + "has_output": any(additive_output) or any(complete_output), "output_errors": output_errors, "errors": errors, "load_duration": data["info"]["load_duration"], diff --git a/rally/ui/templates/task/report.html b/rally/ui/templates/task/report.html index 69f71a6eca..0566ba89cc 100644 --- a/rally/ui/templates/task/report.html +++ b/rally/ui/templates/task/report.html @@ -121,7 +121,11 @@ },{ id: "output", name: "Scenario Data", - visible: function(){ return $scope.scenario.output.length } + visible: function(){ return $scope.scenario.has_output } + },{ + id: "hooks", + name: "Hooks", + visible: function(){ return $scope.scenario.hooks.length } },{ id: "failures", name: "Failures", @@ -138,22 +142,49 @@ $scope.showTab = function(uri) { $scope.tab = uri.hash in $scope.tabs_map ? uri.hash : "overview"; - if (! $scope.scenario.output) { - var has_additive = !! $scope.scenario.additive_output.length; - var has_complete = !! ($scope.scenario.complete_output.length - && $scope.scenario.complete_output[0].length); - $scope.scenario.output = { - has_additive: has_additive, - has_complete: has_complete, - length: has_additive + has_complete, - active: has_additive ? "additive" : (has_complete ? "complete" : "") - } - } if (uri.hash === "output") { + if (typeof $scope.scenario.output === "undefined") { + var has_additive = !! $scope.scenario.additive_output.length; + var has_complete = !! ($scope.scenario.complete_output.length + && $scope.scenario.complete_output[0].length); + $scope.scenario.output = { + has_additive: has_additive, + has_complete: has_complete, + length: has_additive + has_complete, + active: has_additive ? "additive" : (has_complete ? "complete" : "") + } + } if (uri.sub && $scope.scenario.output["has_" + uri.sub]) { $scope.scenario.output.active = uri.sub } } + else if (uri.hash === "hooks") { + if ($scope.scenario.hooks.length) { + var hook_idx = parseInt(uri.sub); + + if (isNaN(hook_idx) || ($scope.scenario.hooks.length - hook_idx) <= 0) { + hook_idx = 0 + } + + if ($scope.scenario.hook_idx === hook_idx) { + return + } + + $scope.scenario.hooks.cur = $scope.scenario.hooks[hook_idx]; + $scope.scenario.hook_idx = hook_idx; + if (typeof $scope.scenario.hooks.cur.active === "undefined") { + if ($scope.scenario.hooks.cur.additive.length) { + $scope.scenario.hooks.cur.active = "additive" + } + if ($scope.scenario.hooks.cur.complete.length) { + if (typeof $scope.scenario.hooks.cur.active === "undefined") { + $scope.scenario.hooks.cur.active = "complete" + } + $scope.set_hook_run() + } + } + } + } } for (var i in $scope.tabs) { @@ -178,6 +209,23 @@ } } + $scope.set_hook_run = function(idx) { + if (typeof idx !== "undefined") { + $scope.scenario.hooks.cur.run_idx = idx + } + else if (typeof $scope.scenario.hooks.cur.run_idx === "undefined") { + $scope.scenario.hooks.cur.run_idx = 0 + } + idx = $scope.scenario.hooks.cur.run_idx; + if (($scope.scenario.hooks.cur.complete.length - idx) > 0) { + $scope.scenario.hooks.cur.run = $scope.scenario.hooks.cur.complete[idx] + } + } + + $scope.complete_hooks_as_dropdown = function() { + return $scope.scenario.hooks.cur.complete.length > 10 + } + /* Other helpers */ $scope.showError = function(message) { @@ -262,6 +310,10 @@ .navmet:hover { background:#f8f8f8 } .navmet.active, .navmet.active:hover { background:#428bca; background-image:linear-gradient(to bottom, #428bca 0px, #3278b3 100%); border-color:#3278b3; color:#fff } + .buttn { color:#555; background:#fff; border:1px solid #ddd; border-radius:5px; font-size:12px; margin-bottom:-1px; padding:5px 7px; text-align:left; text-overflow:ellipsis; white-space:nowrap; overflow:hidden; cursor:pointer } + .buttn:hover { background:#f8f8f8 } + .buttn.active, .bttn.active:hover { background:#428bca; background-image:linear-gradient(to bottom, #428bca 0px, #3278b3 100%); border-color:#3278b3; color:#fff; cursor:default } + .tabs { list-style:outside none none; margin:0 0 5px; padding:0; border-bottom:1px solid #ddd } .tabs:after { clear:both } .tabs li { float:left; margin-bottom:-1px; display:block; position:relative } @@ -272,7 +324,7 @@ .failure-trace { color:#333; white-space:pre; overflow:auto } .link { color:#428BCA; padding:5px 15px 5px 5px; text-decoration:underline; cursor:pointer } - .link.active { color:#333; text-decoration:none } + .link.active { color:#333; text-decoration:none; cursor:default } .chart { padding:0; margin:0; width:890px } .chart svg { height:300px; padding:0; margin:0; overflow:visible; float:right } @@ -380,6 +432,13 @@ ▴ ▾ +