Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to define and run a subset health checks #390

Merged
merged 17 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ 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": ["MigrationsHealthCheck", "DatabaseBackend"],
"liveness-probe": ["DatabaseBackend"],
"<SUBSET_NAME>": ["<Health_Check_Service_Name"]
},
# .....
}
```

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
Expand Down
6 changes: 3 additions & 3 deletions health_check/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion health_check/cache/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions health_check/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
HEALTH_CHECK.setdefault("DISK_USAGE_MAX", 90)
HEALTH_CHECK.setdefault("MEMORY_MIN", 100)
HEALTH_CHECK.setdefault("WARNINGS_AS_ERRORS", True)
HEALTH_CHECK.setdefault("SUBSETS", {})
HEALTH_CHECK.setdefault("DISABLE_THREADING", False)
2 changes: 1 addition & 1 deletion health_check/contrib/celery/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion health_check/contrib/celery_ping/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion health_check/contrib/migrations/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
4 changes: 2 additions & 2 deletions health_check/contrib/psutil/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion health_check/contrib/rabbitmq/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...")

Expand Down
2 changes: 1 addition & 1 deletion health_check/contrib/redis/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion health_check/db/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 13 additions & 3 deletions health_check/management/commands/health_check.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
import sys

from django.core.management.base import BaseCommand
from django.http import Http404

from health_check.mixins import CheckMixin


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)

def handle(self, *args, **options):
# perform all checks
errors = self.errors
subset = options.get("subset", [])
subset = subset[0] if subset else None
try:
errors = self.check(subset=subset)
except Http404 as e:
self.stdout.write(str(e))
sys.exit(1)

for plugin in self.plugins:
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(
plugin.identifier(), style_func(plugin.pretty_status())
plugin_identifier, style_func(plugin.pretty_status())
)
)

Expand Down
54 changes: 41 additions & 13 deletions health_check/mixins.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import copy
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor

from django.http import Http404

from health_check.conf import HEALTH_CHECK
from health_check.exceptions import ServiceWarning
from health_check.plugins import plugin_dir
Expand All @@ -16,19 +19,43 @@ def errors(self):
self._errors = self.run_check()
return self._errors

def check(self, subset=None):
return self.run_check(subset=subset)

@property
def plugins(self):
if not plugin_dir._registry:
return OrderedDict({})

if not self._plugins:
self._plugins = sorted(
(
plugin_class(**copy.deepcopy(options))
for plugin_class, options in plugin_dir._registry
),
key=lambda plugin: plugin.identifier(),
registering_plugins = (
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}
)
return self._plugins

def run_check(self):
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 or not self.plugins:
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):
Expand All @@ -39,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"]:
Expand All @@ -48,14 +75,15 @@ def _collect_errors(plugin):
)
else:
errors.extend(plugin.errors)


plugins = self.filter_plugins(subset=subset)

if HEALTH_CHECK["DISABLE_THREADING"]:
for plugin in self.plugins:
for plugin in plugins:
_run(plugin)
_collect_errors(plugin)
else:
with ThreadPoolExecutor(max_workers=len(self.plugins) or 1) as executor:
for plugin in executor.map(_run, self.plugins):
with ThreadPoolExecutor(max_workers=len(plugins) or 1) as executor:
for plugin in executor.map(_run, plugins):
_collect_errors(plugin)

return errors
2 changes: 1 addition & 1 deletion health_check/storage/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions health_check/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@

urlpatterns = [
path("", MainView.as_view(), name="health_check_home"),
path("<str:subset>/", MainView.as_view(), name="health_check_subset"),
]
24 changes: 18 additions & 6 deletions health_check/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,15 @@ 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):
Expand All @@ -106,18 +109,27 @@ 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,
content_type="text/plain",
)

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,
)
51 changes: 49 additions & 2 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
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


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


Expand All @@ -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"
)
Loading
Loading