diff options
-rw-r--r-- | oslo_middleware/healthcheck/__init__.py | 161 | ||||
-rw-r--r-- | oslo_middleware/tests/test_healthcheck.py | 30 | ||||
-rw-r--r-- | requirements.txt | 3 |
3 files changed, 174 insertions, 20 deletions
diff --git a/oslo_middleware/healthcheck/__init__.py b/oslo_middleware/healthcheck/__init__.py index bdbbd24..9aa2c2e 100644 --- a/oslo_middleware/healthcheck/__init__.py +++ b/oslo_middleware/healthcheck/__init__.py @@ -13,6 +13,19 @@ # License for the specific language governing permissions and limitations # under the License. +import json +import socket + +try: + from collections import OrderedDict # noqa +except ImportError: + # TODO(harlowja): remove this when py2.6 support is dropped... + from ordereddict import OrderedDict # noqa + +import jinja2 +from oslo_utils import reflection +from oslo_utils import strutils +import six import stevedore import webob.dec import webob.exc @@ -21,6 +34,12 @@ import webob.response from oslo_middleware import base +def _expand_template(contents, params): + tpl = jinja2.Template(source=contents, + undefined=jinja2.StrictUndefined) + return tpl.render(**params) + + class Healthcheck(base.ConfigurableMiddleware): """Healthcheck middleware used for monitoring. @@ -28,6 +47,13 @@ class Healthcheck(base.ConfigurableMiddleware): Or 503 with the reason as the body if one of the backend report an application issue. + This is useful for the following reasons: + + 1. Load balancers can 'ping' this url to determine service availability. + 2. Provides an endpoint that is similar to 'mod_status' in apache which + can provide details (or no details, depending on if configured) about + the activity of the server. + Example of paste configuration: .. code-block:: ini @@ -71,36 +97,139 @@ class Healthcheck(base.ConfigurableMiddleware): """ NAMESPACE = "oslo.middleware.healthcheck" + HEALTHY_TO_STATUS_CODES = { + True: webob.exc.HTTPOk.code, + False: webob.exc.HTTPServiceUnavailable.code, + } + PLAIN_RESPONSE_TEMPLATE = """ +{% for reason in reasons %} +{% if reason %}{{reason}}{% endif -%} +{% endfor %} +""" + + HTML_RESPONSE_TEMPLATE = """ +<HTML> +<HEAD><TITLE>Healthcheck Status</TITLE></HEAD> +<BODY> +{% if detailed -%} +{% if hostname -%} +<H1>Server status for {{hostname|e}}</H1> +{%- endif %} +{%- endif %} +<H2>Result of {{results|length}} checks:</H2> +<TABLE bgcolor="#ffffff" border="1"> +<TBODY> +{% for result in results -%} +{% if result.reason -%} +<TR> +{% if detailed -%} + <TD>{{result.class|e}}</TD> +{%- endif %} + <TD>{{result.reason|e}}</TD> +</TR> +{%- endif %} +{%- endfor %} +</TBODY> +</TABLE> +</BODY> +</HTML> +""" def __init__(self, application, conf): super(Healthcheck, self).__init__(application) self._path = conf.get('path', '/healthcheck') + self._show_details = strutils.bool_from_string(conf.get('detailed')) self._backend_names = [] backends = conf.get('backends') if backends: self._backend_names = backends.split(',') - self._backends = stevedore.NamedExtensionManager( self.NAMESPACE, self._backend_names, name_order=True, invoke_on_load=True, invoke_args=(conf,)) + self._accept_to_functor = OrderedDict([ + # Order here matters... + ('text/plain', self._make_text_response), + ('text/html', self._make_html_response), + ('application/json', self._make_json_response), + ]) + self._accept_order = tuple(six.iterkeys(self._accept_to_functor)) + # When no accept type matches instead of returning 406 we will + # always return text/plain (because sending an error from this + # middleware actually can cause issues). + self._default_accept = 'text/plain' + + @staticmethod + def _pretty_json_dumps(contents): + return json.dumps(contents, indent=4, sort_keys=True) + + @staticmethod + def _are_results_healthy(results): + for result in results: + if not result.available: + return False + return True + + def _make_text_response(self, results, healthy): + params = { + 'reasons': [result.reason for result in results], + 'detailed': self._show_details, + } + body = _expand_template(self.PLAIN_RESPONSE_TEMPLATE, params) + return (body.strip(), 'text/plain') + + def _make_json_response(self, results, healthy): + if self._show_details: + body = { + 'detailed': True, + } + reasons = [] + for result in results: + reasons.append({ + 'reason': result.reason, + 'class': reflection.get_class_name(result, + fully_qualified=False), + }) + body['reasons'] = reasons + else: + body = { + 'reasons': [result.reason for result in results], + 'detailed': False, + } + return (self._pretty_json_dumps(body), 'application/json') + + def _make_html_response(self, results, healthy): + try: + hostname = socket.gethostname() + except socket.error: + hostname = None + translated_results = [] + for result in results: + translated_results.append({ + 'reason': result.reason, + 'class': reflection.get_class_name(result, + fully_qualified=False), + }) + params = { + 'healthy': healthy, + 'hostname': hostname, + 'results': translated_results, + 'detailed': self._show_details, + } + body = _expand_template(self.HTML_RESPONSE_TEMPLATE, params) + return (body.strip(), 'text/html') @webob.dec.wsgify def process_request(self, req): if req.path != self._path: return None - - healthy = True - reasons = [] - for ext in self._backends: - result = ext.obj.healthcheck() - healthy &= result.available - if result.reason: - reasons.append(result.reason) - - return webob.response.Response( - status=(webob.exc.HTTPOk.code if healthy - else webob.exc.HTTPServiceUnavailable.code), - body='\n'.join(reasons), - content_type="text/plain", - ) + results = [ext.obj.healthcheck() for ext in self._backends] + healthy = self._are_results_healthy(results) + accept_type = req.accept.best_match(self._accept_order) + if not accept_type: + accept_type = self._default_accept + functor = self._accept_to_functor[accept_type] + body, content_type = functor(results, healthy) + status = self.HEALTHY_TO_STATUS_CODES[healthy] + return webob.response.Response(status=status, body=body, + content_type=content_type) diff --git a/oslo_middleware/tests/test_healthcheck.py b/oslo_middleware/tests/test_healthcheck.py index 13aa5fd..b516d0c 100644 --- a/oslo_middleware/tests/test_healthcheck.py +++ b/oslo_middleware/tests/test_healthcheck.py @@ -28,12 +28,17 @@ class HealthcheckTests(test_base.BaseTestCase): def application(req): return 'Hello, World!!!' - def _do_test(self, conf={}, path='/healthcheck', - expected_code=webob.exc.HTTPOk.code, - expected_body=b''): + def _do_test_request(self, conf={}, path='/healthcheck', + accept='text/plain'): self.app = healthcheck.Healthcheck(self.application, conf) - req = webob.Request.blank(path) + req = webob.Request.blank(path, accept=accept) res = req.get_response(self.app) + return res + + def _do_test(self, conf={}, path='/healthcheck', + expected_code=webob.exc.HTTPOk.code, + expected_body=b'', accept='text/plain'): + res = self._do_test_request(conf=conf, path=path, accept=accept) self.assertEqual(expected_code, res.status_int) self.assertEqual(expected_body, res.body) @@ -69,6 +74,14 @@ class HealthcheckTests(test_base.BaseTestCase): self._do_test(conf, expected_body=b'OK') self.assertIn('disable_by_file', self.app._backends.names()) + def test_disablefile_enabled_html_detailed(self): + conf = {'backends': 'disable_by_file', + 'disable_by_file_path': '/foobar', 'detailed': True} + res = self._do_test_request(conf, accept="text/html") + self.assertIn(b'Result of 1 checks:', res.body) + self.assertIn(b'<TD>OK</TD>', res.body) + self.assertEqual(webob.exc.HTTPOk.code, res.status_int) + def test_disablefile_disabled(self): filename = self.create_tempfiles([('test', 'foobar')])[0] conf = {'backends': 'disable_by_file', @@ -78,6 +91,15 @@ class HealthcheckTests(test_base.BaseTestCase): expected_body=b'DISABLED BY FILE') self.assertIn('disable_by_file', self.app._backends.names()) + def test_disablefile_disabled_html_detailed(self): + filename = self.create_tempfiles([('test', 'foobar')])[0] + conf = {'backends': 'disable_by_file', + 'disable_by_file_path': filename, 'detailed': True} + res = self._do_test_request(conf, accept="text/html") + self.assertIn(b'<TD>DISABLED BY FILE</TD>', res.body) + self.assertEqual(webob.exc.HTTPServiceUnavailable.code, + res.status_int) + def test_two_backends(self): filename = self.create_tempfiles([('test', 'foobar')])[0] conf = {'backends': 'disable_by_file,disable_by_file', diff --git a/requirements.txt b/requirements.txt index ecab230..028e0fd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,9 +4,12 @@ pbr<2.0,>=1.6 Babel>=1.3 +Jinja2>=2.6 # BSD License (3 clause) +ordereddict oslo.config>=2.3.0 # Apache-2.0 oslo.context>=0.2.0 # Apache-2.0 oslo.i18n>=1.5.0 # Apache-2.0 +oslo.utils>=2.0.0 # Apache-2.0 six>=1.9.0 stevedore>=1.5.0 # Apache-2.0 WebOb>=1.2.3 |