From 0e5bd06e591907d0f8a53840acddc38d7711b8c7 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 02:28:23 +0700 Subject: [PATCH 01/16] feat: add ability to define health check subsets --- health_check/backends.py | 6 +++--- health_check/mixins.py | 33 ++++++++++++++++++++++++++------- health_check/urls.py | 1 + health_check/views.py | 14 ++++++++------ 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/health_check/backends.py b/health_check/backends.py index 36a46e71..11106b3f 100644 --- a/health_check/backends.py +++ b/health_check/backends.py @@ -20,14 +20,14 @@ class BaseHealthCheckBackend: def __init__(self): self.errors = [] - def check_status(self): + def check_status(self, subset=None): raise NotImplementedError - def run_check(self): + def run_check(self, subset=None): start = timer() self.errors = [] try: - self.check_status() + self.check_status(subset=subset) except HealthCheckException as e: self.add_error(e, e) except BaseException: diff --git a/health_check/mixins.py b/health_check/mixins.py index fefa29e9..3cdf5c4f 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -1,5 +1,8 @@ import copy +from collections import OrderedDict from concurrent.futures import ThreadPoolExecutor +from django.conf import settings +from django.http import Http404 from health_check.conf import HEALTH_CHECK from health_check.exceptions import ServiceWarning @@ -9,6 +12,7 @@ class CheckMixin: _errors = None _plugins = None + _subset = None @property def errors(self): @@ -16,19 +20,33 @@ def errors(self): self._errors = self.run_check() return self._errors + def check(self, subset): + return self.run_check(subset=subset) + @property def plugins(self): if not self._plugins: - self._plugins = sorted( - ( + registering_plugins = ( plugin_class(**copy.deepcopy(options)) for plugin_class, options in plugin_dir._registry - ), - key=lambda plugin: plugin.identifier(), ) + self._plugins = OrderedDict({plugin.identifier(): plugin for plugin in registering_plugins}) return self._plugins - def run_check(self): + def filter_plugins(self, subset=None): + if subset is None: + return self.plugins + + health_check_subsets = getattr(settings, 'HEALTH_CHECK_SUBSETS', {}) + + if subset not in health_check_subsets: + raise Http404(f"Specify subset: '{subset}' does not exists.") + + selected_subset = set(health_check_subsets[subset]) + return {plugin_identifier: v for plugin_identifier, v in self.plugins.items() if plugin_identifier in selected_subset} + + + def run_check(self, subset=None): errors = [] def _run(plugin): @@ -40,8 +58,9 @@ def _run(plugin): connections.close_all() - with ThreadPoolExecutor(max_workers=len(self.plugins) or 1) as executor: - for plugin in executor.map(_run, self.plugins): + plugins = self.filter_plugins(subset=subset) + with ThreadPoolExecutor(max_workers=len(plugins) or 1) as executor: + for plugin in executor.map(_run, plugins.values()): if plugin.critical_service: if not HEALTH_CHECK["WARNINGS_AS_ERRORS"]: errors.extend( diff --git a/health_check/urls.py b/health_check/urls.py index 92bc7d65..416406be 100644 --- a/health_check/urls.py +++ b/health_check/urls.py @@ -6,4 +6,5 @@ urlpatterns = [ path("", MainView.as_view(), name="health_check_home"), + path("/", MainView.as_view(), name="health_check_subset"), ] diff --git a/health_check/views.py b/health_check/views.py index 8eadf912..75f9ad21 100644 --- a/health_check/views.py +++ b/health_check/views.py @@ -88,12 +88,13 @@ class MainView(CheckMixin, TemplateView): @method_decorator(never_cache) def get(self, request, *args, **kwargs): - status_code = 500 if self.errors else 200 - + subset = kwargs.get("subset", None) + health_check_has_error = self.check(subset) + status_code = 500 if health_check_has_error else 200 format_override = request.GET.get("format") if format_override == "json": - return self.render_to_response_json(self.plugins, status_code) + return self.render_to_response_json(self.filter_plugins(subset=subset), status_code) accept_header = request.META.get("HTTP_ACCEPT", "*/*") for media in MediaType.parse_header(accept_header): @@ -106,7 +107,7 @@ def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) return self.render_to_response(context, status=status_code) elif media.mime_type in ("application/json", "application/*"): - return self.render_to_response_json(self.plugins, status_code) + return self.render_to_response_json(self.filter_plugins(subset=subset), status_code) return HttpResponse( "Not Acceptable: Supported content types: text/html, application/json", status=406, @@ -114,10 +115,11 @@ def get(self, request, *args, **kwargs): ) def get_context_data(self, **kwargs): - return {**super().get_context_data(**kwargs), "plugins": self.plugins} + subset = kwargs.get('subset', None) + return {**super().get_context_data(**kwargs), "plugins": self.filter_plugins(subset=subset).values()} def render_to_response_json(self, plugins, status): return JsonResponse( - {str(p.identifier()): str(p.pretty_status()) for p in plugins}, + {str(plugin_identifier): str(p.pretty_status()) for plugin_identifier, p in plugins.items()}, status=status, ) From 4c81186946a7c48984747ae9c706684fc0c371c8 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 02:29:19 +0700 Subject: [PATCH 02/16] feat: adjust default health check to conform with new structure --- health_check/cache/backends.py | 2 +- health_check/contrib/celery/backends.py | 2 +- health_check/contrib/celery_ping/backends.py | 2 +- health_check/contrib/migrations/backends.py | 2 +- health_check/contrib/psutil/backends.py | 4 ++-- health_check/contrib/rabbitmq/backends.py | 2 +- health_check/contrib/redis/backends.py | 2 +- health_check/db/backends.py | 2 +- health_check/management/commands/health_check.py | 4 ++-- health_check/storage/backends.py | 2 +- tests/test_commands.py | 4 ++-- tests/test_mixins.py | 4 ++-- tests/test_plugins.py | 4 ++-- tests/test_views.py | 6 +++--- 14 files changed, 21 insertions(+), 21 deletions(-) diff --git a/health_check/cache/backends.py b/health_check/cache/backends.py index d810f805..6b6c050c 100644 --- a/health_check/cache/backends.py +++ b/health_check/cache/backends.py @@ -28,7 +28,7 @@ def __init__(self, backend="default"): def identifier(self): return f"Cache backend: {self.backend}" - def check_status(self): + def check_status(self, subset=None): cache = caches[self.backend] try: diff --git a/health_check/contrib/celery/backends.py b/health_check/contrib/celery/backends.py index 77afbdc5..7454597e 100644 --- a/health_check/contrib/celery/backends.py +++ b/health_check/contrib/celery/backends.py @@ -8,7 +8,7 @@ class CeleryHealthCheck(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): timeout = getattr(settings, "HEALTHCHECK_CELERY_TIMEOUT", 3) result_timeout = getattr(settings, "HEALTHCHECK_CELERY_RESULT_TIMEOUT", timeout) queue_timeout = getattr(settings, "HEALTHCHECK_CELERY_QUEUE_TIMEOUT", timeout) diff --git a/health_check/contrib/celery_ping/backends.py b/health_check/contrib/celery_ping/backends.py index 4c2a2d34..2fdc9a69 100644 --- a/health_check/contrib/celery_ping/backends.py +++ b/health_check/contrib/celery_ping/backends.py @@ -8,7 +8,7 @@ class CeleryPingHealthCheck(BaseHealthCheckBackend): CORRECT_PING_RESPONSE = {"ok": "pong"} - def check_status(self): + def check_status(self, subset=None): timeout = getattr(settings, "HEALTHCHECK_CELERY_PING_TIMEOUT", 1) try: diff --git a/health_check/contrib/migrations/backends.py b/health_check/contrib/migrations/backends.py index 727b1814..c465c8f2 100644 --- a/health_check/contrib/migrations/backends.py +++ b/health_check/contrib/migrations/backends.py @@ -14,7 +14,7 @@ class MigrationsHealthCheck(BaseHealthCheckBackend): def get_migration_plan(self, executor): return executor.migration_plan(executor.loader.graph.leaf_nodes()) - def check_status(self): + def check_status(self, subset=None): db_alias = getattr(settings, "HEALTHCHECK_MIGRATIONS_DB", DEFAULT_DB_ALIAS) try: executor = MigrationExecutor(connections[db_alias]) diff --git a/health_check/contrib/psutil/backends.py b/health_check/contrib/psutil/backends.py index 0d2709a4..d5054ec6 100644 --- a/health_check/contrib/psutil/backends.py +++ b/health_check/contrib/psutil/backends.py @@ -14,7 +14,7 @@ class DiskUsage(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): try: du = psutil.disk_usage("/") if DISK_USAGE_MAX and du.percent >= DISK_USAGE_MAX: @@ -28,7 +28,7 @@ def check_status(self): class MemoryUsage(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): try: memory = psutil.virtual_memory() if MEMORY_MIN and memory.available < (MEMORY_MIN * 1024 * 1024): diff --git a/health_check/contrib/rabbitmq/backends.py b/health_check/contrib/rabbitmq/backends.py index adaff36b..37c9f9dc 100644 --- a/health_check/contrib/rabbitmq/backends.py +++ b/health_check/contrib/rabbitmq/backends.py @@ -15,7 +15,7 @@ class RabbitMQHealthCheck(BaseHealthCheckBackend): namespace = None - def check_status(self): + def check_status(self, subset=None): """Check RabbitMQ service by opening and closing a broker channel.""" logger.debug("Checking for a broker_url on django settings...") diff --git a/health_check/contrib/redis/backends.py b/health_check/contrib/redis/backends.py index 9d7272c6..e9076bbf 100644 --- a/health_check/contrib/redis/backends.py +++ b/health_check/contrib/redis/backends.py @@ -14,7 +14,7 @@ class RedisHealthCheck(BaseHealthCheckBackend): redis_url = getattr(settings, "REDIS_URL", "redis://localhost/1") - def check_status(self): + def check_status(self, subset=None): """Check Redis service by pinging the redis instance with a redis connection.""" logger.debug("Got %s as the redis_url. Connecting to redis...", self.redis_url) diff --git a/health_check/db/backends.py b/health_check/db/backends.py index 8687d994..1c7473fd 100644 --- a/health_check/db/backends.py +++ b/health_check/db/backends.py @@ -7,7 +7,7 @@ class DatabaseBackend(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): try: obj = TestModel.objects.create(title="test") obj.title = "newtest" diff --git a/health_check/management/commands/health_check.py b/health_check/management/commands/health_check.py index 1bb7e723..22a4f6ae 100644 --- a/health_check/management/commands/health_check.py +++ b/health_check/management/commands/health_check.py @@ -12,11 +12,11 @@ def handle(self, *args, **options): # perform all checks errors = self.errors - for plugin in self.plugins: + for plugin_identifier, plugin in self.plugins.items(): style_func = self.style.SUCCESS if not plugin.errors else self.style.ERROR self.stdout.write( "{:<24} ... {}\n".format( - plugin.identifier(), style_func(plugin.pretty_status()) + plugin_identifier, style_func(plugin.pretty_status()) ) ) diff --git a/health_check/storage/backends.py b/health_check/storage/backends.py index 5920b133..0d966d67 100644 --- a/health_check/storage/backends.py +++ b/health_check/storage/backends.py @@ -67,7 +67,7 @@ def check_delete(self, file_name): if storage.exists(file_name): raise ServiceUnavailable("File was not deleted") - def check_status(self): + def check_status(self, subset=None): try: # write the file to the storage backend file_name = self.get_file_name() diff --git a/tests/test_commands.py b/tests/test_commands.py index 59c0ceb2..a4ad47ae 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -8,12 +8,12 @@ class FailPlugin(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): self.add_error("Oops") class OkPlugin(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): pass diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 1a35e936..2393490c 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -6,12 +6,12 @@ class FailPlugin(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): self.add_error("Oops") class OkPlugin(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): pass diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 879d4b9d..d2ad270c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -5,12 +5,12 @@ class FakePlugin(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): pass class Plugin(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): pass diff --git a/tests/test_views.py b/tests/test_views.py index 252e76c9..a0a60f77 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -92,7 +92,7 @@ def test_success(self, client): def test_error(self, client): class MyBackend(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): self.add_error("Super Fail!") plugin_dir.reset() @@ -104,7 +104,7 @@ def check_status(self): def test_warning(self, client): class MyBackend(BaseHealthCheckBackend): - def check_status(self): + def check_status(self, subset=None): raise ServiceWarning("so so") plugin_dir.reset() @@ -124,7 +124,7 @@ def test_non_critical(self, client): class MyBackend(BaseHealthCheckBackend): critical_service = False - def check_status(self): + def check_status(self, subset=None): self.add_error("Super Fail!") plugin_dir.reset() From 51c6ed84b352f0b1745be82ba7feb5c3646d6b7a Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 02:42:51 +0700 Subject: [PATCH 03/16] chore: add usage instruction in readme --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index ffba6438..71d01e26 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,19 @@ one of these checks, set its value to `None`. } ``` +To use Health Check Subsets, Specify a subset name and associate it with the relevant health check services to utilize Health Check Subsets. +```python + HEALTH_CHECK_SUBSETS = { + 'startup-probe': ['DatabaseBackend', 'MigrationsHealthCheck'], + 'liveness-probe': ['DatabaseBackend'], + } +``` + +To only execute specific subset of health check +```shell +curl -X GET -H "Accept: application/json" http://www.example.com/ht/startup-probe/ +``` + If using the DB check, run migrations: ```shell From 0e5cd5f375920214a88ea2157a32ac69b6cccaaa Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 03:02:18 +0700 Subject: [PATCH 04/16] feat: allow subset argument to be specify using Django Command --- health_check/management/commands/health_check.py | 9 +++++++-- health_check/mixins.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/health_check/management/commands/health_check.py b/health_check/management/commands/health_check.py index 22a4f6ae..541ea209 100644 --- a/health_check/management/commands/health_check.py +++ b/health_check/management/commands/health_check.py @@ -8,11 +8,16 @@ class Command(CheckMixin, BaseCommand): help = "Run health checks and exit 0 if everything went well." + def add_arguments(self, parser): + parser.add_argument("--subset", type=str, default=None, nargs=1) + def handle(self, *args, **options): # perform all checks - errors = self.errors + subset = options.get("subset", []) + subset = subset[0] if subset else None + errors = self.check(subset=subset) - for plugin_identifier, plugin in self.plugins.items(): + for plugin_identifier, plugin in self.filter_plugins(subset=subset).items(): style_func = self.style.SUCCESS if not plugin.errors else self.style.ERROR self.stdout.write( "{:<24} ... {}\n".format( diff --git a/health_check/mixins.py b/health_check/mixins.py index 3cdf5c4f..bfbf30a7 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -20,7 +20,7 @@ def errors(self): self._errors = self.run_check() return self._errors - def check(self, subset): + def check(self, subset=None): return self.run_check(subset=subset) @property From 77fba53fedea3ff15094e55611ff578078070b47 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 03:17:30 +0700 Subject: [PATCH 05/16] feat: fix broken tests with missing subset argument --- tests/test_views.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/test_views.py b/tests/test_views.py index a0a60f77..cc8e86c6 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -136,7 +136,7 @@ def check_status(self, subset=None): def test_success_accept_json(self, client): class JSONSuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -147,7 +147,7 @@ def run_check(self): def test_success_prefer_json(self, client): class JSONSuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -160,7 +160,7 @@ def run_check(self): def test_success_accept_xhtml(self, client): class SuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -171,7 +171,7 @@ def run_check(self): def test_success_unsupported_accept(self, client): class SuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -186,7 +186,7 @@ def run_check(self): def test_success_unsupported_and_supported_accept(self, client): class SuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -199,7 +199,7 @@ def run_check(self): def test_success_accept_order(self, client): class JSONSuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -213,7 +213,7 @@ def run_check(self): def test_success_accept_order__reverse(self, client): class JSONSuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -227,7 +227,7 @@ def run_check(self): def test_format_override(self, client): class JSONSuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -238,7 +238,7 @@ def run_check(self): def test_format_no_accept_header(self, client): class JSONSuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -249,7 +249,7 @@ def run_check(self): def test_error_accept_json(self, client): class JSONErrorBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): self.add_error("JSON Error") plugin_dir.reset() @@ -266,7 +266,7 @@ def run_check(self): def test_success_param_json(self, client): class JSONSuccessBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): pass plugin_dir.reset() @@ -278,9 +278,13 @@ def run_check(self): JSONSuccessBackend().identifier(): JSONSuccessBackend().pretty_status() } + + def test_success_subset_define(self, client): + + def test_error_param_json(self, client): class JSONErrorBackend(BaseHealthCheckBackend): - def run_check(self): + def run_check(self, subset=None): self.add_error("JSON Error") plugin_dir.reset() From 4268859ddc74ab555158a9c1ae279f486678f9d7 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 03:22:29 +0700 Subject: [PATCH 06/16] feat: move getattr from django settings to conf --- health_check/conf.py | 1 + health_check/mixins.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/health_check/conf.py b/health_check/conf.py index fdd2b1ec..ab72ed0e 100644 --- a/health_check/conf.py +++ b/health_check/conf.py @@ -4,3 +4,4 @@ HEALTH_CHECK.setdefault("DISK_USAGE_MAX", 90) HEALTH_CHECK.setdefault("MEMORY_MIN", 100) HEALTH_CHECK.setdefault("WARNINGS_AS_ERRORS", True) +HEALTH_CHECK_SUBSETS = getattr(settings, "HEALTH_CHECK_SUBSETS", {}) diff --git a/health_check/mixins.py b/health_check/mixins.py index bfbf30a7..b775f6e3 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -4,7 +4,7 @@ from django.conf import settings from django.http import Http404 -from health_check.conf import HEALTH_CHECK +from health_check.conf import HEALTH_CHECK, HEALTH_CHECK_SUBSETS from health_check.exceptions import ServiceWarning from health_check.plugins import plugin_dir @@ -37,7 +37,7 @@ def filter_plugins(self, subset=None): if subset is None: return self.plugins - health_check_subsets = getattr(settings, 'HEALTH_CHECK_SUBSETS', {}) + health_check_subsets = HEALTH_CHECK_SUBSETS if subset not in health_check_subsets: raise Http404(f"Specify subset: '{subset}' does not exists.") From afc83476baffdf11f07de1ae8fe51ef0399821e7 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 03:26:21 +0700 Subject: [PATCH 07/16] feat: remove unused variable --- health_check/mixins.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/health_check/mixins.py b/health_check/mixins.py index b775f6e3..6d43f57d 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -1,7 +1,6 @@ import copy from collections import OrderedDict from concurrent.futures import ThreadPoolExecutor -from django.conf import settings from django.http import Http404 from health_check.conf import HEALTH_CHECK, HEALTH_CHECK_SUBSETS @@ -12,7 +11,6 @@ class CheckMixin: _errors = None _plugins = None - _subset = None @property def errors(self): From 5d248d713f6b31bebdbc84897bb4a1a065e8f785 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 03:36:54 +0700 Subject: [PATCH 08/16] feat: add tests --- health_check/mixins.py | 16 +++++++++++----- health_check/views.py | 20 +++++++++++++++----- tests/test_views.py | 41 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/health_check/mixins.py b/health_check/mixins.py index 6d43f57d..c808a4d2 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -1,6 +1,7 @@ import copy from collections import OrderedDict from concurrent.futures import ThreadPoolExecutor + from django.http import Http404 from health_check.conf import HEALTH_CHECK, HEALTH_CHECK_SUBSETS @@ -25,10 +26,12 @@ def check(self, subset=None): def plugins(self): if not self._plugins: registering_plugins = ( - plugin_class(**copy.deepcopy(options)) - for plugin_class, options in plugin_dir._registry + plugin_class(**copy.deepcopy(options)) + for plugin_class, options in plugin_dir._registry + ) + self._plugins = OrderedDict( + {plugin.identifier(): plugin for plugin in registering_plugins} ) - self._plugins = OrderedDict({plugin.identifier(): plugin for plugin in registering_plugins}) return self._plugins def filter_plugins(self, subset=None): @@ -41,8 +44,11 @@ def filter_plugins(self, subset=None): raise Http404(f"Specify subset: '{subset}' does not exists.") selected_subset = set(health_check_subsets[subset]) - return {plugin_identifier: v for plugin_identifier, v in self.plugins.items() if plugin_identifier in selected_subset} - + return { + plugin_identifier: v + for plugin_identifier, v in self.plugins.items() + if plugin_identifier in selected_subset + } def run_check(self, subset=None): errors = [] diff --git a/health_check/views.py b/health_check/views.py index 75f9ad21..3c928e90 100644 --- a/health_check/views.py +++ b/health_check/views.py @@ -94,7 +94,9 @@ def get(self, request, *args, **kwargs): format_override = request.GET.get("format") if format_override == "json": - return self.render_to_response_json(self.filter_plugins(subset=subset), status_code) + return self.render_to_response_json( + self.filter_plugins(subset=subset), status_code + ) accept_header = request.META.get("HTTP_ACCEPT", "*/*") for media in MediaType.parse_header(accept_header): @@ -107,7 +109,9 @@ def get(self, request, *args, **kwargs): context = self.get_context_data(**kwargs) return self.render_to_response(context, status=status_code) elif media.mime_type in ("application/json", "application/*"): - return self.render_to_response_json(self.filter_plugins(subset=subset), status_code) + return self.render_to_response_json( + self.filter_plugins(subset=subset), status_code + ) return HttpResponse( "Not Acceptable: Supported content types: text/html, application/json", status=406, @@ -115,11 +119,17 @@ def get(self, request, *args, **kwargs): ) def get_context_data(self, **kwargs): - subset = kwargs.get('subset', None) - return {**super().get_context_data(**kwargs), "plugins": self.filter_plugins(subset=subset).values()} + subset = kwargs.get("subset", None) + return { + **super().get_context_data(**kwargs), + "plugins": self.filter_plugins(subset=subset).values(), + } def render_to_response_json(self, plugins, status): return JsonResponse( - {str(plugin_identifier): str(p.pretty_status()) for plugin_identifier, p in plugins.items()}, + { + str(plugin_identifier): str(p.pretty_status()) + for plugin_identifier, p in plugins.items() + }, status=status, ) diff --git a/tests/test_views.py b/tests/test_views.py index cc8e86c6..92f4834b 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -2,6 +2,7 @@ import pytest +from health_check import conf from health_check.backends import BaseHealthCheckBackend from health_check.conf import HEALTH_CHECK from health_check.exceptions import ServiceWarning @@ -278,9 +279,47 @@ def run_check(self, subset=None): JSONSuccessBackend().identifier(): JSONSuccessBackend().pretty_status() } - def test_success_subset_define(self, client): + class SuccessOneBackend(BaseHealthCheckBackend): + def run_check(self, subset=None): + pass + class SuccessTwoBackend(BaseHealthCheckBackend): + def run_check(self, subset=None): + pass + + plugin_dir.reset() + plugin_dir.register(SuccessOneBackend) + plugin_dir.register(SuccessTwoBackend) + + HEALTH_CHECK_SUBSETS = { + "startup-probe": ["SuccessOneBackend", "SuccessTwoBackend"], + "liveness-probe": ["SuccessTwoBackend"], + } + setattr(conf, "HEALTH_CHECK_SUBSETS", HEALTH_CHECK_SUBSETS) + + response_startup_probe = client.get( + self.url + "startup-probe/", {"format": "json"} + ) + assert ( + response_startup_probe.status_code == 200 + ), response_startup_probe.content.decode("utf-8") + assert response_startup_probe["content-type"] == "application/json" + assert json.loads(response_startup_probe.content.decode("utf-8")) == { + SuccessOneBackend().identifier(): SuccessOneBackend().pretty_status(), + SuccessTwoBackend().identifier(): SuccessTwoBackend().pretty_status(), + } + + response_liveness_probe = client.get( + self.url + "liveness-probe/", {"format": "json"} + ) + assert ( + response_liveness_probe.status_code == 200 + ), response_liveness_probe.content.decode("utf-8") + assert response_liveness_probe["content-type"] == "application/json" + assert json.loads(response_liveness_probe.content.decode("utf-8")) == { + SuccessTwoBackend().identifier(): SuccessTwoBackend().pretty_status(), + } def test_error_param_json(self, client): class JSONErrorBackend(BaseHealthCheckBackend): From b61c4f1e1320af6cf8b8e06edaf6a7874099931c Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 03:45:26 +0700 Subject: [PATCH 09/16] feat: add subset not found should return HTTP 404 --- tests/test_views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_views.py b/tests/test_views.py index 92f4834b..2b861701 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -321,6 +321,10 @@ def run_check(self, subset=None): SuccessTwoBackend().identifier(): SuccessTwoBackend().pretty_status(), } + def test_error_subset_not_found(self, client): + response = client.get(self.url, {"format": "json"}) + assert response.status_code == 404, response.content.decode("utf-8") + def test_error_param_json(self, client): class JSONErrorBackend(BaseHealthCheckBackend): def run_check(self, subset=None): From 99ae3952be249df2a82f3111ec890d81f1d8aeef Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 12:59:44 +0700 Subject: [PATCH 10/16] feat: ensure plugins are sorted by name --- health_check/mixins.py | 1 + 1 file changed, 1 insertion(+) diff --git a/health_check/mixins.py b/health_check/mixins.py index c808a4d2..a498f848 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -29,6 +29,7 @@ def plugins(self): plugin_class(**copy.deepcopy(options)) for plugin_class, options in plugin_dir._registry ) + registering_plugins = sorted(registering_plugins, key=lambda plugin: plugin.identifier()) self._plugins = OrderedDict( {plugin.identifier(): plugin for plugin in registering_plugins} ) From 01364bddb9d26202f0defebb60a90030311f85a4 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Sun, 26 Nov 2023 13:01:11 +0700 Subject: [PATCH 11/16] feat: ensure plugins are sorted by name --- health_check/mixins.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/health_check/mixins.py b/health_check/mixins.py index a498f848..eeb55153 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -29,7 +29,9 @@ def plugins(self): plugin_class(**copy.deepcopy(options)) for plugin_class, options in plugin_dir._registry ) - registering_plugins = sorted(registering_plugins, key=lambda plugin: plugin.identifier()) + registering_plugins = sorted( + registering_plugins, key=lambda plugin: plugin.identifier() + ) self._plugins = OrderedDict( {plugin.identifier(): plugin for plugin in registering_plugins} ) From 1aa13e7ffe3abe0bb6277ec1c03143a420f463b0 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Mon, 27 Nov 2023 15:22:31 +0700 Subject: [PATCH 12/16] feat: adjust health check config structure --- README.md | 11 ++++++++--- health_check/conf.py | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 71d01e26..b95f2b44 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,14 @@ one of these checks, set its value to `None`. To use Health Check Subsets, Specify a subset name and associate it with the relevant health check services to utilize Health Check Subsets. ```python - HEALTH_CHECK_SUBSETS = { - 'startup-probe': ['DatabaseBackend', 'MigrationsHealthCheck'], - 'liveness-probe': ['DatabaseBackend'], + HEALTH_CHECK = { + # ..... + "SUBSETS": { + "startup-probe": ["MigrationsHealthCheck", "DatabaseBackend"], + "liveness-probe": ["DatabaseBackend"], + "": [" Date: Wed, 29 Nov 2023 12:10:08 +0700 Subject: [PATCH 13/16] feat: add more tests --- health_check/conf.py | 2 -- health_check/mixins.py | 10 ++++++---- tests/test_views.py | 8 +++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/health_check/conf.py b/health_check/conf.py index 9eeb8b9e..adb74e17 100644 --- a/health_check/conf.py +++ b/health_check/conf.py @@ -5,5 +5,3 @@ HEALTH_CHECK.setdefault("MEMORY_MIN", 100) HEALTH_CHECK.setdefault("WARNINGS_AS_ERRORS", True) HEALTH_CHECK.setdefault("SUBSETS", {}) - -HEALTH_CHECK_SUBSETS = HEALTH_CHECK.get("SUBSETS") diff --git a/health_check/mixins.py b/health_check/mixins.py index eeb55153..c6a6d704 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -4,7 +4,7 @@ from django.http import Http404 -from health_check.conf import HEALTH_CHECK, HEALTH_CHECK_SUBSETS +from health_check.conf import HEALTH_CHECK from health_check.exceptions import ServiceWarning from health_check.plugins import plugin_dir @@ -24,6 +24,9 @@ def check(self, subset=None): @property def plugins(self): + if not plugin_dir._registry: + return OrderedDict({}) + if not self._plugins: registering_plugins = ( plugin_class(**copy.deepcopy(options)) @@ -41,9 +44,8 @@ def filter_plugins(self, subset=None): if subset is None: return self.plugins - health_check_subsets = HEALTH_CHECK_SUBSETS - - if subset not in health_check_subsets: + health_check_subsets = HEALTH_CHECK['SUBSETS'] + if subset not in health_check_subsets or not self.plugins: raise Http404(f"Specify subset: '{subset}' does not exists.") selected_subset = set(health_check_subsets[subset]) diff --git a/tests/test_views.py b/tests/test_views.py index 2b861701..61047a29 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -292,11 +292,10 @@ def run_check(self, subset=None): plugin_dir.register(SuccessOneBackend) plugin_dir.register(SuccessTwoBackend) - HEALTH_CHECK_SUBSETS = { + HEALTH_CHECK['SUBSETS'] = { "startup-probe": ["SuccessOneBackend", "SuccessTwoBackend"], "liveness-probe": ["SuccessTwoBackend"], } - setattr(conf, "HEALTH_CHECK_SUBSETS", HEALTH_CHECK_SUBSETS) response_startup_probe = client.get( self.url + "startup-probe/", {"format": "json"} @@ -322,7 +321,10 @@ def run_check(self, subset=None): } def test_error_subset_not_found(self, client): - response = client.get(self.url, {"format": "json"}) + plugin_dir.reset() + response = client.get(self.url + "liveness-probe/", {"format": "json"}) + print(f"content: {response.content}") + print(f"code: {response.status_code}") assert response.status_code == 404, response.content.decode("utf-8") def test_error_param_json(self, client): From ad0b1e8b263a150dacc6f77896d8b10789028dcb Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Wed, 29 Nov 2023 12:41:04 +0700 Subject: [PATCH 14/16] feat: add commmand tests --- .../management/commands/health_check.py | 9 +++- tests/test_commands.py | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/health_check/management/commands/health_check.py b/health_check/management/commands/health_check.py index 541ea209..eb36ac88 100644 --- a/health_check/management/commands/health_check.py +++ b/health_check/management/commands/health_check.py @@ -1,6 +1,7 @@ import sys from django.core.management.base import BaseCommand +from django.http import Http404 from health_check.mixins import CheckMixin @@ -9,13 +10,17 @@ class Command(CheckMixin, BaseCommand): help = "Run health checks and exit 0 if everything went well." def add_arguments(self, parser): - parser.add_argument("--subset", type=str, default=None, nargs=1) + parser.add_argument("-s", '--subset', type=str, nargs=1) def handle(self, *args, **options): # perform all checks subset = options.get("subset", []) subset = subset[0] if subset else None - errors = self.check(subset=subset) + try: + errors = self.check(subset=subset) + except Http404 as e: + self.stdout.write(str(e)) + sys.exit(1) for plugin_identifier, plugin in self.filter_plugins(subset=subset).items(): style_func = self.style.SUCCESS if not plugin.errors else self.style.ERROR diff --git a/tests/test_commands.py b/tests/test_commands.py index a4ad47ae..7c7aaf15 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -4,6 +4,7 @@ from django.core.management import call_command from health_check.backends import BaseHealthCheckBackend +from health_check.conf import HEALTH_CHECK from health_check.plugins import plugin_dir @@ -35,3 +36,49 @@ def test_command(self): "FailPlugin ... unknown error: Oops\n" "OkPlugin ... working\n" ) + + def test_command_with_subset(self): + SUBSET_NAME_1 = 'subset-1' + SUBSET_NAME_2 = 'subset-2' + HEALTH_CHECK['SUBSETS'] = { + SUBSET_NAME_1: ["OkPlugin"], + SUBSET_NAME_2: ["OkPlugin", "FailPlugin"] + } + + stdout = StringIO() + call_command(f"health_check", f"--subset={SUBSET_NAME_1}", stdout=stdout) + stdout.seek(0) + assert stdout.read() == ( + "OkPlugin ... working\n" + ) + + + def test_command_with_failed_check_subset(self): + SUBSET_NAME = 'subset-2' + HEALTH_CHECK['SUBSETS'] = { + SUBSET_NAME: ["OkPlugin", "FailPlugin"] + } + + stdout = StringIO() + with pytest.raises(SystemExit): + call_command(f"health_check", f"--subset={SUBSET_NAME}", stdout=stdout) + stdout.seek(0) + assert stdout.read() == ( + "FailPlugin ... unknown error: Oops\n" + "OkPlugin ... working\n" + ) + + def test_command_with_non_existence_subset(self): + SUBSET_NAME = 'subset-2' + NON_EXISTENCE_SUBSET_NAME = "abcdef12" + HEALTH_CHECK['SUBSETS'] = { + SUBSET_NAME: ["OkPlugin"] + } + + stdout = StringIO() + with pytest.raises(SystemExit): + call_command(f"health_check", f"--subset={NON_EXISTENCE_SUBSET_NAME}", stdout=stdout) + stdout.seek(0) + assert stdout.read() == ( + f"Specify subset: '{NON_EXISTENCE_SUBSET_NAME}' does not exists.\n" + ) From 649274160128647fb1e44c663661bd9e3e4a2473 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Wed, 13 Dec 2023 23:50:23 +0700 Subject: [PATCH 15/16] feat: fix merge conflicts --- health_check/mixins.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/health_check/mixins.py b/health_check/mixins.py index ac87939b..e32be97e 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -66,7 +66,7 @@ def _run(plugin): from django.db import connections connections.close_all() - + def _collect_errors(plugin): if plugin.critical_service: if not HEALTH_CHECK["WARNINGS_AS_ERRORS"]: @@ -75,15 +75,16 @@ def _collect_errors(plugin): ) else: errors.extend(plugin.errors) - + plugins = self.filter_plugins(subset=subset) - + plugin_instances = plugins.values() + if HEALTH_CHECK["DISABLE_THREADING"]: - for plugin in plugins: + for plugin in plugin_instances: _run(plugin) _collect_errors(plugin) else: - with ThreadPoolExecutor(max_workers=len(plugins) or 1) as executor: - for plugin in executor.map(_run, plugins): + with ThreadPoolExecutor(max_workers=len(plugin_instances) or 1) as executor: + for plugin in executor.map(_run, plugin_instances): _collect_errors(plugin) return errors From 13efe1a19af34edfd5fa08efe32ea6f778f25687 Mon Sep 17 00:00:00 2001 From: Pan Teparak Date: Wed, 13 Dec 2023 23:51:44 +0700 Subject: [PATCH 16/16] feat: fix merge conflicts --- .../management/commands/health_check.py | 2 +- health_check/mixins.py | 2 +- tests/test_commands.py | 33 ++++++++----------- tests/test_views.py | 3 +- 4 files changed, 17 insertions(+), 23 deletions(-) diff --git a/health_check/management/commands/health_check.py b/health_check/management/commands/health_check.py index eb36ac88..191d50d1 100644 --- a/health_check/management/commands/health_check.py +++ b/health_check/management/commands/health_check.py @@ -10,7 +10,7 @@ class Command(CheckMixin, BaseCommand): help = "Run health checks and exit 0 if everything went well." def add_arguments(self, parser): - parser.add_argument("-s", '--subset', type=str, nargs=1) + parser.add_argument("-s", "--subset", type=str, nargs=1) def handle(self, *args, **options): # perform all checks diff --git a/health_check/mixins.py b/health_check/mixins.py index e32be97e..dd04180c 100644 --- a/health_check/mixins.py +++ b/health_check/mixins.py @@ -44,7 +44,7 @@ def filter_plugins(self, subset=None): if subset is None: return self.plugins - health_check_subsets = HEALTH_CHECK['SUBSETS'] + health_check_subsets = HEALTH_CHECK["SUBSETS"] if subset not in health_check_subsets or not self.plugins: raise Http404(f"Specify subset: '{subset}' does not exists.") diff --git a/tests/test_commands.py b/tests/test_commands.py index 7c7aaf15..da2a7aec 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -38,30 +38,25 @@ def test_command(self): ) def test_command_with_subset(self): - SUBSET_NAME_1 = 'subset-1' - SUBSET_NAME_2 = 'subset-2' - HEALTH_CHECK['SUBSETS'] = { + SUBSET_NAME_1 = "subset-1" + SUBSET_NAME_2 = "subset-2" + HEALTH_CHECK["SUBSETS"] = { SUBSET_NAME_1: ["OkPlugin"], - SUBSET_NAME_2: ["OkPlugin", "FailPlugin"] + SUBSET_NAME_2: ["OkPlugin", "FailPlugin"], } stdout = StringIO() - call_command(f"health_check", f"--subset={SUBSET_NAME_1}", stdout=stdout) + call_command("health_check", f"--subset={SUBSET_NAME_1}", stdout=stdout) stdout.seek(0) - assert stdout.read() == ( - "OkPlugin ... working\n" - ) - + assert stdout.read() == ("OkPlugin ... working\n") def test_command_with_failed_check_subset(self): - SUBSET_NAME = 'subset-2' - HEALTH_CHECK['SUBSETS'] = { - SUBSET_NAME: ["OkPlugin", "FailPlugin"] - } + SUBSET_NAME = "subset-2" + HEALTH_CHECK["SUBSETS"] = {SUBSET_NAME: ["OkPlugin", "FailPlugin"]} stdout = StringIO() with pytest.raises(SystemExit): - call_command(f"health_check", f"--subset={SUBSET_NAME}", stdout=stdout) + call_command("health_check", f"--subset={SUBSET_NAME}", stdout=stdout) stdout.seek(0) assert stdout.read() == ( "FailPlugin ... unknown error: Oops\n" @@ -69,15 +64,15 @@ def test_command_with_failed_check_subset(self): ) def test_command_with_non_existence_subset(self): - SUBSET_NAME = 'subset-2' + SUBSET_NAME = "subset-2" NON_EXISTENCE_SUBSET_NAME = "abcdef12" - HEALTH_CHECK['SUBSETS'] = { - SUBSET_NAME: ["OkPlugin"] - } + HEALTH_CHECK["SUBSETS"] = {SUBSET_NAME: ["OkPlugin"]} stdout = StringIO() with pytest.raises(SystemExit): - call_command(f"health_check", f"--subset={NON_EXISTENCE_SUBSET_NAME}", stdout=stdout) + call_command( + "health_check", f"--subset={NON_EXISTENCE_SUBSET_NAME}", stdout=stdout + ) stdout.seek(0) assert stdout.read() == ( f"Specify subset: '{NON_EXISTENCE_SUBSET_NAME}' does not exists.\n" diff --git a/tests/test_views.py b/tests/test_views.py index 61047a29..d9161081 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -2,7 +2,6 @@ import pytest -from health_check import conf from health_check.backends import BaseHealthCheckBackend from health_check.conf import HEALTH_CHECK from health_check.exceptions import ServiceWarning @@ -292,7 +291,7 @@ def run_check(self, subset=None): plugin_dir.register(SuccessOneBackend) plugin_dir.register(SuccessTwoBackend) - HEALTH_CHECK['SUBSETS'] = { + HEALTH_CHECK["SUBSETS"] = { "startup-probe": ["SuccessOneBackend", "SuccessTwoBackend"], "liveness-probe": ["SuccessTwoBackend"], }