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

Reply via email to