diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..f283220 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,8 @@ +# Authors +This file contains the list of people involved in the development +of ianitor along its history. + +* Michał Jaworski + +Great thanks to [Clearcode](http://clearcode.cc) for allowing releasing +ianitor as free software! \ No newline at end of file diff --git a/COPYING b/COPYING index 4a87280..02bbb60 100644 --- a/COPYING +++ b/COPYING @@ -1,13 +1,165 @@ - DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE - Version 2, December 2004 + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 - Copyright (C) 2004 Sam Hocevar + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. - Everyone is permitted to copy and distribute verbatim or modified - copies of this license document, and changing it is allowed as long - as the name is changed. - DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. - 0. You just DO WHAT THE FUCK YOU WANT TO. \ No newline at end of file + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. \ No newline at end of file diff --git a/README.md b/README.md index 5c4efe8..f537cc2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -[![WTFPL](http://www.wtfpl.net/wp-content/uploads/2012/12/wtfpl-badge-4.png)](http://www.wtfpl.net/) [![Build Status](https://travis-ci.org/ClearcodeHQ/ianitor.svg?branch=master)](https://travis-ci.org/ClearcodeHQ/ianitor) # ianitor @@ -18,13 +17,100 @@ your existing process/service supervision tool like Simply install with pip: - pip install ianitor + $ pip install ianitor And you're ready to go with: - ianitor - yourapp --some-switch + $ ianitor appname -- ./yourapp --some-switch +You can check if service is registered diggin' into consul DNS service: + + $ dig @localhost -p 8600 appname.service.consul + ; <<>> DiG 9.9.3-P1 <<>> @localhost -p 8600 appname.service.consul + ; (1 server found) + ;; global options: +cmd + ;; Got answer: + ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 25966 + ;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0 + ;; WARNING: recursion requested but not available + + ;; QUESTION SECTION: + ;appname.service.consul. IN A + + ;; ANSWER SECTION: + appname.service.consul. 0 IN A 10.54.54.214 + + ;; Query time: 44 msec + ;; SERVER: 127.0.0.1#8600(127.0.0.1) + ;; WHEN: Tue Oct 28 13:53:09 CET 2014 + ;; MSG SIZE rcvd: 78 + +Full usage: + + usage: ianitor [-h] [--consul-agent hostname[:port]] [--ttl seconds] + [--heartbeat seconds] [--tags tag] [--id ID] [--port PORT] [-v] + service-name -- command [arguments] + + Doorkeeper for consul discovered services. + + positional arguments: + service-name service name in consul cluster + + optional arguments: + -h, --help show this help message and exit + --consul-agent=hostname[:port] set consul agent address + --ttl=seconds set TTL of service in consul cluster + --heartbeat=seconds set rocess poll heartbeat (defaults to + ttl/10) + --tags=tag set service tags in consul cluster (can be + used multiple times) + --id=ID set service id - must be node unique + (defaults to service name) + --port=PORT set service port + -v, --verbose enable logging to stdout (use multiple times + to increase verbosity) + + +## How does ianitor work? + +ianitor spawns process using python's `subprocess.Popen()` with command line +specified after `--` . It redirects its own stdin to child's stdin and +childs stdout/stderr to his own stdout/stderr. + +This way ianitor does not interfere with logging of managed service if it +logs to stdout. Moreover ianitor does not log anything to make it easier to +plug it in your existing process supervision tool. + +ianitor handles service registration in consul agent as well as keeping +registered service entry in consul in "healthy" state by continously requesting +it's [TTL health check endpoint](http://www.consul.io/docs/agent/checks.html). + +## Example supervisord config + +Assuming that you have some service under supervisord supervision: + + [program:rabbitmq] + command=/usr/sbin/rabbitmq-server + priority=0 + + autostart=true + +Simply wrap it with ianitor call: + + [program:rabbitmq] + command=/usr/local/bin/ianitor rabbitmq -- /usr/sbin/rabbitmq-server + priority=0 + + autostart=true + ## Licence -This code is under [WTFPL](https://en.wikipedia.org/wiki/WTFPL). -Just do what the fuck you want with it. \ No newline at end of file +`ianitor` is licensed under LGPL license, version 3. + + +## Contributing and reporting bugs + +Source code is available at: +[ClearcodeHQ/ianitor](https://github.com/ClearcodeHQ/ianitor). Issue tracker +is located at [GitHub Issues](https://github.com/ClearcodeHQ/ianitor/issues). +Projects [PyPi page](https://pypi.python.org/pypi/ianitor). \ No newline at end of file diff --git a/requirements-tests.txt b/requirements-tests.txt new file mode 100644 index 0000000..68e751a --- /dev/null +++ b/requirements-tests.txt @@ -0,0 +1,2 @@ +pytest +mock \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 663bd1f..6024d6c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests \ No newline at end of file +python-consul \ No newline at end of file diff --git a/setup.py b/setup.py index cb3d1a8..887da71 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,22 @@ # -*- coding: utf-8 -*- -# -*- coding: utf-8 -*- +# Copyright (C) 2014 by Clearcode +# and associates (see AUTHORS.md). + +# This file is part of ianitor. + +# mirakuru is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# ianitor is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License +# along with ianitor. If not, see . + from setuptools import setup, find_packages import os @@ -25,33 +42,43 @@ def get_version(version_tuple): INSTALL_REQUIRES = reqs('requirements.txt') -README = open(os.path.join(os.path.dirname(__file__), 'README.md')).read() +try: + from pypandoc import convert + read_md = lambda f: convert(f, 'rst') +except ImportError: + print( + "warning: pypandoc module not found, could not convert Markdown to RST" + ) + read_md = lambda f: open(f, 'r').read() + +README = os.path.join(os.path.dirname(__file__), 'README.md') PACKAGES = find_packages('src') PACKAGE_DIR = {'': 'src'} setup( name='ianitor', version=VERSION, - author='Michał Jaworski', - author_email='swistakm@gmail.com', + author='Clearcode - The A Room', + author_email='thearoom@clearcode.cc', description='Doorkeeper for consul discovered services.', - long_description=README, + long_description=read_md(README), packages=PACKAGES, package_dir=PACKAGE_DIR, - url='https://github.com/swistakm/ianitor', + url='https://github.com/ClearcodeHQ/ianitor', include_package_data=True, install_requires=INSTALL_REQUIRES, zip_safe=False, - license="WTFPL", + license="LGPL", classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', # noqa ], entry_points={ diff --git a/src/ianitor/__init__.py b/src/ianitor/__init__.py index 342f25e..5e18e73 100644 --- a/src/ianitor/__init__.py +++ b/src/ianitor/__init__.py @@ -1,4 +1,22 @@ # -*- coding: utf-8 -*- +# Copyright (C) 2014 by Clearcode +# and associates (see AUTHORS.md). + +# This file is part of ianitor. + +# ianitor is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# ianitor is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License +# along with ianitor. If not, see . + # flake8: noqa VERSION = (0, 0, 1) # PEP 386 __version__ = ".".join([str(x) for x in VERSION]) diff --git a/src/ianitor/args_parser.py b/src/ianitor/args_parser.py new file mode 100644 index 0000000..1249400 --- /dev/null +++ b/src/ianitor/args_parser.py @@ -0,0 +1,201 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 by Clearcode +# and associates (see AUTHORS.md). + +# This file is part of ianitor. + +# ianitor is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# ianitor is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License +# along with ianitor. If not, see . + +import sys +import argparse +import logging + +logger = logging.getLogger(__name__) + +DEFAULT_CONSUL_HTTP_API_PORT = 8500 +DEFAULT_TTL = 10 + + +class CustomFormatter(argparse.HelpFormatter): + def __init__(self, prog): + # default max_help_position increased for readability + super(CustomFormatter, self).__init__(prog, max_help_position=50) + + def _format_action_invocation(self, action): + """ + Hack _format_action_invocation to display metavar for only one + of options + """ + if not action.option_strings: + metavar, = self._metavar_formatter(action, action.dest)(1) + return metavar + + parts = [] + + # if the Optional doesn't take a value, format is: + # -s, --long + if action.nargs == 0: + parts.extend(action.option_strings) + + # if the Optional takes a value, format is: + # -s ARGS, --long ARGS + else: + default = action.dest.upper() + args_string = self._format_args(action, default) + + # here is the hack: do not add args to first option part + # if it is both long and short + if len(action.option_strings) > 1: + parts.append(action.option_strings[0] + "") + remaining = action.option_strings[1:] + else: + remaining = action.option_strings + + for option_string in remaining: + parts.append('%s=%s' % (option_string, args_string)) + + return ', '.join(parts) + + def add_usage(self, usage, actions, groups, prefix=None): + """ + Hack add_usage to add fake "-- command [arguments]" to usage + """ + actions.append(argparse._StoreAction( + option_strings=[], + dest="-- command [arguments]" + )) + return super(CustomFormatter, self).add_usage( + usage, actions, groups, prefix + ) + + +def coordinates(coordinates_string): + """ parse coordinates string + :param coordinates_string: string in "hostname" or "hostname:port" format + :return: (hostname, port) two-tuple + """ + if ':' in coordinates_string: + try: + hostname, port = coordinates_string.split(":") + port = int(port) + + if not hostname: + raise ValueError() + + except ValueError: + raise ValueError("Coordinate should be hostname or hostname:port ") + else: + hostname = coordinates_string + port = DEFAULT_CONSUL_HTTP_API_PORT + + return hostname, port + + +def get_parser(): + """ Create ianotor argument parser with a set of reasonable defaults + :return: argument parser + """ + parser = argparse.ArgumentParser( + "ianitor", + description="Doorkeeper for consul discovered services.", + formatter_class=CustomFormatter, + ) + + parser.add_argument( + "--consul-agent", + metavar="hostname[:port]", type=coordinates, default="localhost", + help="set consul agent address" + ) + + parser.add_argument( + "--ttl", + metavar="seconds", type=float, default=DEFAULT_TTL, + help="set TTL of service in consul cluster" + ) + + parser.add_argument( + "--heartbeat", + metavar="seconds", type=float, default=None, + help="set process poll heartbeat (defaults to ttl/10)", + ) + + parser.add_argument( + "--tags", + action="append", metavar="tag", + help="set service tags in consul cluster (can be used multiple times)", + ) + + parser.add_argument( + "--id", + help="set service id - must be node unique (defaults to service name)" + ) + + parser.add_argument( + "--port", + help="set service port", + ) + + parser.add_argument( + "-v", "--verbose", + action="count", + help="enable logging to stdout (use multiple times to increase verbosity)", # noqa + ) + + parser.add_argument( + metavar="service-name", + dest="service_name", + help="service name in consul cluster", + ) + + return parser + + +def parse_args(): + """ + Parse program arguments. + + This function ensures that argv arguments after '--' won't be parsed by + `argparse` and will be returned as separate list. + + :return: (args, command) two-tuple + """ + + parser = get_parser() + + try: + split_point = sys.argv.index('--') + + except ValueError: + if "--help" in sys.argv or "-h" in sys.argv or len(sys.argv) == 1: + parser.print_help() + exit(0) + else: + parser.print_usage() + print(parser.prog, ": error: command missing") + exit(1) + + else: + argv = sys.argv[1:split_point] + invocation = sys.argv[split_point + 1:] + + args = parser.parse_args(argv) + + # set default heartbeat to ttl / 10. if not specified + if not args.heartbeat: + args.heartbeat = args.ttl / 10. + logger.debug( + "heartbeat not specified, setting to %s" % args.heartbeat + ) + + return args, invocation diff --git a/src/ianitor/script.py b/src/ianitor/script.py new file mode 100644 index 0000000..3ef22d0 --- /dev/null +++ b/src/ianitor/script.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 by Clearcode +# and associates (see AUTHORS.md). + +# This file is part of ianitor. + +# ianitor is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# ianitor is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License +# along with ianitor. If not, see . + +from time import sleep +import signal +import logging + +import consul + +from ianitor.service import Service +from ianitor.args_parser import parse_args + + +SIGNALS = [ + member + for member + in dir(signal) + if member.startswith("SIG") and '_' not in member +] + + +logger = logging.getLogger(__name__) + + +def setup_logging(verbosity): + ilogger = logging.getLogger('ianitor') + + if verbosity: + handler = logging.StreamHandler() + if verbosity == 1: + handler.setLevel(logging.ERROR) + if verbosity == 2: + handler.setLevel(logging.WARNING) + if verbosity >= 3: + handler.setLevel(logging.DEBUG) + else: + handler = logging.NullHandler() + + formatter = logging.Formatter( + '[%(levelname)s] %(name)s: %(message)s' + ) + + handler.setFormatter(formatter) + ilogger.setLevel(logging.DEBUG) + ilogger.addHandler(handler) + + +def main(): + args, command = parse_args() + setup_logging(args.verbose) + + session = consul.Consul(*args.consul_agent) + + service = Service( + command, + session=session, + ttl=args.ttl, + service_name=args.service_name, + service_id=args.id, + tags=args.tags, + port=args.port + ) + + service.start() + + def signal_handler(signal_number, *_): + service.process.send_signal(signal_number) + + for signal_name in SIGNALS: + try: + signum = getattr(signal, signal_name) + signal.signal(signum, signal_handler) + except RuntimeError: + # signals that cannot be catched will raise RuntimeException + pass + + while sleep(args.heartbeat) or service.is_up(): + service.keep_alive() + + logger.info("process quit with errorcode %s %s" % ( + service.process.poll(), + "(signal)" if service.process.poll() < 0 else "" + )) + + service.deregister() diff --git a/src/ianitor/service.py b/src/ianitor/service.py new file mode 100644 index 0000000..e6c9ace --- /dev/null +++ b/src/ianitor/service.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 by Clearcode +# and associates (see AUTHORS.md). + +# This file is part of ianitor. + +# ianitor is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# ianitor is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License +# along with ianitor. If not, see . + +from contextlib import contextmanager +import subprocess +import logging +from requests import ConnectionError + +logger = logging.getLogger(__name__) + + +@contextmanager +def ignore_connection_errors(action="unknown"): + try: + yield + except ConnectionError: + logger.error("connection error on <%s> action failed" % action) + + +class Service(object): + def __init__(self, command, session, ttl, service_name, + service_id=None, tags=None, port=None): + self.command = command + self.session = session + self.process = None + + self.ttl = ttl + self.service_name = service_name + self.tags = tags or [] + self.port = port + self.service_id = service_id or service_name + + self.check_id = "service:" + self.service_id + + def start(self): + """ Start service process. + + :return: + """ + logger.debug("starting service: %s" % " ".join(self.command)) + self.process = subprocess.Popen(self.command) + self.register() + + def is_up(self): + """ + Poll service process to check if service is up. + + :return: + """ + logger.debug("polling service") + return bool(self.process) and self.process.poll() is None + + def kill(self): + """ + Kill service process and make sure it is deregistered from consul + cluster. + + :return: + """ + logger.debug("killing service") + if self.process is None: + raise RuntimeError("Process does not exist") + + self.process.kill() + self.deregister() + + def register(self): + """ + Register service in consul cluster. + + :return: None + """ + logger.debug("registering service") + with ignore_connection_errors(): + self.session.agent.service.register( + name=self.service_name, + service_id=self.service_id, + port=self.port, + tags=self.tags, + # format it into XXXs format + ttl="%ss" % self.ttl, + ) + + def deregister(self): + """ + Deregister service from consul cluster. + + :return: None + """ + logger.debug("deregistering service") + + with ignore_connection_errors("deregister"): + self.session.agent.service.deregister(self.service_id) + + def keep_alive(self): + """ + Keep alive service in consul cluster marking TTL check pass + on consul agent. + + If some cases it can happen that service registry disappeared from + consul cluster. This method registers service again if it happens. + + :return: None + """ + with ignore_connection_errors("ttl_pass"): + if not self.session.health.check.ttl_pass(self.check_id): + # register and ttl_pass again if it failed + logger.warning("service keep-alive failed, re-registering") + self.register() + self.session.health.check.ttl_pass(self.check_id) + + def __del__(self): + """ + Cleanup processes on del + """ + if self.process and self.process.poll() is None: + self.kill() diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index 55b033e..0000000 --- a/test-requirements.txt +++ /dev/null @@ -1 +0,0 @@ -pytest \ No newline at end of file diff --git a/tests/test_args_parser.py b/tests/test_args_parser.py new file mode 100644 index 0000000..5584525 --- /dev/null +++ b/tests/test_args_parser.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 by Clearcode +# and associates (see AUTHORS.md). + +# This file is part of ianitor. + +# ianitor is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# ianitor is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License +# along with ianitor. If not, see . + +import pytest +from mock import patch + +from ianitor import args_parser + + +def test_coordinates(): + assert args_parser.coordinates("localhost:1222") == ("localhost", 1222) + + assert args_parser.coordinates("localhost") == ( + "localhost", args_parser.DEFAULT_CONSUL_HTTP_API_PORT + ) + + with pytest.raises(ValueError): + args_parser.coordinates("localhost:12:") + + with pytest.raises(ValueError): + args_parser.coordinates("localhost:") + + with pytest.raises(ValueError): + args_parser.coordinates(":123") + + +@patch('sys.argv', ["ianitor", "tailf", '--', 'tailf', 'something']) +def test_parse_args(): + args, invocation = args_parser.parse_args() + assert invocation == ['tailf', 'something'] + +TEST_TTL = 100 + + +@patch('sys.argv', ["ianitor", "tailf", '--ttl', str(TEST_TTL), '--', 'tailf', 'something']) # noqa +def test_default_heartbeat(): + args, invocation = args_parser.parse_args() + assert args.heartbeat == TEST_TTL / 10. diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..d15ef19 --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2014 by Clearcode +# and associates (see AUTHORS.md). + +# This file is part of ianitor. + +# ianitor is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# ianitor is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public License +# along with ianitor. If not, see . + +from time import sleep +import mock + +from ianitor import service +from consul import Consul + + +def get_tailf_service(session): + return service.Service( + ["tailf", "/dev/null"], + session=session, + service_name="tailf", + # small ttl for faster testing + ttl=1, + ) + + +def test_service_start(): + session = Consul() + tailf = get_tailf_service(session) + + with mock.patch.object(service.Service, "register") as register_method: + tailf.start() + + assert bool(tailf.is_up()) + register_method.assert_any_call() + + +def test_is_up_false_if_not_started(): + session = Consul() + tailf = get_tailf_service(session) + + assert not tailf.is_up() + + +def test_remove_services(): + """ + ~ Yo dawg I herd that you like tests so we put test inside your test so + you can test while you test + + Note: this is not a tests of `ianitor` but a tests helper. Still we must be + sure that this helper works so we test it. + """ + session = Consul() + agent = session.agent + + services = agent.services() + + for srv, description in services.items(): + if description["ID"] != 'consul': + agent.service.deregister(description["ID"]) + + # this is consul 0.4.1 behavior - consul is one of services + services = agent.services() + if 'consul' in services: + services.pop('consul') + + assert not services + + +def test_service_register(): + session = Consul() + agent = session.agent + + tailf = get_tailf_service(session) + tailf.start() + + test_remove_services() + tailf.register() + + assert agent.services() + + +def test_deregister(): + session = Consul() + agent = session.agent + + tailf = get_tailf_service(session) + tailf.start() + + test_remove_services() + tailf.register() + tailf.deregister() + + # this is consul 0.4.1 behavior - consul is one of services + services = agent.services() + if 'consul' in services: + services.pop('consul') + assert not services + + +def test_kill(): + test_remove_services() + + session = Consul() + agent = session.agent + + tailf = get_tailf_service(session) + + # test service registered after start + tailf.start() + assert agent.services() + + # and deregistered after restart + with mock.patch.object(service.Service, "deregister") as r: + tailf.kill() + r.assert_any_call() + + +def _get_service_status(session, service_obj): + _, check_response = session.health.service(service_obj.service_name) + + try: + checks = check_response[0]["Checks"] + except IndexError: + # return none because check does not even exist + return + + # from pprint import pprint; pprint(checks) + service_check = list(filter( + lambda check: check["ServiceName"] == service_obj.service_name, checks + )).pop() + + return service_check["Status"] + + +def test_keep_alive(): + """ + Integration test for keeping service alive in consul cluster + """ + test_remove_services() + session = Consul() + tailf = get_tailf_service(session) + + # assert that there is no checks yet + _, checks = session.health.service(tailf.service_name) + assert not checks + + # test that service health check is unknown or critical after registration + tailf.register() + # small sleep for cluster consensus + sleep(0.1) + assert _get_service_status(session, tailf) in ("unknown", "critical") + + # assert service is healthy back again after keep alive + tailf.keep_alive() + sleep(0.1) + assert _get_service_status(session, tailf) == "passing" + + # assert service health check fails after ttl passed + sleep(tailf.ttl + 0.5) + assert _get_service_status(session, tailf) == "critical" + + +def test_keepalive_reregister(): + """ + Integration test that keep-alive registers service again if it disapears + """ + test_remove_services() + session = Consul() + tailf = get_tailf_service(session) + + # [integration] assert service is healthy + tailf.register() + tailf.keep_alive() + sleep(0.1) + assert _get_service_status(session, tailf) == "passing" + + # [integration] assert that check + test_remove_services() + assert _get_service_status(session, tailf) is None + + # [integration] assert that keepalive makes service registered again + tailf.keep_alive() + sleep(0.1) + assert _get_service_status(session, tailf) == "passing" + + +def test_ignore_connection_failures(): + session = Consul(host="invalid") + + tailf = get_tailf_service(session) + + # assert service starts + tailf.start() + assert tailf.process.poll() is None + + with mock.patch('ianitor.service.logger') as logger: + tailf.register() + assert logger.error.called + + with mock.patch('ianitor.service.logger') as logger: + tailf.keep_alive() + assert logger.error.called + + with mock.patch('ianitor.service.logger') as logger: + tailf.deregister() + assert logger.error.called + + tailf.deregister() + + # assert service can be killed + tailf.kill() + tailf.process.wait() diff --git a/tox.ini b/tox.ini index bdb7089..9cfa472 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = py27,py34,pep8 [testenv] deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt + -r{toxinidir}/requirements-tests.txt setenv = VIRTUAL_ENV = {envdir} commands = py.test {posargs} sitepackages = False