diff --git a/.gitignore b/.gitignore index ba49c89..1078f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ env26/ env27/ env32/ env33/ +.tox/ diff --git a/README.md b/README.md index 101ee33..2831ac4 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Imagine you want to take down the server `web01` for maintenance. Just SSH to it * `mysql_username`: username to use when logging into mysql for checks * `mysql_password`: password to use when logging into mysql for checks * `rlimit_nofile`: set the NOFILE rlimit. If the string "max", will set the rlimit to the hard rlimit; otherwise, will be interpreted as an integer and set to that value. +* `allow_remote_spool_changes`: whether to allow remote control of spool files. ### Monitoring diff --git a/hacheck/checker.py b/hacheck/checker.py index 45551a9..b1d2365 100644 --- a/hacheck/checker.py +++ b/hacheck/checker.py @@ -66,9 +66,13 @@ def timed_out(*args, **kwargs): # Do not cache spool checks @tornado.concurrent.return_future def check_spool(service_name, port, query, io_loop, callback, query_params, headers): - up, extra_info = spool.is_up(service_name) + up, extra_info = spool.is_up(service_name, port=port) if not up: info_string = 'Service %s in down state' % (extra_info['service'],) + if extra_info.get('creation') is not None: + info_string += ' since %f' % extra_info['creation'] + if extra_info.get('expiration') is not None: + info_string += ' until %f' % extra_info['expiration'] if extra_info.get('reason', ''): info_string += ": %s" % extra_info['reason'] callback((503, info_string)) diff --git a/hacheck/compat.py b/hacheck/compat.py index 5b412ab..085fd27 100644 --- a/hacheck/compat.py +++ b/hacheck/compat.py @@ -57,6 +57,7 @@ def nested3(*managers): def bchr3(c): return bytes((c,)) + def bchr2(c): return chr(c) diff --git a/hacheck/config.py b/hacheck/config.py index 58e869b..d31931b 100644 --- a/hacheck/config.py +++ b/hacheck/config.py @@ -14,7 +14,8 @@ def max_or_int(some_str_value): 'log_path': (str, 'stderr'), 'mysql_username': (str, None), 'mysql_password': (str, None), - 'rlimit_nofile': (max_or_int, None) + 'rlimit_nofile': (max_or_int, None), + 'allow_remote_spool_changes': (bool, False), } diff --git a/hacheck/handlers.py b/hacheck/handlers.py index 97da557..bc45924 100644 --- a/hacheck/handlers.py +++ b/hacheck/handlers.py @@ -10,6 +10,8 @@ from . import cache from . import checker +from . import config +from . import spool log = logging.getLogger('hacheck') @@ -105,6 +107,34 @@ def get(self, service_name, port, query): class SpoolServiceHandler(BaseServiceHandler): CHECKERS = [checker.check_spool] + def post(self, service_name, port, query): + if not config.config['allow_remote_spool_changes']: + self.set_status(403) + self.write('remote spool changes are not enabled') + return + + port = int(port) or None + status = self.get_argument('status') + + if status == 'up': + spool.up(service_name, port=port) + elif status == 'down': + expiration = self.get_argument('expiration', None) + if expiration is not None: + expiration = float(expiration) + reason = self.get_argument('reason') + creation = self.get_argument('creation', None) + if creation is not None: + creation = float(creation) + spool.down(service_name, reason=reason, port=port, expiration=expiration, creation=creation) + else: + self.set_status(400) + self.write("status must be up or down") + return + + self.set_status(200) + self.write("") + class HTTPServiceHandler(BaseServiceHandler): CHECKERS = [checker.check_spool, checker.check_http] diff --git a/hacheck/haupdown.py b/hacheck/haupdown.py index 3a5dcde..07bbb31 100644 --- a/hacheck/haupdown.py +++ b/hacheck/haupdown.py @@ -40,6 +40,20 @@ def print_s(fmt_string, *formats): print(fmt_string % formats) +def print_status(service_name, port, is_up, info_dict): + """Print a status line for a given service""" + if is_up: + print_s('UP\t%s', service_name) + else: + expiration = info_dict.get('expiration') + if expiration is None: + expiration = float('Inf') + if port is not None: + print_s('DOWN\t%f\t%s:%d\t%s', expiration, service_name, port, info_dict.get('reason', '')) + else: + print_s('DOWN\t%f\t%s\t%s', expiration, service_name, info_dict.get('reason', '')) + + def main(default_action='list'): ACTIONS = ('up', 'down', 'status', 'status_downed', 'list') parser = optparse.OptionParser(usage='%prog [options] service_name(s)') @@ -63,6 +77,20 @@ def main(default_action='list'): default="", help='Reason string when setting down' ) + parser.add_option( + '-e', + '--expiration', + type=float, + default=None, + help='Expiration time (unix time) when setting down', + ) + parser.add_option( + '-P', + '--service-port', + type=int, + default=None, + help='Port to check/set status for', + ) parser.add_option( '-p', '--port', @@ -116,27 +144,25 @@ def main(default_action='list'): elif opts.action == 'up': hacheck.spool.configure(opts.spool_root, needs_write=True) for service_name in service_names: - hacheck.spool.up(service_name) + hacheck.spool.up(service_name, port=opts.service_port) return 0 elif opts.action == 'down': hacheck.spool.configure(opts.spool_root, needs_write=True) for service_name in service_names: - hacheck.spool.down(service_name, opts.reason) + hacheck.spool.down(service_name, opts.reason, expiration=opts.expiration, port=opts.service_port) return 0 elif opts.action == 'status_downed': hacheck.spool.configure(opts.spool_root, needs_write=False) - for service_name, info in hacheck.spool.status_all_down(): - print_s('DOWN\t%s\t%s', service_name, info.get('reason', '')) + for service_name, port, info in hacheck.spool.status_all_down(): + print_status(service_name, port, False, info) return 0 else: hacheck.spool.configure(opts.spool_root, needs_write=False) rv = 0 for service_name in service_names: - status, info = hacheck.spool.status(service_name) - if status: - print_s('UP\t%s', service_name) - else: - print_s('DOWN\t%s\t%s', service_name, info.get('reason', '')) + status, info = hacheck.spool.status(service_name, port=opts.service_port) + print_status(service_name, opts.service_port, status, info) + if not status: rv = 1 return rv diff --git a/hacheck/spool.py b/hacheck/spool.py index 1519b89..0b659d8 100644 --- a/hacheck/spool.py +++ b/hacheck/spool.py @@ -1,10 +1,54 @@ +import json import os +import time config = { 'spool_root': None, } +def spool_file_path(service_name, port): + if port is None: + base_name = service_name + else: + base_name = "%s:%s" % (service_name, port) + + return os.path.join(config['spool_root'], base_name) + + +def parse_spool_file_path(path): + base_name = os.path.basename(path) + + if ':' in base_name: + service_name, port = base_name.rsplit(':', 1) + port = int(port) + else: + service_name = base_name + port = None + + return service_name, port + + +def serialize_spool_file_contents(reason, expiration=None, creation=None): + return json.dumps({ + "reason": reason, + "expiration": expiration, + "creation": (time.time() if creation is None else creation), + }) + + +def deserialize_spool_file_contents(contents): + try: + return json.loads(contents) + except ValueError: + # in case we're looking at a file created by earlier versions of hacheck + return { + "reason": contents, + "expiration": None, + "creation": None, + } + + def configure(spool_root, needs_write=False): access_required = os.W_OK | os.R_OK if needs_write else os.R_OK if os.path.exists(spool_root): @@ -15,7 +59,7 @@ def configure(spool_root, needs_write=False): config['spool_root'] = spool_root -def is_up(service_name): +def is_up(service_name, port=None): """Check whether a service is asserted to be up or down. Includes the logic for checking system-wide all state @@ -23,23 +67,35 @@ def is_up(service_name): """ all_up, all_info = status("all") if all_up: - return status(service_name) + # Check with port=None first, because if service foo is down, then service foo on port 123 should be down too. + service_up, service_info = status(service_name, port=None) + if service_up: + return status(service_name, port=port) + else: + return service_up, service_info else: return all_up, all_info -def status(service_name): +def status(service_name, port=None): """Check whether a service is asserted to be up or down, without checking the system-wide 'all' state. :returns: (bool of service status, dict of extra information) """ + happy_retval = (True, {'service': service_name, 'reason': '', 'expiration': None}) + path = spool_file_path(service_name, port) try: - with open(os.path.join(config['spool_root'], service_name), 'r') as f: - reason = f.read() - return False, {'service': service_name, 'reason': reason} + with open(path, 'r') as f: + info_dict = deserialize_spool_file_contents(f.read()) + info_dict['service'] = service_name + expiration = info_dict.get('expiration') + if expiration is not None and expiration < time.time(): + os.remove(path) + return happy_retval + return False, info_dict except IOError: - return True, {'service': service_name, 'reason': ''} + return happy_retval def status_all_down(): @@ -47,19 +103,28 @@ def status_all_down(): :returns: Iterable of pairs of (service name, dict of extra information) """ - for service_name in os.listdir(config['spool_root']): - up, info = status(service_name) + for filename in os.listdir(config['spool_root']): + service_name, port = parse_spool_file_path(filename) + up, info = status(service_name, port=port) if not up: - yield service_name, info + yield service_name, port, info -def up(service_name): +def up(service_name, port=None): try: - os.unlink(os.path.join(config['spool_root'], service_name)) + os.unlink(spool_file_path(service_name, port)) except OSError: pass -def down(service_name, reason=""): - with open(os.path.join(config['spool_root'], service_name), 'w') as f: - f.write(reason) +def down(service_name, reason="", port=None, expiration=None, creation=None): + currently_up, info = status(service_name, port=port) + + # If we already downed the service for the same reason, leave the creation time alone. This allows a user to + # repeatedly down a service to refresh its expiration time, and we will keep track of how long it has been down + # for. + if creation is None and (not currently_up) and reason == info['reason']: + creation = info.get('creation', creation) + + with open(spool_file_path(service_name, port), 'w') as f: + f.write(serialize_spool_file_contents(reason, expiration=expiration, creation=creation)) diff --git a/tests/test_application.py b/tests/test_application.py index ef9e646..8dbb6d0 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -12,6 +12,7 @@ from hacheck import main from hacheck import spool from hacheck import cache +from hacheck import config from hacheck import handlers @@ -80,6 +81,14 @@ def test_spool_checker(self): response = self.fetch('/spool/foo/1/status') self.assertEqual(response.code, 503) self.assertEqual(response.body, b'Service any in down state: just because') + with mock.patch.object( + spool, + 'is_up', + return_value=(False, {"service": "any", "reason": "reason", "expiration": 5, "creation": 4}) + ): + response = self.fetch('/spool/foo/1/status') + self.assertEqual(response.code, 503) + self.assertRegexpMatches(response.body, b'^Service any in down state since 4\.0+ until 5\.0+: reason$') def test_calls_all_checkers(self): rv1 = tornado.concurrent.Future() @@ -176,3 +185,29 @@ def test_show_recent(self): 'seen_services': [['foo', {'code': 200, 'ts': mock.ANY, 'remote_ip': '127.0.0.1'}]], 'threshold_seconds': 20 }) + + def test_remote_spool_check_forbidden(self): + with mock.patch.dict(config.config, {'allow_remote_spool_changes': False}): + response = self.fetch('/spool/foo/1/status', method='POST', body="") + self.assertEqual(response.code, 403) + + def test_spool_post(self): + with nested( + mock.patch.dict(config.config, {'allow_remote_spool_changes': True}), + mock.patch.object(spool, 'up'), + mock.patch.object(spool, 'down'), + ) as (_1, spool_up, spool_down): + + response = self.fetch('/spool/foo/0/status', method='POST', body="status=up") + self.assertEqual(response.code, 200) + spool_up.assert_called_once_with('foo', port=None) + + response = self.fetch('/spool/foo/1234/status', method='POST', body="status=down&reason=because") + self.assertEqual(response.code, 200) + spool_down.assert_called_once_with('foo', reason='because', port=1234, expiration=None, creation=None) + + spool_down.reset_mock() + response = self.fetch('/spool/foo/1234/status', method='POST', + body="status=down&reason=because&expiration=1&creation=2") + self.assertEqual(response.code, 200) + spool_down.assert_called_once_with('foo', reason='because', port=1234, expiration=1, creation=2) diff --git a/tests/test_callables.py b/tests/test_callables.py index d2ac310..16c6680 100644 --- a/tests/test_callables.py +++ b/tests/test_callables.py @@ -36,12 +36,18 @@ def test_exit_codes(self): mock_print.assert_any_call('UP\t%s', sentinel_service_name) spooler.status.return_value = (False, {'reason': 'irrelevant'}) self.assertEqual(1, hacheck.haupdown.main('status')) - mock_print.assert_any_call('DOWN\t%s\t%s', sentinel_service_name, 'irrelevant') + mock_print.assert_any_call('DOWN\t%f\t%s\t%s', float('Inf'), sentinel_service_name, 'irrelevant') def test_up(self): with self.setup_wrapper([sentinel_service_name]) as (spooler, mock_print): hacheck.haupdown.up() - spooler.up.assert_called_once_with(sentinel_service_name) + spooler.up.assert_called_once_with(sentinel_service_name, port=None) + self.assertEqual(mock_print.call_count, 0) + + def test_up_with_port(self): + with self.setup_wrapper(['-P', '1234', sentinel_service_name]) as (spooler, mock_print): + hacheck.haupdown.up() + spooler.up.assert_called_once_with(sentinel_service_name, port=1234) self.assertEqual(mock_print.call_count, 0) def test_down(self): @@ -49,28 +55,53 @@ def test_down(self): os.environ['SUDO_USER'] = 'testyuser' with self.setup_wrapper([sentinel_service_name]) as (spooler, mock_print): hacheck.haupdown.down() - spooler.down.assert_called_once_with(sentinel_service_name, - 'testyuser') + spooler.down.assert_called_once_with(sentinel_service_name, 'testyuser', expiration=None, port=None) self.assertEqual(mock_print.call_count, 0) def test_down_with_reason(self): with self.setup_wrapper(['-r', 'something', sentinel_service_name]) as (spooler, mock_print): hacheck.haupdown.down() - spooler.down.assert_called_once_with(sentinel_service_name, 'something') + spooler.down.assert_called_once_with(sentinel_service_name, 'something', expiration=None, port=None) + self.assertEqual(mock_print.call_count, 0) + + def test_down_with_expiration(self): + with self.setup_wrapper(['-e', '9876543210', sentinel_service_name]) as (spooler, mock_print): + hacheck.haupdown.down() + spooler.down.assert_called_once_with(sentinel_service_name, 'testyuser', expiration=9876543210, port=None) + self.assertEqual(mock_print.call_count, 0) + + def test_down_with_port(self): + with self.setup_wrapper(['-P', '1234', sentinel_service_name]) as (spooler, mock_print): + hacheck.haupdown.down() + spooler.down.assert_called_once_with(sentinel_service_name, 'testyuser', expiration=None, port=1234) self.assertEqual(mock_print.call_count, 0) def test_status(self): with self.setup_wrapper([sentinel_service_name]) as (spooler, mock_print): spooler.status.return_value = (True, {}) hacheck.haupdown.status() - spooler.status.assert_called_once_with(sentinel_service_name) + spooler.status.assert_called_once_with(sentinel_service_name, port=None) mock_print.assert_called_once_with("UP\t%s", sentinel_service_name) def test_status_downed(self): with self.setup_wrapper() as (spooler, mock_print): - spooler.status_all_down.return_value = [(sentinel_service_name, {'service': sentinel_service_name, 'reason': ''})] + spooler.status_all_down.return_value = [ + (sentinel_service_name, None, {'service': sentinel_service_name, 'reason': '', 'expiration': None}) + ] self.assertEqual(hacheck.haupdown.status_downed(), 0) - mock_print.assert_called_once_with("DOWN\t%s\t%s", sentinel_service_name, mock.ANY) + mock_print.assert_called_once_with("DOWN\t%f\t%s\t%s", float('Inf'), sentinel_service_name, mock.ANY) + + def test_status_downed_expiration(self): + with self.setup_wrapper() as (spooler, mock_print): + spooler.status_all_down.return_value = [ + ( + sentinel_service_name, + None, + {'service': sentinel_service_name, 'reason': '', 'expiration': 9876543210} + ), + ] + self.assertEqual(hacheck.haupdown.status_downed(), 0) + mock_print.assert_called_once_with("DOWN\t%f\t%s\t%s", 9876543210, sentinel_service_name, mock.ANY) def test_list(self): with self.setup_wrapper() as (spooler, mock_print): @@ -82,3 +113,16 @@ def test_list(self): self.assertEqual(hacheck.haupdown.halist(), 0) mock_urlopen.assert_called_once_with('http://127.0.0.1:3333/recent', timeout=mock.ANY) mock_print.assert_called_once_with("foo") + + def test_print_status(self): + with self.setup_wrapper() as (spooler, mock_print): + hacheck.haupdown.print_status('foo', None, True, {}) + mock_print.assert_called_once_with('UP\t%s', 'foo') + + with self.setup_wrapper() as (spooler, mock_print): + hacheck.haupdown.print_status('foo', None, False, {'reason': 'somereason'}) + mock_print.assert_called_once_with('DOWN\t%f\t%s\t%s', float('Inf'), 'foo', 'somereason') + + with self.setup_wrapper() as (spooler, mock_print): + hacheck.haupdown.print_status('foo', 1234, False, {'reason': 'somereason'}) + mock_print.assert_called_once_with('DOWN\t%f\t%s:%d\t%s', float('Inf'), 'foo', 1234, 'somereason') diff --git a/tests/test_checker.py b/tests/test_checker.py index c07abb9..d04d0eb 100644 --- a/tests/test_checker.py +++ b/tests/test_checker.py @@ -47,17 +47,19 @@ def get(self): class TestChecker(TestCase): def test_spool_success(self): - with mock.patch.object(spool, 'is_up', return_value=(True, {})): + with mock.patch.object(spool, 'is_up', return_value=(True, {})) as is_up_patch: fut = checker.check_spool(se.name, se.port, se.query, None, query_params=None, headers={}) self.assertIsInstance(fut, tornado.concurrent.Future) self.assertTrue(fut.done()) res = fut.result() self.assertEqual(res[0], 200) + is_up_patch.assert_called_once_with(se.name, port=se.port) def test_spool_failure(self): - with mock.patch.object(spool, 'is_up', return_value=(False, {'service': se.service})): + with mock.patch.object(spool, 'is_up', return_value=(False, {'service': se.service})) as is_up_patch: fut = checker.check_spool(se.name, se.port, se.query, None, query_params=None, headers={}) self.assertEqual(fut.result()[0], 503) + is_up_patch.assert_called_once_with(se.name, port=se.port) class TestHTTPChecker(tornado.testing.AsyncHTTPTestCase): @@ -72,38 +74,45 @@ def get_app(self): @tornado.testing.gen_test def test_check_success(self): - response = yield checker.check_http("foo", self.get_http_port(), "/", io_loop=self.io_loop, query_params="", headers={}) + response = yield checker.check_http("foo", self.get_http_port(), "/", io_loop=self.io_loop, query_params="", + headers={}) self.assertEqual((200, b'TEST OK'), response) @tornado.testing.gen_test def test_check_failure(self): - code, response = yield checker.check_http("foo", self.get_http_port(), "/bar", io_loop=self.io_loop, query_params="", headers={}) + code, response = yield checker.check_http("foo", self.get_http_port(), "/bar", io_loop=self.io_loop, + query_params="", headers={}) self.assertEqual(404, code) @tornado.testing.gen_test def test_check_failure_with_code(self): - code, response = yield checker.check_http("foo", self.get_http_port(), "/bip", io_loop=self.io_loop, query_params="", headers={}) + code, response = yield checker.check_http("foo", self.get_http_port(), "/bip", io_loop=self.io_loop, + query_params="", headers={}) self.assertEqual(501, code) @tornado.testing.gen_test def test_check_wrong_port(self): - code, response = yield checker.check_http("foo", self.get_http_port() + 1, "/", io_loop=self.io_loop, query_params="", headers={}) + code, response = yield checker.check_http("foo", self.get_http_port() + 1, "/", io_loop=self.io_loop, + query_params="", headers={}) self.assertEqual(599, code) @tornado.testing.gen_test def test_service_name_header(self): with mock.patch.dict(config.config, {'service_name_header': 'SName'}): - code, response = yield checker.check_http('service_name', self.get_http_port(), "/sname", io_loop=self.io_loop, query_params="", headers={}) + code, response = yield checker.check_http('service_name', self.get_http_port(), "/sname", + io_loop=self.io_loop, query_params="", headers={}) self.assertEqual(b'service_name', response) @tornado.testing.gen_test def test_query_params_passed(self): - response = yield checker.check_http("foo", self.get_http_port(), "/echo_foo", io_loop=self.io_loop, query_params="foo=bar", headers={}) + response = yield checker.check_http("foo", self.get_http_port(), "/echo_foo", io_loop=self.io_loop, + query_params="foo=bar", headers={}) self.assertEqual((200, b'bar'), response) @tornado.testing.gen_test def test_query_params_not_passed(self): - response = yield checker.check_http("foo", self.get_http_port(), "/echo_foo", io_loop=self.io_loop, query_params="", headers={}) + response = yield checker.check_http("foo", self.get_http_port(), "/echo_foo", io_loop=self.io_loop, + query_params="", headers={}) self.assertEqual(400, response[0]) @@ -142,5 +151,6 @@ def test_check_success(self): @tornado.testing.gen_test def test_check_failure(self): with mock.patch.object(checker, 'TIMEOUT', 1): - response = yield checker.check_tcp("foo", self.unlistened_port, None, io_loop=self.io_loop, query_params="", headers={}) + response = yield checker.check_tcp("foo", self.unlistened_port, None, io_loop=self.io_loop, query_params="", + headers={}) self.assertEqual(response[0], 503) diff --git a/tests/test_integration.py b/tests/test_integration.py index 1d11e11..93e904d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -68,7 +68,7 @@ def test_down_and_up(self): hacheck.spool.down('test_app', 'TESTING') response = self.fetch('/http/test_app/%d/pinged' % self.get_http_port()) self.assertEqual(503, response.code) - self.assertEqual(b'Service test_app in down state: TESTING', response.body) + self.assertRegexpMatches(response.body, b'^Service test_app in down state since (\d|\.)*: TESTING$') hacheck.spool.up('test_app') response = self.fetch('/http/test_app/%d/pinged' % self.get_http_port()) self.assertEqual(b'PONG', response.body) diff --git a/tests/test_mysql.py b/tests/test_mysql.py index f91361d..b18b08e 100644 --- a/tests/test_mysql.py +++ b/tests/test_mysql.py @@ -3,8 +3,6 @@ except ImportError: from unittest import TestCase -#import tornado.testing - from hacheck import mysql diff --git a/tests/test_spool.py b/tests/test_spool.py index 57ca3fb..e5e981c 100644 --- a/tests/test_spool.py +++ b/tests/test_spool.py @@ -36,9 +36,15 @@ def test_configure_no_write_no_needs_write(self): def test_basic(self): svcname = 'test_basic' self.assertEquals(True, spool.status(svcname)[0]) + self.assertEquals(True, spool.status(svcname, 1234)[0]) + spool.down(svcname, port=1234) + self.assertEquals(True, spool.status(svcname)[0]) + self.assertEquals(False, spool.status(svcname, 1234)[0]) spool.down(svcname) self.assertEquals(False, spool.status(svcname)[0]) self.assertEquals(False, spool.is_up(svcname)[0]) + spool.up(svcname, port=1234) + self.assertEquals(True, spool.status(svcname, 1234)[0]) spool.up(svcname) self.assertEquals(True, spool.status(svcname)[0]) @@ -52,8 +58,109 @@ def test_all(self): def test_status_all_down(self): self.assertEqual(len(list(spool.status_all_down())), 0) spool.down('foo') - self.assertEqual(list(spool.status_all_down()), [('foo', {'service': 'foo', 'reason': ''})]) + self.assertEqual( + list(spool.status_all_down()), + [('foo', None, {'service': 'foo', 'reason': '', 'expiration': None, 'creation': mock.ANY})] + ) def test_repeated_ups_works(self): spool.up('all') spool.up('all') + + def test_spool_file_path(self): + self.assertEqual(os.path.join(self.root, 'foo:1234'), spool.spool_file_path("foo", port=1234)) + self.assertEqual(os.path.join(self.root, 'foo'), spool.spool_file_path("foo", None)) + + def test_parse_spool_file_path(self): + self.assertEqual(("foo", 1234), spool.parse_spool_file_path(spool.spool_file_path("foo", 1234))) + + def test_serialize_spool_file_contents(self): + actual = spool.serialize_spool_file_contents("hi", expiration=12345, creation=54321) + assert '"reason": "hi"' in actual + assert '"expiration": 12345' in actual + assert '"creation": 54321' in actual + + def test_deserialize_spool_file_contents_legacy(self): + actual = spool.deserialize_spool_file_contents("this is a reason") + self.assertEqual(actual, {"reason": "this is a reason", "expiration": None, "creation": None}) + + def test_deserialize_spool_file_contents_new(self): + actual = spool.deserialize_spool_file_contents('{"reason": "hi", "expiration": 12345, "creation": 12344}') + self.assertEqual(actual, {"reason": "hi", "expiration": 12345, "creation": 12344}) + + def test_status_creation(self): + now = 1000 + svcname = 'test_status_creation' + + with mock.patch('time.time', return_value=now): + spool.down(svcname) + up, info_dict = spool.status(svcname) + spool.up(svcname) + + self.assertEqual(info_dict['creation'], 1000) + + def test_status_expiration(self): + svcname = 'test_status_expiration' + now = 1000 + future = now + 10 + past = now - 10 + + with mock.patch('time.time', return_value=now): + self.assertEquals(True, spool.status(svcname)[0]) + + # First, check with expiration in future; everything should behave normally. + spool.down(svcname, expiration=future) + self.assertEquals(False, spool.status(svcname)[0]) + + # Now, let's make sure we remove the spool file if its expiration is in the past. + spool.down(svcname, expiration=past) + self.assertEquals(True, os.path.exists(spool.spool_file_path(svcname, None))) + + self.assertEquals(True, spool.status(svcname)[0]) + self.assertEquals(False, os.path.exists(spool.spool_file_path(svcname, None))) + + def test_repeated_downs_leaves_original_creation_time(self): + first_creation = 1000 + first_expiration = first_creation + 60 + second_creation = first_creation + 10 + second_expiration = second_creation + 60 + with mock.patch('time.time', return_value=first_creation): + spool.down('all', expiration=first_expiration) + with mock.patch('time.time', return_value=second_creation): + spool.down('all', expiration=second_expiration) + + with mock.patch('time.time', return_value=second_creation): + _, info = spool.status('all') + + self.assertEquals(info['creation'], first_creation) + self.assertEquals(info['expiration'], second_expiration) + + def test_repeated_downs_with_different_reason_changes_creation_time(self): + first_creation = 1000 + first_expiration = first_creation + 60 + second_creation = first_creation + 10 + second_expiration = second_creation + 60 + with mock.patch('time.time', return_value=first_creation): + spool.down('all', reason="first reason", expiration=first_expiration) + with mock.patch('time.time', return_value=second_creation): + spool.down('all', reason="second reason", expiration=second_expiration) + + with mock.patch('time.time', return_value=second_creation): + _, info = spool.status('all') + + self.assertEquals(info['creation'], second_creation) + self.assertEquals(info['expiration'], second_expiration) + + def test_repeated_downs_with_creation_arg_changes_creation_time(self): + first_creation = 1000 + first_expiration = first_creation + 60 + second_creation = first_creation + 10 + second_expiration = second_creation + 60 + spool.down('all', creation=first_creation, expiration=first_expiration) + spool.down('all', creation=second_creation, expiration=second_expiration) + + with mock.patch('time.time', return_value=second_creation): + _, info = spool.status('all') + + self.assertEquals(info['creation'], second_creation) + self.assertEquals(info['expiration'], second_expiration) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c0ece85 --- /dev/null +++ b/tox.ini @@ -0,0 +1,29 @@ +[tox] +basepython = python2.7 +envlist = py26, py27, py34 + +[testenv] +usedevelop=True +basepython = python2.7 +install_command = pip install --upgrade {opts} {packages} +deps = + -rrequirements-tests.txt + flake8 +commands = + flake8 hacheck tests + nosetests + +[testenv:py27] +basepython = python2.7 +deps = + {[testenv]deps} + -rrequirements-py2.txt + +[testenv:py34] +basepython = python3.4 + +[testenv:py26] +basepython = python2.6 +deps = + {[testenv]deps} + -rrequirements-py2.txt \ No newline at end of file