diff --git a/hathor/healthcheck/models.py b/hathor/healthcheck/models.py deleted file mode 100644 index c75457720..000000000 --- a/hathor/healthcheck/models.py +++ /dev/null @@ -1,116 +0,0 @@ -from abc import ABC, abstractmethod -from dataclasses import dataclass -from datetime import datetime -from enum import Enum -from typing import Any, Optional - - -class ComponentType(str, Enum): - """Enum used to store the component types that can be used in the HealthCheckComponentStatus class.""" - - DATASTORE = 'datastore' - INTERNAL = 'internal' - FULLNODE = 'fullnode' - - -class HealthCheckStatus(str, Enum): - """Enum used to store the component status that can be used in the HealthCheckComponentStatus class.""" - - PASS = 'pass' - WARN = 'warn' - FAIL = 'fail' - - -@dataclass -class ComponentHealthCheck: - """This class is used to store the result of a health check in a specific component.""" - - component_name: str - component_type: ComponentType - status: HealthCheckStatus - output: str - time: Optional[str] = None - component_id: Optional[str] = None - observed_value: Optional[str] = None - observed_unit: Optional[str] = None - - def __post_init__(self) -> None: - self.time = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ') - - def to_json(self) -> dict[str, str]: - """Return a dict representation of the object. All field names are converted to camel case.""" - json = { - 'componentType': self.component_type.value, - 'status': self.status.value, - 'output': self.output, - } - - if self.time: - json['time'] = self.time - - if self.component_id: - json['componentId'] = self.component_id - - if self.observed_value: - assert ( - self.observed_unit is not None - ), 'observed_unit must be set if observed_value is set' - - json['observedValue'] = self.observed_value - json['observedUnit'] = self.observed_unit - - return json - - -@dataclass -class ServiceHealthCheck: - """This class is used to store the result of a service health check.""" - - description: str - checks: dict[str, list[ComponentHealthCheck]] - - @property - def status(self) -> HealthCheckStatus: - """Return the status of the health check based on the status of the components.""" - status = HealthCheckStatus.PASS - - for component_checks in self.checks.values(): - for check in component_checks: - if check.status == HealthCheckStatus.FAIL: - return HealthCheckStatus.FAIL - elif check.status == HealthCheckStatus.WARN: - status = HealthCheckStatus.WARN - - return status - - def __post_init__(self) -> None: - """Perform some validations after the object is initialized.""" - # Make sure the checks dict is not empty - if not self.checks: - raise ValueError('checks dict cannot be empty') - - def get_http_status_code(self) -> int: - """Return the HTTP status code for the status.""" - if self.status in [HealthCheckStatus.PASS]: - return 200 - elif self.status in [HealthCheckStatus.WARN, HealthCheckStatus.FAIL]: - return 503 - else: - raise ValueError(f'Missing treatment for status {self.status}') - - def to_json(self) -> dict[str, Any]: - """Return a dict representation of the object. All field names are converted to camel case.""" - return { - 'status': self.status.value, - 'description': self.description, - 'checks': {k: [c.to_json() for c in v] for k, v in self.checks.items()}, - } - - -class ComponentHealthCheckInterface(ABC): - """This is an interface to be used by other classes implementing health checks for components.""" - - @abstractmethod - async def get_health_check(self) -> ComponentHealthCheck: - """Return the health check status for the component.""" - raise NotImplementedError() diff --git a/hathor/healthcheck/resources/healthcheck.py b/hathor/healthcheck/resources/healthcheck.py index 2cdc29cd9..5e9afcb9f 100644 --- a/hathor/healthcheck/resources/healthcheck.py +++ b/hathor/healthcheck/resources/healthcheck.py @@ -1,19 +1,18 @@ -import hathor +import asyncio + +from healthcheck import Healthcheck, HealthcheckCallbackResponse, HealthcheckInternalComponent, HealthcheckStatus + from hathor.api_util import Resource, get_arg_default, get_args from hathor.cli.openapi_files.register import register_resource -from hathor.healthcheck.models import ComponentHealthCheck, ComponentType, HealthCheckStatus, ServiceHealthCheck from hathor.manager import HathorManager from hathor.util import json_dumpb -def build_sync_health_status(manager: HathorManager) -> ComponentHealthCheck: - """Builds the sync health status object.""" +async def sync_healthcheck(manager: HathorManager) -> HealthcheckCallbackResponse: healthy, reason = manager.is_sync_healthy() - return ComponentHealthCheck( - component_name='sync', - component_type=ComponentType.INTERNAL, - status=HealthCheckStatus.PASS if healthy else HealthCheckStatus.FAIL, + return HealthcheckCallbackResponse( + status=HealthcheckStatus.PASS if healthy else HealthcheckStatus.FAIL, output=reason or 'Healthy', ) @@ -38,22 +37,21 @@ def render_GET(self, request): raw_args = get_args(request) strict_status_code = get_arg_default(raw_args, 'strict_status_code', '0') == '1' - components_health_checks = [ - build_sync_health_status(self.manager) - ] - - health_check = ServiceHealthCheck( - description=f'Hathor-core {hathor.__version__}', - checks={c.component_name: [c] for c in components_health_checks}, + sync_component = HealthcheckInternalComponent( + name='sync', ) + sync_component.add_healthcheck(lambda: sync_healthcheck(self.manager)) + + healthcheck = Healthcheck(name='hathor-core', components=[sync_component]) + status = asyncio.get_event_loop().run_until_complete(healthcheck.run()) if strict_status_code: request.setResponseCode(200) else: - status_code = health_check.get_http_status_code() + status_code = status.get_http_status_code() request.setResponseCode(status_code) - return json_dumpb(health_check.to_json()) + return json_dumpb(status.to_json()) HealthcheckResource.openapi = { diff --git a/poetry.lock b/poetry.lock index e327ad4fb..53b74c73a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1374,6 +1374,17 @@ psutil = ["psutil (>=3.0)"] setproctitle = ["setproctitle"] testing = ["filelock"] +[[package]] +name = "python-healthchecklib" +version = "0.1.0" +description = "Opinionated healthcheck library" +optional = false +python-versions = ">=3.8.1,<4.0.0" +files = [ + {file = "python_healthchecklib-0.1.0-py3-none-any.whl", hash = "sha256:95d94fcae7f281adf16624014ae789dfa38d1be327cc38b02ee82bad70671f2f"}, + {file = "python_healthchecklib-0.1.0.tar.gz", hash = "sha256:afa0572d37902c50232d99acf0065836082bb027109c9c98e8d5acfefd381595"}, +] + [[package]] name = "pywin32" version = "305" @@ -2135,4 +2146,4 @@ sentry = ["sentry-sdk", "structlog-sentry"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4" -content-hash = "1a2830d269a9d5a6fe449b5e884438b5f17a5dacd89110b7ada5af2026c4ab97" +content-hash = "2b20a90cf75e75bd32568e722489db53b4a4b490f4e3f084ff5734ea8137c37e" diff --git a/pyproject.toml b/pyproject.toml index f6b8e838f..f40746b0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ hathorlib = "0.3.0" pydantic = "~1.10.13" pyyaml = "^6.0.1" typing-extensions = "~4.8.0" +python-healthchecklib = "^0.1.0" [tool.poetry.extras] sentry = ["sentry-sdk", "structlog-sentry"] diff --git a/tests/resources/healthcheck/test_healthcheck.py b/tests/resources/healthcheck/test_healthcheck.py index 888aac2af..e40fb2a76 100644 --- a/tests/resources/healthcheck/test_healthcheck.py +++ b/tests/resources/healthcheck/test_healthcheck.py @@ -31,6 +31,7 @@ def test_get_no_recent_activity(self): 'checks': { 'sync': [{ 'componentType': 'internal', + 'componentName': 'sync', 'status': 'fail', 'output': HathorManager.UnhealthinessReason.NO_RECENT_ACTIVITY, 'time': ANY @@ -53,6 +54,7 @@ def test_strict_status_code(self): 'checks': { 'sync': [{ 'componentType': 'internal', + 'componentName': 'sync', 'status': 'fail', 'output': HathorManager.UnhealthinessReason.NO_RECENT_ACTIVITY, 'time': ANY @@ -79,6 +81,7 @@ def test_get_no_connected_peer(self): 'checks': { 'sync': [{ 'componentType': 'internal', + 'componentName': 'sync', 'status': 'fail', 'output': HathorManager.UnhealthinessReason.NO_SYNCED_PEER, 'time': ANY @@ -111,6 +114,7 @@ def test_get_peer_out_of_sync(self): 'checks': { 'sync': [{ 'componentType': 'internal', + 'componentName': 'sync', 'status': 'fail', 'output': HathorManager.UnhealthinessReason.NO_SYNCED_PEER, 'time': ANY @@ -143,6 +147,7 @@ def test_get_ready(self): 'checks': { 'sync': [{ 'componentType': 'internal', + 'componentName': 'sync', 'status': 'pass', 'output': 'Healthy', 'time': ANY