diff --git a/.travis.yml b/.travis.yml index 39e6026..85ba436 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,8 @@ cache: env: - TOXENV=py27-tornado2 + - TOXENV=py27-tornado3 + - TOXENV=py27-tornado4 - TOXENV=docs before_install: diff --git a/sickmuse/app.py b/sickmuse/app.py index dbf825c..bd4e9ac 100644 --- a/sickmuse/app.py +++ b/sickmuse/app.py @@ -7,7 +7,6 @@ from tornado.httpserver import HTTPServer from tornado.ioloop import IOLoop, PeriodicCallback -from tornado.process import task_id from tornado.options import define, parse_command_line, options from tornado.web import Application, url @@ -40,7 +39,7 @@ def __init__(self, **kwargs): super(APIApplication, self).__init__(handlers, **settings) rrd_directory = os.path.abspath(self.settings['rrd_directory']) # From base directory: host/plugin/instance.rrd - self.plugin_info = {} # Host --> Plugins --> Instances + self.plugin_info = {} # Host --> Plugins --> Instances for name in glob.glob(u"%s/*/*/*.rrd" % rrd_directory): name = name.replace(u"%s/" % rrd_directory, '') host, plugin = os.path.split(os.path.dirname(name)) @@ -113,5 +112,5 @@ def main(): IOLoop.instance().start() -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover main() diff --git a/sickmuse/handlers.py b/sickmuse/handlers.py index 9de8fcc..720f37c 100644 --- a/sickmuse/handlers.py +++ b/sickmuse/handlers.py @@ -1,4 +1,3 @@ -import glob import os import rrdtool @@ -50,13 +49,13 @@ def get_template_namespace(self): class RootHandler(TemplateHandler): - + def get(self): self.render("index.html") class HostHandler(TemplateHandler): - + def get(self, host_name): if host_name not in self.application.plugin_info: raise HTTPError(404, 'Host not found') @@ -69,7 +68,7 @@ def get(self, host_name): class MetricAPIHandler(RequestHandler): - + def get(self, host, metric): if host not in self.application.plugin_info: raise HTTPError(404, 'Host not found') @@ -92,16 +91,19 @@ def get(self, host, metric): )) start = str(date_range['start']) res = str(date_range['resolution']) - period, metrics, data = rrdtool.fetch(load_file, 'AVERAGE', '--start', start, '--resolution', res) + period, metrics, data = rrdtool.fetch( + load_file, 'AVERAGE', '--start', start, '--resolution', res) start, end, resolution = period - default = {'start': start, 'end': end, 'resolution': resolution, 'timeline': []} + default = {'start': start, 'end': end, 'resolution': resolution} if len(metrics) == 1: key = instance - instance_data[key] = default + instance_data[key] = default.copy() + instance_data[key]['timeline'] = [] else: for name in metrics: key = '%s-%s' % (instance, name) - instance_data[key] = default + instance_data[key] = default.copy() + instance_data[key]['timeline'] = [] for item in data: for i, name in enumerate(metrics): if len(metrics) == 1: diff --git a/sickmuse/tests/base.py b/sickmuse/tests/base.py index 2c5763d..d842d72 100644 --- a/sickmuse/tests/base.py +++ b/sickmuse/tests/base.py @@ -4,6 +4,11 @@ import shutil import tempfile +try: + from unittest.mock import patch, Mock +except ImportError: + from mock import patch, Mock # noqa + from ..app import APIApplication diff --git a/sickmuse/tests/test_app.py b/sickmuse/tests/test_app.py index 583668a..e706961 100644 --- a/sickmuse/tests/test_app.py +++ b/sickmuse/tests/test_app.py @@ -1,7 +1,8 @@ import os import unittest -from .base import ApplicationMixin +from ..app import shutdown +from .base import ApplicationMixin, Mock class ApplicationTest(ApplicationMixin, unittest.TestCase): @@ -77,3 +78,20 @@ def test_non_rrd_file(self): self.assertTrue(test_plugin in plugins) self.assertTrue(test_instance in plugins[test_plugin]) self.assertFalse(other_instance in plugins[test_plugin]) + + +class ShutdownTestCase(unittest.TestCase): + """Testing application shutdown.""" + + def test_graceful_shutdown(self): + """Graceful shutdown is the default.""" + server = Mock() + shutdown(server) + server.stop.assert_called_once_with() + + def test_forceful_shutdown(self): + """Immediately shutdown the server.""" + server = Mock() + with self.assertRaises(SystemExit): + shutdown(server, graceful=False) + server.stop.assert_called_once_with() diff --git a/sickmuse/tests/test_handlers.py b/sickmuse/tests/test_handlers.py index 547250a..d5900ef 100644 --- a/sickmuse/tests/test_handlers.py +++ b/sickmuse/tests/test_handlers.py @@ -1,6 +1,9 @@ +import json +import os + from tornado.testing import LogTrapTestCase, AsyncHTTPTestCase -from .base import ApplicationMixin +from .base import ApplicationMixin, patch class BaseHandlerTest(ApplicationMixin, LogTrapTestCase, AsyncHTTPTestCase): @@ -19,7 +22,7 @@ def test_render(self): class HostHandlerTest(BaseHandlerTest): def get_plugins(self): - return {'test-host': {'plugins': {'foo': 'bar'}}} + return {'test-host': {'plugins': {'foo': ['bar', ]}}} def test_valid_hostname(self): "Render valid host info" @@ -33,3 +36,190 @@ def test_invalid_hostname(self): self.http_client.fetch(self.get_url('/host/invalid-host'), self.stop) response = self.wait() self.assertEqual(response.code, 404) + + +class MetricHandlerTest(BaseHandlerTest): + + def get_plugins(self): + return { + 'test-host': { + 'plugins': { + 'foo': ['bar', ], + 'xxx': ['yyy', 'zzz', ] + } + } + } + + def test_get_metrics(self): + """Get metric info for a host/plugin.""" + with patch('sickmuse.handlers.rrdtool') as mock_rrdtool: + mock_rrdtool.fetch.return_value = [ + # Start, End, Resolution + (1492183000, 1492186640, 70), + # Metrics + ('baz', ), + # Data + [(1, ), (2, ), (3, ), ] + ] + self.http_client.fetch(self.get_url('/api/test-host/foo'), self.stop) + response = self.wait() + file_path = os.path.join(self.rrd_directory, 'test-host', 'foo', 'bar.rrd') + mock_rrdtool.fetch.assert_called_once_with( + file_path, 'AVERAGE', '--start', '-1h', '--resolution', '60' + ) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], 'application/json; charset=UTF-8') + result = json.loads(response.body) + expected = { + 'units': None, + 'instances': { + 'bar': { + 'start': 1492183000, + 'end': 1492186640, + 'resolution': 70, + 'timeline': [1, 2, 3] + } + } + } + self.assertEqual(result, expected) + + def test_plugin_with_mutliple_instances(self): + """Get more complex plugin info.""" + with patch('sickmuse.handlers.rrdtool') as mock_rrdtool: + mock_rrdtool.fetch.return_value = [ + # Start, End, Resolution + (1492183000, 1492186640, 70), + # Metrics + ('baz', ), + # Data + [(1, ), (2, ), (3, ), ] + ] + self.http_client.fetch(self.get_url('/api/test-host/xxx'), self.stop) + response = self.wait() + file_path = os.path.join(self.rrd_directory, 'test-host', 'xxx', 'yyy.rrd') + mock_rrdtool.fetch.assert_any_call( + file_path, 'AVERAGE', '--start', '-1h', '--resolution', '60' + ) + file_path = os.path.join(self.rrd_directory, 'test-host', 'xxx', 'zzz.rrd') + mock_rrdtool.fetch.assert_any_call( + file_path, 'AVERAGE', '--start', '-1h', '--resolution', '60' + ) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], 'application/json; charset=UTF-8') + result = json.loads(response.body) + expected = { + 'units': None, + 'instances': { + 'yyy': { + 'start': 1492183000, + 'end': 1492186640, + 'resolution': 70, + 'timeline': [1, 2, 3] + }, + 'zzz': { + 'start': 1492183000, + 'end': 1492186640, + 'resolution': 70, + 'timeline': [1, 2, 3] + } + } + } + self.assertEqual(result, expected) + + def test_plugin_with_multiple_metrics(self): + """Get more complex metric info.""" + with patch('sickmuse.handlers.rrdtool') as mock_rrdtool: + mock_rrdtool.fetch.return_value = [ + # Start, End, Resolution + (1492183000, 1492186640, 70), + # Metrics + ('blip', 'blah', ), + # Data + [(1, 4, ), (2, 5, ), (3, 6), ] + ] + self.http_client.fetch(self.get_url('/api/test-host/foo'), self.stop) + response = self.wait() + file_path = os.path.join(self.rrd_directory, 'test-host', 'foo', 'bar.rrd') + mock_rrdtool.fetch.assert_called_once_with( + file_path, 'AVERAGE', '--start', '-1h', '--resolution', '60' + ) + self.assertEqual(response.code, 200) + self.assertEqual(response.headers['Content-Type'], 'application/json; charset=UTF-8') + result = json.loads(response.body) + expected = { + 'units': None, + 'instances': { + 'bar-blip': { + 'start': 1492183000, + 'end': 1492186640, + 'resolution': 70, + 'timeline': [1, 2, 3] + }, + 'bar-blah': { + 'start': 1492183000, + 'end': 1492186640, + 'resolution': 70, + 'timeline': [4, 5, 6] + } + } + } + self.assertEqual(result, expected) + + def test_get_time_range(self): + """Optionally change the time range for the metric data.""" + with patch('sickmuse.handlers.rrdtool') as mock_rrdtool: + mock_rrdtool.fetch.return_value = [ + # Start, End, Resolution + (1492183000, 1492186640, 70), + # Metrics + ('baz', ), + # Data + [(1, ), (2, ), (3, ), ] + ] + tests = ( + # Parameter, (Start, Resolution) + ('1hr', ('-1h', '60')), + ('3hr', ('-3h', '3600')), + ('6hr', ('-6h', '3600')), + ('12hr', ('-12h', '3600')), + ('24hr', ('-1d', '86400')), + ('1week', ('-1w', '604800')), + ('1mon', ('-1mon', '2678400')), + ('3mon', ('-3mon', '2678400')), + ('6mon', ('-6mon', '2678400')), + ('1year', ('-1y', '31622400')), + ) + for param, (start, resolution) in tests: + url = self.get_url('/api/test-host/foo') + '?range=' + param + self.http_client.fetch(url, self.stop) + self.wait() + file_path = os.path.join(self.rrd_directory, 'test-host', 'foo', 'bar.rrd') + mock_rrdtool.fetch.assert_called_once_with( + file_path, 'AVERAGE', '--start', start, '--resolution', resolution + ) + mock_rrdtool.fetch.reset_mock() + + def test_invalid_host(self): + """Try to fetch metrics for an invalid host.""" + with patch('sickmuse.handlers.rrdtool') as mock_rrdtool: + self.http_client.fetch(self.get_url('/api/missing-host/foo'), self.stop) + response = self.wait() + self.assertFalse(mock_rrdtool.fetch.called) + self.assertEqual(response.code, 404) + + def test_invalid_plugin(self): + """Try to fetch metrics for an invalid plugin.""" + with patch('sickmuse.handlers.rrdtool') as mock_rrdtool: + self.http_client.fetch(self.get_url('/api/test-host/missing'), self.stop) + response = self.wait() + self.assertFalse(mock_rrdtool.fetch.called) + self.assertEqual(response.code, 404) + + def test_invalid_range(self): + """Handle invalid range parameters.""" + with patch('sickmuse.handlers.rrdtool') as mock_rrdtool: + url = self.get_url('/api/test-host/foo') + '?range=6b' + self.http_client.fetch(url, self.stop) + response = self.wait() + self.assertFalse(mock_rrdtool.fetch.called) + self.assertEqual(response.code, 400) diff --git a/tox.ini b/tox.ini index d880bbf..182d09b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,16 @@ [tox] -envlist = py{27}-tornado{2},docs +envlist = py{27}-tornado{2,3,4},docs [testenv] basepython = py27: python2.7 -commands = coverage run setup.py test +commands = coverage run -m unittest discover deps = coverage>=4.3,<4.4 + py27: mock>=2.0,<2.1 tornado2: tornado>=2.4,<3.0 + tornado3: tornado>=3.0,<4.0 + tornado4: tornado>=4.0,<5.0 passenv = CI TRAVIS