From dfc1aa21b88351faa38152fc89720f3c9f5710f8 Mon Sep 17 00:00:00 2001 From: Graham Beckley Date: Sat, 5 Aug 2023 10:20:11 -0400 Subject: [PATCH 1/8] Add fns to run checks and build result payloads --- src/dockerflow/checks/__init__.py | 150 ++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/src/dockerflow/checks/__init__.py b/src/dockerflow/checks/__init__.py index 8df8c78..1b4222e 100644 --- a/src/dockerflow/checks/__init__.py +++ b/src/dockerflow/checks/__init__.py @@ -1,6 +1,12 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, you can obtain one at http://mozilla.org/MPL/2.0/. +import asyncio +import functools +import inspect +from dataclasses import dataclass +from typing import Any, Callable, Dict, Iterable, List, Tuple + from .messages import ( # noqa CRITICAL, DEBUG, @@ -16,3 +22,147 @@ Warning, level_to_text, ) + +CheckFn = Callable[..., List[CheckMessage]] + + +@dataclass +class ChecksResults: + """ + Represents the results of running checks. + + This data class holds the results of running a collection of checks. It includes + details about each check's outcome, their statuses, and the overall result level. + + :param details: A dictionary containing detailed information about each check's + outcome, with check names as keys and dictionaries of details as values. + :type details: Dict[str, Dict[str, Any]] + + :param statuses: A dictionary containing the status of each check, with check names + as keys and statuses as values (e.g., 'pass', 'fail', 'warning'). + :type statuses: Dict[str, str] + + :param level: An integer representing the overall result level of the checks + :type level: int + """ + + details: Dict[str, Dict[str, Any]] + statuses: Dict[str, str] + level: int + + +def iscoroutinefunction_or_partial(obj): + """ + Determine if the provided object is a coroutine function or a partial function + that wraps a coroutine function. + + This function checks whether the given object is a coroutine function or a partial + function that wraps a coroutine function. This function should be removed when we + drop support for Python 3.7, as this is handled directly by `inspect.iscoroutinefunction` + in Python 3.8. + + :param obj: The object to be checked for being a coroutine function or partial. + :type obj: object + + :return: True if the object is a coroutine function or a partial function wrapping + a coroutine function, False otherwise. + :rtype: bool + """ + while isinstance(obj, functools.partial): + obj = obj.func + return inspect.iscoroutinefunction(obj) + + +async def _run_check_async(check): + name, check_fn = check + if iscoroutinefunction_or_partial(check_fn): + errors = await check_fn() + else: + loop = asyncio.get_event_loop() + errors = await loop.run_in_executor(None, check_fn) + + return (name, errors) + + +async def run_checks_async( + checks: Iterable[Tuple[str, CheckFn]], + silenced_check_ids=None, +) -> ChecksResults: + """ + Run checks concurrently and return the results. + + Executes a collection of checks concurrently, supporting both synchronous and + asynchronous checks. The results include the outcome of each check and can be + further processed. + + :param checks: An iterable of tuples where each tuple contains a check name and a + check function. + :type checks: Iterable[Tuple[str, CheckFn]] + + :param silenced_check_ids: A list of check IDs that should be omitted from the + results. + :type silenced_check_ids: List[str] + + :return: An instance of ChecksResults containing detailed information about each + check's outcome, their statuses, and the overall result level. + :rtype: ChecksResults + """ + if not silenced_check_ids: + silenced_check_ids = [] + + tasks = (_run_check_async(check) for check in checks) + results = await asyncio.gather(*tasks) + return _build_results_payload(results, silenced_check_ids) + + +def run_checks( + checks: Iterable[Tuple[str, CheckFn]], + silenced_check_ids=None, +) -> ChecksResults: + """ + Run checks synchronously and return the results. + + Executes a collection of checks and returns the results. The results include the + outcome of each check and can be further processed. + + :param checks: An iterable of tuples where each tuple contains a check name and a + check function. + :type checks: Iterable[Tuple[str, CheckFn]] + + :param silenced_check_ids: A list of check IDs that should be omitted from the + results. + :type silenced_check_ids: List[str] + + :return: An instance of ChecksResults containing detailed information about each + check's outcome, their statuses, and the overall result level. + :rtype: ChecksResults + """ + if not silenced_check_ids: + silenced_check_ids = [] + results = [(name, check()) for name, check in checks] + return _build_results_payload(results, silenced_check_ids) + + +def _build_results_payload( + checks_results: Iterable[Tuple[str, Iterable[CheckMessage]]], + silenced_check_ids, +): + details = {} + statuses = {} + max_level = 0 + + for name, errors in checks_results: + errors = [e for e in errors if e not in silenced_check_ids] + level = max([0] + [e.level for e in errors]) + + detail = { + "status": level_to_text(level), + "level": level, + "messages": {e.id: e.msg for e in errors}, + } + statuses[name] = level_to_text(level) + max_level = max(max_level, level) + if level > 0: + details[name] = detail + + return ChecksResults(statuses=statuses, details=details, level=max_level) From 909539fe5c3b8496302678ae60488731f35e889a Mon Sep 17 00:00:00 2001 From: Graham Beckley Date: Sat, 5 Aug 2023 10:26:41 -0400 Subject: [PATCH 2/8] Add check running to Django --- src/dockerflow/django/checks.py | 12 -------- src/dockerflow/django/views.py | 53 +++++++++++++-------------------- 2 files changed, 20 insertions(+), 45 deletions(-) diff --git a/src/dockerflow/django/checks.py b/src/dockerflow/django/checks.py index 6b8e821..b5b12c8 100644 --- a/src/dockerflow/django/checks.py +++ b/src/dockerflow/django/checks.py @@ -11,18 +11,6 @@ from .. import health -def level_to_text(level): - statuses = { - 0: "ok", - checks.messages.DEBUG: "debug", - checks.messages.INFO: "info", - checks.messages.WARNING: "warning", - checks.messages.ERROR: "error", - checks.messages.CRITICAL: "critical", - } - return statuses.get(level, "unknown") - - def check_database_connected(app_configs, **kwargs): """ A Django check to see if connecting to the configured default diff --git a/src/dockerflow/django/views.py b/src/dockerflow/django/views.py index 3cc16e3..f1d46ca 100644 --- a/src/dockerflow/django/views.py +++ b/src/dockerflow/django/views.py @@ -1,12 +1,14 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, you can obtain one at http://mozilla.org/MPL/2.0/. +import functools + from django.conf import settings from django.core import checks from django.http import HttpResponse, HttpResponseNotFound, JsonResponse from django.utils.module_loading import import_string -from .checks import level_to_text +from ..checks import level_to_text, run_checks from .signals import heartbeat_failed, heartbeat_passed version_callback = getattr( @@ -42,42 +44,27 @@ def heartbeat(request): Any check that returns a warning or worse (error, critical) will return a 500 response. """ - all_checks = checks.registry.registry.get_checks( + registered_checks = checks.registry.registry.get_checks( include_deployment_checks=not settings.DEBUG ) - - details = {} - statuses = {} - level = 0 - - for check in all_checks: - detail = heartbeat_check_detail(check) - statuses[check.__name__] = detail["status"] - level = max(level, detail["level"]) - if detail["level"] > 0: - details[check.__name__] = detail - - if level < checks.messages.ERROR: + # partially apply app_configs=None to our check functions so we can run them with our check + # running function, since `app_configs` is a Django-specific kwarg + checks_to_run = ( + (check.__name__, functools.partial(check, app_configs=None)) + for check in registered_checks + ) + check_results = run_checks( + checks_to_run, + silenced_check_ids=settings.SILENCED_SYSTEM_CHECKS, + ) + if check_results.level < checks.messages.ERROR: status_code = 200 - heartbeat_passed.send(sender=heartbeat, level=level) + heartbeat_passed.send(sender=heartbeat, level=check_results.level) else: status_code = 500 - heartbeat_failed.send(sender=heartbeat, level=level) - - payload = {"status": level_to_text(level)} + heartbeat_failed.send(sender=heartbeat, level=check_results.level) + payload = {"status": level_to_text(check_results.level)} if settings.DEBUG: - payload["checks"] = statuses - payload["details"] = details + payload["checks"] = check_results.statuses + payload["details"] = check_results.details return JsonResponse(payload, status=status_code) - - -def heartbeat_check_detail(check): - errors = check(app_configs=None) - errors = list(filter(lambda e: e.id not in settings.SILENCED_SYSTEM_CHECKS, errors)) - level = max([0] + [e.level for e in errors]) - - return { - "status": level_to_text(level), - "level": level, - "messages": {e.id: e.msg for e in errors}, - } From a60f704fcc6d2bd090dde8239fba2d4309ec2a80 Mon Sep 17 00:00:00 2001 From: Graham Beckley Date: Sat, 5 Aug 2023 10:27:16 -0400 Subject: [PATCH 3/8] Add check running to Flask --- src/dockerflow/flask/app.py | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/src/dockerflow/flask/app.py b/src/dockerflow/flask/app.py index 25d467b..086f908 100644 --- a/src/dockerflow/flask/app.py +++ b/src/dockerflow/flask/app.py @@ -11,6 +11,8 @@ import flask from werkzeug.exceptions import InternalServerError +from dockerflow.checks import run_checks + from .. import version from . import checks from .signals import heartbeat_failed, heartbeat_passed @@ -303,16 +305,6 @@ def _lbheartbeat_view(self): """ return "", 200 - def _heartbeat_check_detail(self, check): - errors = list(filter(lambda e: e.id not in self.silenced_checks, check())) - level = max([0] + [e.level for e in errors]) - - return { - "status": checks.level_to_text(level), - "level": level, - "messages": {e.id: e.msg for e in errors}, - } - def _heartbeat_view(self): """ Runs all the registered checks and returns a JSON response with either @@ -321,33 +313,25 @@ def _heartbeat_view(self): Any check that returns a warning or worse (error, critical) will return a 500 response. """ - details = {} - statuses = {} - level = 0 - for name, check in self.checks.items(): - detail = self._heartbeat_check_detail(check) - statuses[name] = detail["status"] - level = max(level, detail["level"]) - if detail["level"] > 0: - details[name] = detail + check_results = run_checks(self.checks.items()) payload = { - "status": checks.level_to_text(level), - "checks": statuses, - "details": details, + "status": checks.level_to_text(check_results.level), + "checks": check_results.statuses, + "details": check_results.details, } def render(status_code): return flask.make_response(flask.jsonify(payload), status_code) - if level < checks.ERROR: + if check_results.level < checks.ERROR: status_code = 200 - heartbeat_passed.send(self, level=level) + heartbeat_passed.send(self, level=check_results.level) return render(status_code) else: status_code = 500 - heartbeat_failed.send(self, level=level) + heartbeat_failed.send(self, level=check_results.level) raise HeartbeatFailure(response=render(status_code)) def version_callback(self, func): From 2b4649082beff134b830aacac3d1fad32309ccec Mon Sep 17 00:00:00 2001 From: Graham Beckley Date: Sat, 5 Aug 2023 10:28:19 -0400 Subject: [PATCH 4/8] Add check running to Sanic --- src/dockerflow/sanic/app.py | 56 ++++++++++++++++--------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/src/dockerflow/sanic/app.py b/src/dockerflow/sanic/app.py index 5457c0c..121a679 100644 --- a/src/dockerflow/sanic/app.py +++ b/src/dockerflow/sanic/app.py @@ -11,6 +11,8 @@ from sanic import response +from dockerflow.checks import iscoroutinefunction_or_partial, run_checks_async + from .. import version from . import checks @@ -208,19 +210,6 @@ async def _lbheartbeat_view(self, request): """ return response.raw(b"", 200) - async def _heartbeat_check_detail(self, check): - result = check() - if isawaitable(result): - result = await result - errors = [e for e in result if e.id not in self.silenced_checks] - level = max([0] + [e.level for e in errors]) - - return { - "status": checks.level_to_text(level), - "level": level, - "messages": {e.id: e.msg for e in errors}, - } - async def _heartbeat_view(self, request): """ Runs all the registered checks and returns a JSON response with either @@ -229,24 +218,16 @@ async def _heartbeat_view(self, request): Any check that returns a warning or worse (error, critical) will return a 500 response. """ - details = {} - statuses = {} - level = 0 - for name, check in self.checks.items(): - detail = await self._heartbeat_check_detail(check) - statuses[name] = detail["status"] - level = max(level, detail["level"]) - if detail["level"] > 0: - details[name] = detail + check_results = await run_checks_async(self.checks.items()) payload = { - "status": checks.level_to_text(level), - "checks": statuses, - "details": details, + "status": checks.level_to_text(check_results.level), + "checks": check_results.statuses, + "details": check_results.details, } - if level < checks.ERROR: + if check_results.level < checks.ERROR: status_code = 200 else: status_code = 500 @@ -321,10 +302,21 @@ async def storage_reachable(): self.logger.info("Registered Dockerflow check %s", name) - @functools.wraps(func) - def decorated_function(*args, **kwargs): - self.logger.info("Called Dockerflow check %s", name) - return func(*args, **kwargs) + if iscoroutinefunction_or_partial(func): + + @functools.wraps(func) + async def decorated_function_asyc(*args, **kwargs): + self.logger.info("Called Dockerflow check %s", name) + return await func(*args, **kwargs) + + self.checks[name] = decorated_function_asyc + return decorated_function_asyc + else: + + @functools.wraps(func) + def decorated_function(*args, **kwargs): + self.logger.info("Called Dockerflow check %s", name) + return func(*args, **kwargs) - self.checks[name] = decorated_function - return decorated_function + self.checks[name] = decorated_function + return decorated_function From 2dfa8c1392d01c4bab2ab27b758717889ec1e635 Mon Sep 17 00:00:00 2001 From: Graham Beckley Date: Tue, 8 Aug 2023 21:26:59 -0400 Subject: [PATCH 5/8] Use a messages's id to check if it's silenced --- src/dockerflow/checks/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dockerflow/checks/__init__.py b/src/dockerflow/checks/__init__.py index 1b4222e..e4b9834 100644 --- a/src/dockerflow/checks/__init__.py +++ b/src/dockerflow/checks/__init__.py @@ -152,7 +152,7 @@ def _build_results_payload( max_level = 0 for name, errors in checks_results: - errors = [e for e in errors if e not in silenced_check_ids] + errors = [e for e in errors if e.id not in silenced_check_ids] level = max([0] + [e.level for e in errors]) detail = { From a92c23cde1177a5447d7e15141e551abe62fe99a Mon Sep 17 00:00:00 2001 From: Graham Beckley Date: Tue, 8 Aug 2023 21:27:29 -0400 Subject: [PATCH 6/8] Check for silenced IDs in flask and sanic --- src/dockerflow/flask/app.py | 5 ++++- src/dockerflow/sanic/app.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/dockerflow/flask/app.py b/src/dockerflow/flask/app.py index 086f908..637eb63 100644 --- a/src/dockerflow/flask/app.py +++ b/src/dockerflow/flask/app.py @@ -314,7 +314,10 @@ def _heartbeat_view(self): return a 500 response. """ - check_results = run_checks(self.checks.items()) + check_results = run_checks( + self.checks.items(), + silenced_check_ids=self.silenced_checks, + ) payload = { "status": checks.level_to_text(check_results.level), diff --git a/src/dockerflow/sanic/app.py b/src/dockerflow/sanic/app.py index 121a679..22ba018 100644 --- a/src/dockerflow/sanic/app.py +++ b/src/dockerflow/sanic/app.py @@ -219,7 +219,10 @@ async def _heartbeat_view(self, request): return a 500 response. """ - check_results = await run_checks_async(self.checks.items()) + check_results = await run_checks_async( + self.checks.items(), + silenced_check_ids=self.silenced_checks, + ) payload = { "status": checks.level_to_text(check_results.level), From bd95aec9f5af7ff8c217764380737934f7868719 Mon Sep 17 00:00:00 2001 From: Graham Beckley Date: Tue, 8 Aug 2023 21:30:27 -0400 Subject: [PATCH 7/8] Refactor django heartbeat tests - use client instead of request factory - automatically clear registry, and don't handle Django version <2 - test response content when debug is enabled and disabled --- tests/django/settings.py | 2 +- tests/django/test_django.py | 43 ++++++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/django/settings.py b/tests/django/settings.py index 341b3f3..55e80c7 100644 --- a/tests/django/settings.py +++ b/tests/django/settings.py @@ -6,7 +6,7 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -ROOT_URLCONF = "tests.urls" +ROOT_URLCONF = "tests.django.urls" SECRET_KEY = ( "e4ac21f475d9d1c4b426f82640e7772d7f09fd8caa78919abbfcc4949bd74aa1b48302a3d2162cdd" diff --git a/tests/django/test_django.py b/tests/django/test_django.py index 6270f3e..8d0507a 100644 --- a/tests/django/test_django.py +++ b/tests/django/test_django.py @@ -6,7 +6,6 @@ import pytest import redis -from django import VERSION as django_version from django.core.checks.registry import registry from django.core.exceptions import ImproperlyConfigured from django.db import connection @@ -20,14 +19,11 @@ from dockerflow.django.middleware import DockerflowMiddleware -@pytest.fixture +@pytest.fixture(autouse=True) def reset_checks(): - if django_version[0] < 2: - registry.registered_checks = [] - registry.deployment_checks = [] - else: - registry.registered_checks = set() - registry.deployment_checks = set() + yield + registry.registered_checks = set() + registry.deployment_checks = set() @pytest.fixture @@ -54,9 +50,8 @@ def test_version_missing(dockerflow_middleware, mocker, rf): @pytest.mark.django_db -def test_heartbeat(dockerflow_middleware, reset_checks, rf, settings): - request = rf.get("/__heartbeat__") - response = dockerflow_middleware.process_request(request) +def test_heartbeat(client, settings): + response = client.get("/__heartbeat__") assert response.status_code == 200 settings.DOCKERFLOW_CHECKS = [ @@ -64,8 +59,30 @@ def test_heartbeat(dockerflow_middleware, reset_checks, rf, settings): "tests.django.django_checks.error", ] checks.register() - response = dockerflow_middleware.process_request(request) + response = client.get("/__heartbeat__") assert response.status_code == 500 + content = response.json() + assert content["status"] == "error" + assert content.get("checks") is None + assert content.get("details") is None + + +@pytest.mark.django_db +def test_heartbeat_debug(client, settings): + settings.DOCKERFLOW_CHECKS = [ + "tests.django.django_checks.warning", + "tests.django.django_checks.error", + ] + settings.DEBUG = True + checks.register() + response = client.get("/__heartbeat__") + assert response.status_code == 500 + content = response.json() + assert content["status"] + assert content["checks"] + assert content["details"] + + @pytest.mark.django_db @@ -79,7 +96,7 @@ def test_lbheartbeat_makes_no_db_queries(dockerflow_middleware, rf): @pytest.mark.django_db -def test_redis_check(dockerflow_middleware, reset_checks, rf, settings): +def test_redis_check(dockerflow_middleware, rf, settings): settings.DOCKERFLOW_CHECKS = ["dockerflow.django.checks.check_redis_connected"] checks.register() request = rf.get("/__heartbeat__") From 4a37cee67f89e9abee2b3358a3b00e3dd346683c Mon Sep 17 00:00:00 2001 From: Graham Beckley Date: Tue, 8 Aug 2023 21:30:44 -0400 Subject: [PATCH 8/8] Add tests for silenced checks --- tests/django/test_django.py | 16 ++++++++++++++++ tests/flask/test_flask.py | 20 ++++++++++++++++++++ tests/sanic/test_sanic.py | 20 ++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/tests/django/test_django.py b/tests/django/test_django.py index 8d0507a..2bd2e21 100644 --- a/tests/django/test_django.py +++ b/tests/django/test_django.py @@ -83,6 +83,22 @@ def test_heartbeat_debug(client, settings): assert content["details"] +@pytest.mark.django_db +def test_heartbeat_silenced(client, settings): + settings.DOCKERFLOW_CHECKS = [ + "tests.django.django_checks.warning", + "tests.django.django_checks.error", + ] + settings.SILENCED_SYSTEM_CHECKS.append("tests.checks.E001") + settings.DEBUG = True + checks.register() + + response = client.get("/__heartbeat__") + assert response.status_code == 200 + content = response.json() + assert content["status"] == "warning" + assert "warning" in content["details"] + assert "error" not in content["details"] @pytest.mark.django_db diff --git a/tests/flask/test_flask.py b/tests/flask/test_flask.py index 7b345a9..ca46cfd 100644 --- a/tests/flask/test_flask.py +++ b/tests/flask/test_flask.py @@ -139,6 +139,26 @@ def warning_check2(): assert "warning-check-two" in defaults +def test_heartbeat_silenced_checks(app): + dockerflow = Dockerflow(app, silenced_checks=["tests.checks.W001"]) + + @dockerflow.check + def error_check(): + return [checks.Error("some error", id="tests.checks.E001")] + + @dockerflow.check() + def warning_check(): + return [checks.Warning("some warning", id="tests.checks.W001")] + + response = app.test_client().get("/__heartbeat__") + assert response.status_code == 500 + payload = json.loads(response.data.decode()) + assert payload["status"] == "error" + details = payload["details"] + assert "error_check" in details + assert "warning_check" not in details + + def test_lbheartbeat_makes_no_db_queries(dockerflow, app): with app.app_context(): assert len(get_debug_queries()) == 0 diff --git a/tests/sanic/test_sanic.py b/tests/sanic/test_sanic.py index 745052f..a2da7e2 100644 --- a/tests/sanic/test_sanic.py +++ b/tests/sanic/test_sanic.py @@ -162,6 +162,26 @@ async def warning_check2(): assert "warning-check-two" in details +def test_heartbeat_silenced_checks(app, test_client): + dockerflow = Dockerflow(app, silenced_checks=["tests.checks.W001"]) + + @dockerflow.check + def error_check(): + return [checks.Error("some error", id="tests.checks.E001")] + + @dockerflow.check() + def warning_check(): + return [checks.Warning("some warning", id="tests.checks.W001")] + + _, response = test_client.get("/__heartbeat__") + assert response.status == 500 + payload = response.json + assert payload["status"] == "error" + details = payload["details"] + assert "error_check" in details + assert "warning_check" not in details + + def test_redis_check(dockerflow_redis, mocker, test_client): assert "check_redis_connected" in dockerflow_redis.checks mocker.patch.object(sanic_redis.core, "from_url", fake_redis)