Giuseppe Lavagetto has uploaded a new change for review. https://gerrit.wikimedia.org/r/296420
Change subject: First commit of the reorganized codebase ...................................................................... First commit of the reorganized codebase Change-Id: I5744bfecab987d749207bf0d59854f434a3169d3 --- A .gitignore A AUTHORS A LICENSE.txt A README.rst A checker/__init__.py A checker/service.py A checker/tests/__init__.py A checker/tests/fixtures/test.json A checker/tests/fixtures/test_error_spec.json A checker/tests/unit/__init__.py A checker/tests/unit/test_checker.py A setup.py 12 files changed, 1,038 insertions(+), 0 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/operations/software/service-checker refs/changes/20/296420/1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c535955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Partially taken from +# https://github.com/github/gitignore +# Originally released under CC-0 (public domain) +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +sdist/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +coverage.xml +*,cover + +# Virtual environments +.venv* diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000..7bfcfed --- /dev/null +++ b/AUTHORS @@ -0,0 +1,3 @@ +Giuseppe Lavagetto <[email protected]> +Marko Obrovac <[email protected]> +Ori Livneh <[email protected]> diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..c2423ba --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,14 @@ +Copyright (c) 2015-16 Wikimedia Foundation Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program 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 General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see <http://www.gnu.org/licenses/>. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..b624e17 --- /dev/null +++ b/README.rst @@ -0,0 +1,112 @@ +service-checker documentation +============================= + +A generic swagger-based webservice checker. + +It can perform http requests based on the ``x-amples`` sections of the +``paths`` part of the spec, and match responses to what the expected +response is. It is able to check both headers and the body of the response. + +Installation +------------ + +This software is known to work correctly with python 2.7 and 3.5 + +From Source +~~~~~~~~~~~ + +.. code:: bash + + $ python setup.py install + +Usage +----- + +Once installed, you will have the ``service-checker`` binary in your path. + +Suppose you have a webservice running on localhost port 8080, which +reponds for HTTP host ``awesomeservice.local`` and it +exposes its swagger spec on the /swagger url. To check it is working +as designed according to its spec you can just do + +.. code:: bash + + $ service-checker 127.0.0.1 awesomeservice.local:8080 -s /swagger + All endpoints are healthy + + +Spec format support +------------------- + +``service-checker`` checks each of the paths in your swagger/OpenAPI +specification for an ``x-amples`` section and uses it to do live requests +to the API and checks that the response corresponds to the base. The +``x-amples`` section is an extension to the swagger spec that has been +introduced by `swagger-test <https://github.com/earldouglas/swagger-test>`_ +and that consists of a ``request`` and a ``response`` sections. + +Url templating according to RFC 6570 is partially supported via the +``params`` section of the ``x-amples`` section. + +There could be some specific examples you might want to run unit tests +on but you don't want to monitor on a live service (typically, any +non-idempotent request is a good candidate for this). In that case, +just add ``x-monitor: false`` at the root of your example. + +Basic example +~~~~~~~~~~~~~ +.. code:: javascript + + "/pets/{id}": { + "get": { + "x-monitor": true, + "x-amples": { + "request": { + "params": {"id": 10}, + "headers": { "Accept": "application/json", }, + "query": {"refresh": "y"}, + }, + "response": { + "status": 200, + "headers": {"X-Pet-Iscute": "yes"}, + "body": { "species": "/canis .*/"} + }, + ... + +Url template interpolation +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We support basic Url template interpolation, a subset of the +specification in RFC 6570 is supported at the moment. We support +simple, optional and multiple parameter substitutions. + + + +Body data check +~~~~~~~~~~~~~~~ + +Body data is assumed to be either json or plain text; actual content +(of either fields in the json structure or the text) can be either +matched exactly or with a regexp. So for example: + +.. code:: javascript + + "body": "abcd" + +will verify that "abcd" is the exact response body, while + +.. code:: javascript + + "body": "/^abcd/" + +will just check that the body begins with "abcd". + +Limitations +----------- + +- Only supports GET and POST at the moment +- Only plain-text and json responses are supported +- Url templating support is pretty limited at the moment +- All endpoints are checked sequentially, which could easily lead to + timeouts in nagios-like systems +- No logging diff --git a/checker/__init__.py b/checker/__init__.py new file mode 100755 index 0000000..6dcee3d --- /dev/null +++ b/checker/__init__.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python + +import json +import urllib3 + + +class CheckError(Exception): + """ + Generic Exception used as a catchall + """ + pass + + +def fetch_url(client, url, **kw): + """ + Standalone function to fetch an url. + + Args: + client (urllib3.Poolmanager): + The HTTP client we want to use + url (str): The URL to fetch + + kw: any keyword arguments we want to pass to + urllib3.request.RequestMethods.request + """ + if 'method' in kw: + method = kw['method'].upper() + del kw['method'] + else: + method = 'GET' + try: + if method == 'GET': + return client.request( + method, + url, + **kw + ) + elif method == 'POST': + try: + headers = kw.get('headers', {}) + content_type = headers.get('Content-Type', '') + except: + content_type = '' + + # Handle json-encoded requests + if content_type.lower() == 'application/json': + kw['body'] = json.dumps(kw['fields']) + del kw['fields'] + return client.urlopen( + method, + url, + **kw + ) + + return client.request_encode_body( + method, + url, + encode_multipart=False, + **kw + ) + except urllib3.exceptions.SSLError: + raise CheckError("Invalid certificate") + except (urllib3.exceptions.ConnectTimeoutError, + urllib3.exceptions.TimeoutError, + # urllib3.exceptions.ConnectionError, # commented out until we can + # remove trusty (aka urllib3 1.7.1) support + urllib3.exceptions.ReadTimeoutError): + raise CheckError("Timeout on connection while " + "downloading {}".format(url)) + except Exception as e: + raise CheckError("Generic connection error: {}".format(e)) + + +class CheckerBase(object): + """ + Base class to implement higher-level checkers + """ + nagios_codes = ['OK', 'WARNING', 'CRITICAL'] + + def _spawn_downloader(self): + """ + Spawns an urllib3.Poolmanager with the correct configuration. + """ + kw = { + # 'retries': 1, uncomment this once we've got rid of trusty + 'timeout': self._timeout + } + kw['ca_certs'] = "/etc/ssl/certs/ca-certificates.crt" + kw['cert_reqs'] = 'CERT_REQUIRED' + return urllib3.PoolManager(**kw) diff --git a/checker/service.py b/checker/service.py new file mode 100755 index 0000000..ba7587d --- /dev/null +++ b/checker/service.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python +from checker import CheckerBase, fetch_url, CheckError + +import argparse +from collections import namedtuple +import json +import re +import sys + +# python 2 vs python 3 imports +try: + import urlparse + from urllib import quote_plus +except ImportError: + import urllib.parse as urlparse + from urllib.parse import quote_plus + +try: + reload(sys) + sys.setdefaultencoding('utf-8') +except: + pass + + +class CheckService(CheckerBase): + """ + Shell class for checking services + """ + default_response = {'status': 200} + _supported_methods = ['get', 'post'] + + def __init__(self, host_ip, base_url, timeout=5, spec_url='/?spec'): + """ + Initialize the checker + + Args: + host_ip (str): The host ipv4 address (also works with a hostname) + + base_url (str): The base url the service expects to respond from + + timeout (int): Number of seconds to wait for each request + + spec_url (str): the string to append to the base url, defaults to + /?spec + """ + self.host_ip = host_ip + self.base_url = urlparse.urlsplit(base_url) + http_host_port = self.base_url.netloc.split(':') + if len(http_host_port) < 2: + if self.base_url.scheme == 'https': + http_host_port.append('443') + else: + http_host_port.append('80') + self.http_host, self.port = http_host_port + self._url_prefix = self.base_url.path + self.endpoints = {} + self._timeout = timeout + self.spec_url = spec_url + + @property + def _url(self): + """ + Returns an url pointing to the IP of the host to check. + """ + return "{}://{}:{}{}".format(self.base_url.scheme, + self.host_ip, + self.port, + self._url_prefix) + + def get_endpoints(self): + """ + Gets the full spec from base_url + '/?spec' and parses it. + Returns a generator iterating over the available endpoints + """ + http = self._spawn_downloader() + # TODO: cache all this. + response = fetch_url( + http, + self._url + self.spec_url, + timeout=self._timeout, + headers={'Host': self.http_host} + ) + + resp = response.data.decode('utf-8') + + try: + r = json.loads(resp) + except ValueError: + raise ValueError("No valid spec found") + + TemplateUrl.default = r.get('x-default-params', {}) + for endpoint, data in r['paths'].items(): + if not endpoint: + continue + for key in self._supported_methods: + try: + d = data[key] + # If x-monitor is False, skip this + if not d.get('x-monitor', True): + continue + if key == 'get': + default_example = [{ + 'request': {}, + 'response': self.default_response + }] + else: + # Only GETs have default examples + default_example = [] + examples = d.get('x-amples', default_example) + for x in examples: + x['http_method'] = key + yield endpoint, x + except KeyError: + # No data for this method + pass + + def run(self): + """ + Runs the checks on all the endpoints we find + """ + res = [] + status = 'OK' + idx = self.nagios_codes.index(status) + try: + for endpoint, data in self.get_endpoints(): + ep_status, msg = self._check_endpoint(endpoint, data) + if ep_status != 'OK': + res.append("{} ({}) is {}: {}".format( + endpoint, data.get('title', 'no title'), + ep_status, msg)) + ep_idx = self.nagios_codes.index(ep_status) + if ep_idx >= idx: + status = ep_status + idx = ep_idx + message = u"; ".join(res) + if status == 'OK': + message = "All endpoints are healthy" + except Exception as e: + message = "Generic error: {}".format(e) + status = 'CRITICAL' + print(message) + sys.exit(self.nagios_codes.index(status)) + + def _check_endpoint(self, endpoint, data): + """ + Actually performs the checks on each single endpoint + """ + req = data.get('request', {}) + req['http_host'] = self.http_host + er = EndpointRequest( + data.get('title', + "test for {}".format(endpoint)), + self._url, + data['http_method'], + endpoint, + req, + data.get('response') + ) + er.run(self._spawn_downloader()) + return (er.status, er.msg) + + +class EndpointRequest(object): + + """ + Manages a request to a specific endpoint + """ + + def __init__(self, title, base_url, http_method, + endpoint, request, response): + """ + Initialize the endpoint request + + Args: + title (str): a descriptive name + + base_url (str): the base url + + http_method(str): the HTTP method + + endpoint (str): an url template for the endpoint, per RFC 6570 + + request (dict): All data for building the request + + response (dict): What we should test in the response + """ + self.status = 'OK' + self.msg = 'Test "{}" healthy'.format(title) + self.title = title + self.method = http_method + self._request(request) + self._response(response) + self.tpl_url = TemplateUrl(base_url + endpoint) + + def run(self, client): + """ + Perform the request, and test the result + + Args: + client (urllib3.Poolmanager): the HTTP client we want to use + """ + try: + url = self.tpl_url.realize(self.url_parameters) + r = fetch_url( + client, + url, + headers=self.request_headers, + fields=self.query_parameters, + redirect=False, + method=self.method + ) + except CheckError as e: + self.status = 'CRITICAL' + self.msg = "Could not fetch url {}: {}".format( + url, e) + return + + # Response status + if r.status != self.resp_status: + self.status = "CRITICAL" + self.msg = ("Test {} returned " + "the unexpected status {} (expecting: {})".format( + self.title, r.status, self.resp_status)) + return + + # Headers + for k, v in self.headers.items(): + h = r.getheader(k) + if h is None or not v(h): + self.status = "CRITICAL" + self.msg = ("Test {} had an unexpected value " + "for header {}: {}".format(self.title, k, h)) + return + # Body + if self.body is not None: + body = r.data.decode('utf-8') + if isinstance(self.body, dict) or isinstance(self.body, list): + data = json.loads(body) + try: + self._check_json_chunk(data, self.body) + except CheckError: + return + except Exception as e: + self.status = "CRITICAL" + self.msg = ("Test {} responds with malformed " + "body: {}".format(self.title, e)) + else: + check = self._verify(self.body) + if not check(body): + self.status = "WARNING" + self.msg = ("Test {} responds with unexpected " + "body: {} != {}".format( + self.title, + body, + self.body)) + return + + def _request(self, data): + """ + Gather data from the request object + """ + self.request_headers = {'Host': data['http_host']} + if 'headers' in data: + self.request_headers.update(data['headers']) + self.url_parameters = data.get('params', {}) + qkey = 'query' if self.method == 'get' else 'body' + self.query_parameters = data.get(qkey, {}) + + def _response(self, data): + """ + Organize the expected response data + """ + self.resp_status = data['status'] + self.body = data.get('body', None) + self.headers = {} + try: + for k, v in data['headers'].items(): + self.headers[k] = self._verify(v) + except KeyError: + pass + + def _verify(self, orig): + """ + Return a lambda function to verify the response data + + Args: + arg (str): The argument to check against. If enclosed + in slashes, it's assumed to be a regex + """ + arg = str(orig) + t = 'eq' + if arg.startswith('/') and arg.endswith('/'): + arg = arg.strip('/') + t = 're' + if t == 'eq': + return lambda x: (x == arg) or x.startswith(arg) + elif t == 're': + return lambda x: re.search(arg, x) + + def _check_json_chunk(self, data, model, prefix=''): + """ + Recursively check a json chunk of the response. + + Args: + data (mixed): the data to check + + model (mixed): the model to check the data against + + prefix (str): the depth we're checking at + """ + if isinstance(model, dict): + for k, v in model.items(): + p = prefix + '/' + k + d = data.get(k, None) + self._check_json_chunk(d, v, prefix=p) + elif isinstance(model, list): + for i in range(len(model)): + p = prefix + '[%d]' % i + self._check_json_chunk(data[i], model[i], prefix=p) + else: + check = self._verify(model) + if not check(str(data)): + self.status = "WARNING" + self.msg = ("Test {} responds with " + "unexpected body: {} => {}".format( + self.title, prefix, data)) + raise CheckError("{} => {}".format(prefix, data)) + return True + + +class TemplateUrl(object): + + """ + A very partial implementation of RFC 6570, limited to our use + """ + transforms = { + 'simple': lambda x: x, + 'optional': lambda x: '/' + x, + 'multiple': lambda x: '/'.join(x) + } + default = {} + base = re.compile('(\{.+?\})', re.U) + + def __init__(self, url_string): + """ + Initialize the template + + Args: + url_string (str): The url template + """ + Token = namedtuple('Token', ['key', 'types', 'original']) + self._url_string = url_string + self.tokens = [] + for param in self.base.findall(self._url_string): + types = ['simple'] + key = param.strip('{}') + if key.startswith('/'): + types.append('optional') + key = key.lstrip('/') + if key.startswith('+'): + types.append('multiple') + key = key.lstrip('+') + self.tokens.append(Token(original=param, key=key, types=types)) + + def realize(self, params): + """ + Returns an url based on the template. + + Args: + params (dict): the list of params to substitute in the template + """ + realized = self._url_string + p = {} + p.update(self.default) + p.update(params) + for token in self.tokens: + if token.key in p: + v = p[token.key] + if isinstance(v, list): + v = map(quote_plus, map(str, v)) + else: + v = quote_plus(str(v)) + for transform in reversed(token.types): + v = self.transforms[transform](v) + else: + v = u"" + realized = realized.replace( + token.original, v, 1) + + return realized + + +def main(): + parser = argparse.ArgumentParser( + description='Checks the availability and response of one WMF service') + parser.add_argument('host_ip', help="The IP address of the host to check") + parser.add_argument('service_url', + help="The base url for the service, including port") + parser.add_argument('-t', dest="timeout", default=5, type=int, + help="Timeout (in seconds) for each " + "request. Default: 5") + parser.add_argument('-s', dest="spec_url", default="/?spec", + help="Specific spec url relative to the base one." + " Defaults to /?spec.") + args = parser.parse_args() + checker = CheckService(args.host_ip, args.service_url, + args.timeout, args.spec_url) + checker.run() + + +if __name__ == '__main__': + main() diff --git a/checker/tests/__init__.py b/checker/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/checker/tests/__init__.py diff --git a/checker/tests/fixtures/test.json b/checker/tests/fixtures/test.json new file mode 100644 index 0000000..edb8394 --- /dev/null +++ b/checker/tests/fixtures/test.json @@ -0,0 +1,22 @@ +{ + "basepath": "/api", + "x-default-params": {"who": "joe"}, + "paths": { + "/simple": { "get": {} }, + "/not_monitored": {"get": {"x-monitor": false}}, + "/{who}/{verb}": {"get": { + "x-amples": [{ + "request": { + "params": { + "verb": "rulez" + } + }, + "response": { + "body": "\"For sure!\"", + "status": 200 + }, + "title": "General affirmation" + }] + }} + } +} diff --git a/checker/tests/fixtures/test_error_spec.json b/checker/tests/fixtures/test_error_spec.json new file mode 100644 index 0000000..8d11544 --- /dev/null +++ b/checker/tests/fixtures/test_error_spec.json @@ -0,0 +1,22 @@ +{ + "basepath": "/api", + "x-default-params": {"who": "joe"}, + "paths": { + "/simple": {}, + "/not_monitored": {"get": {"x-monitor": false}}, + "/{who}/{verb}": {"get": { + "x-amples": [{ + "request": { + "params": { + "verb": "rulez" + } + }, + "response": { + "body": "\"For sure!\"", + "status": 200 + }, + "title": "General affirmation" + }] + }} + } +} diff --git a/checker/tests/unit/__init__.py b/checker/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/checker/tests/unit/__init__.py diff --git a/checker/tests/unit/test_checker.py b/checker/tests/unit/test_checker.py new file mode 100644 index 0000000..c53cb9b --- /dev/null +++ b/checker/tests/unit/test_checker.py @@ -0,0 +1,286 @@ +import checker +from checker import service +import unittest +import mock +import json +import os +import urllib3 +import copy + + +class TestTemplateUrl(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.t = service.TemplateUrl( + 'https://example.org/test/{where}{/what}{/+why}') + + def test_init(self): + """ + Test initialization of the template url + """ + origs = [el.original for el in self.t.tokens] + self.assertEquals(origs, ['{where}', '{/what}', '{/+why}']) + self.assertEquals(self.t.tokens[0].types, ['simple']) + self.assertEquals(self.t.tokens[1].types, ['simple', 'optional']) + + def test_realize(self): + """ + Test Realization + """ + params = {'where': 'Rome', 'what': 'Eat', 'why': ['I', 'am', 'hungry']} + self.assertEquals(self.t.realize(params), + 'https://example.org/test/Rome/Eat/I/am/hungry') + + params['what'] = 'Eat:pasta' + self.assertEquals( + self.t.realize(params), + 'https://example.org/test/Rome/Eat%3Apasta/I/am/hungry') + params1 = {'where': 'Rome'} + self.assertEquals(self.t.realize(params1), + 'https://example.org/test/Rome') + + del params['why'] + self.assertEquals(self.t.realize(params), + 'https://example.org/test/Rome/Eat%3Apasta') + + +class TestEndpointRequest(unittest.TestCase): + + def mock_response(self, d): + r = mock.create_autospec(urllib3.response.HTTPResponse) + r.status = d['status'] + r.data = json.dumps(d['body']).encode('utf-8') + + def getheader(key): + return d['headers'].get(key, None) + r.getheader = mock.MagicMock(side_effect=getheader) + service.fetch_url = mock.MagicMock(return_value=r) + + def setUp(self): + self.resp = { + 'body': { + 'has_items': True, + 'items': [ + { + 'comment': '/.*/', + 'rev': '/\\d+/', + 'tid': '/^[0-9a-fA-F]{4}-[0-9a-fA-F]{4}$/', + 'title': 'Foobar' + } + ] + }, + 'headers': { + 'content-type': 'application/json', + 'etag': '/.+/' + }, + 'status': 200 + } + + self.ep = service.EndpointRequest( + "a Test endpoint", + "http://127.0.0.1/baseurl", + "get", + "/an/endpoint/{revision}", + {"http_host": 'example.org', 'params': {'revision': 1}}, + copy.deepcopy(self.resp) + ) + self.resp["body"]["items"] = [ + {"title": "Foobar", + "comment": "blabla", + "rev": 1, + "tid": "00AA-abcd"} + ] + self.resp["body"]["has_items"] = True + self.resp["headers"]["etag"] = "Imtrackingyou" + + def test_init(self): + """ + Test initialization + """ + self.assertEquals(self.ep.title, "a Test endpoint") + self.assertEquals(self.ep.tpl_url._url_string, + "http://127.0.0.1/baseurl/an/endpoint/{revision}") + self.assertEquals(self.ep.request_headers, {'Host': 'example.org'}) + self.assertEquals(self.ep.url_parameters, {'revision': 1}) + self.assertEquals(self.ep.resp_status, 200) + self.assertTrue(self.ep.headers["content-type"]('application/json')) + + def test_run_ok(self): + """ + Test a successful run + """ + self.mock_response(self.resp) + self.ep.run(urllib3.PoolManager()) + self.assertEquals(self.ep.status, 'OK') + + def test_run_bad_status(self): + """ + Test an unexpected HTTP status + """ + self.resp['status'] = 301 + self.mock_response(self.resp) + self.ep.run(urllib3.PoolManager()) + self.assertEquals(self.ep.status, 'CRITICAL') + self.assertEquals("Test a Test endpoint returned " + "the unexpected status 301 (expecting: 200)", + self.ep.msg) + + def test_run_bad_header(self): + """ + Test an unexpected HTTP Header + """ + self.resp['headers']['etag'] = "" + self.mock_response(self.resp) + self.ep.run(urllib3.PoolManager()) + self.assertEquals(self.ep.status, 'CRITICAL') + self.assertEquals("Test a Test endpoint had an unexpected value " + "for header etag: ", self.ep.msg) + + def test_run_missing_header(self): + """ + Test a missing HTTP header + """ + del self.resp['headers']['etag'] + self.mock_response(self.resp) + self.ep.run(urllib3.PoolManager()) + self.assertEquals(self.ep.status, 'CRITICAL') + self.assertEquals("Test a Test endpoint had an unexpected value " + "for header etag: None", self.ep.msg) + + def test_run_bad_body(self): + """ + Test unexpected value in body + """ + self.resp['body']['items'][0]['tid'] = 12 + self.mock_response(self.resp) + self.ep.run(urllib3.PoolManager()) + self.assertEquals(self.ep.status, 'WARNING') + self.assertEquals("Test a Test endpoint responds with unexpected " + "body: /items[0]/tid => 12", self.ep.msg) + + def test_run_missing_body(self): + """ + Test missing value in body + """ + del self.resp['body']['items'][0]['tid'] + self.mock_response(self.resp) + self.ep.run(urllib3.PoolManager()) + self.assertEquals(self.ep.status, 'WARNING') + self.assertEquals("Test a Test endpoint responds with unexpected " + "body: /items[0]/tid => None", self.ep.msg) + + def test_default_response(self): + """ + Test a simple endpoint + """ + ep = service.EndpointRequest( + "simple test", + "http://127.0.0.1:7321", + "get", + "/test", + {'http_host': "example.org"}, + {'status': 200}, + ) + self.mock_response( + {"status": 200, + "body": "Hello, World!", + "headers": {"content-length": 3240}}) + ep.run(urllib3.PoolManager()) + self.assertEquals(self.ep.status, 'OK') + + +class TestCheckService(unittest.TestCase): + routes = {} + + def add_mock_response(self, route, d): + r = mock.create_autospec(urllib3.response.HTTPResponse) + r.status = d['status'] + r.data = json.dumps(d['body']).encode('utf-8') + + def getheader(key): + return d['headers'].get(key, None) + r.getheader = mock.MagicMock(side_effect=getheader) + self.routes[route] = r + + def router(self, client, route, **kw): + r = route.replace(self.cs._url, '') + return self.routes.get(r, None) + + def mock_routes(self): + service.fetch_url = mock.MagicMock(side_effect=self.router) + + def setUp(self): + self.cs = service.CheckService('127.0.0.1', 'http://example.org/api') + fn = os.path.join(os.path.dirname(__file__), '../fixtures/test.json') + with open(fn, 'rb') as f: + data = f.read().decode('utf-8') + self.add_mock_response( + '/?spec', {'status': 200, 'body': json.loads(data)}) + + def test_initialize(self): + """ + Test initialization + """ + self.assertEquals(self.cs.host_ip, '127.0.0.1') + self.assertEquals(self.cs._timeout, 5) + self.assertEquals(self.cs.port, '80') + self.assertEquals(self.cs.http_host, 'example.org') + self.assertEquals(self.cs._url, 'http://127.0.0.1:80/api') + + def test_get_endpoints(self): + """ + Test list of endpoints is returned + """ + self.mock_routes() + l = [el for el, data in self.cs.get_endpoints()] + l.sort() + self.assertListEqual(l, [u'/simple', u'/{who}/{verb}']) + + def test_get_ep_invalid_spec(self): + """ + Test what endpoints are returned with incorrect specs + """ + fn = os.path.join(os.path.dirname(__file__), '../fixtures/test_error_spec.json') + with open(fn, 'rb') as f: + data = f.read().decode('utf-8') + self.add_mock_response( + '/?spec', {'status': 200, 'body': json.loads(data)}) + self.mock_routes() + l = [el for el, _ in self.cs.get_endpoints()] + self.assertEquals(l, [u'/{who}/{verb}']) + + def test_run(self): + """ + Test a successful run + """ + self.add_mock_response('/simple', {'status': 200, 'body': 'hi'}) + self.add_mock_response( + '/joe/rulez', {'status': 200, 'body': 'For sure!'}) + self.mock_routes() + with self.assertRaises(SystemExit) as e: + self.cs.run() + self.assertEquals(e.exception.code, 0) + + def test_endpoint_critical(self): + """ + Test a critical exit + """ + self.add_mock_response('/simple', {'status': 200, 'body': 'hi'}) + self.add_mock_response('/joe/rulez', {'status': 301, 'body': ''}) + self.mock_routes() + with self.assertRaises(SystemExit) as e: + self.cs.run() + self.assertEquals(e.exception.code, 2) + + def test_endpoint_warning(self): + """ + Test a warning exit + """ + self.add_mock_response('/simple', {'status': 200, 'body': 'hi'}) + self.add_mock_response( + '/joe/rulez', {'status': 200, 'body': 'For sure?'}) + self.mock_routes() + with self.assertRaises(SystemExit) as e: + self.cs.run() + self.assertEquals(e.exception.code, 1) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..984b64c --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +from setuptools import setup, find_packages +import os + +here = os.path.abspath(os.path.dirname(__file__)) +README = open(os.path.join(here, 'README.rst')).read() + +version = '0.0.1' + +install_requires = [ + 'urllib3>=1.7', +] + +test_requires = [ + 'mock', + 'nose', +] + +setup( + name='service-checker', + version=version, + description="An automatic monitoring tool for swagger-based webservices", + long_description=README, + author='Giuseppe Lavagetto', + author_email='[email protected]', + url='http://github.com/wikimedia/service_checker', + license='GPL', + packages=find_packages(), + zip_safe=False, + install_requires=install_requires, + tests_require=test_requires, + test_suite='nose.collector', + entry_points={ + 'console_scripts': [ + 'service-checker = checker.service:main' + ] + }, +) -- To view, visit https://gerrit.wikimedia.org/r/296420 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I5744bfecab987d749207bf0d59854f434a3169d3 Gerrit-PatchSet: 1 Gerrit-Project: operations/software/service-checker Gerrit-Branch: master Gerrit-Owner: Giuseppe Lavagetto <[email protected]> _______________________________________________ MediaWiki-commits mailing list [email protected] https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits
