From 1b61689eb8289fafa3e7a1c13b7b2576b133fd5c Mon Sep 17 00:00:00 2001 From: Stefan Kairinos Date: Fri, 15 Nov 2024 12:41:49 +0000 Subject: [PATCH] feat: Contributor backend 31 (#146) * feat: health check * fix: health check path * Reusable commands --- codeforlife/commands/__init__.py | 7 + codeforlife/commands/load_fixtures.py | 40 ++++++ codeforlife/commands/summarize_fixtures.py | 86 ++++++++++++ codeforlife/settings/custom.py | 4 + codeforlife/urls/patterns.py | 10 +- .../user/management/commands/load_fixtures.py | 33 +---- .../management/commands/summarize_fixtures.py | 79 +---------- codeforlife/views/__init__.py | 1 + codeforlife/views/health_check.py | 126 ++++++++++++++++++ 9 files changed, 279 insertions(+), 107 deletions(-) create mode 100644 codeforlife/commands/__init__.py create mode 100644 codeforlife/commands/load_fixtures.py create mode 100644 codeforlife/commands/summarize_fixtures.py create mode 100644 codeforlife/views/health_check.py diff --git a/codeforlife/commands/__init__.py b/codeforlife/commands/__init__.py new file mode 100644 index 00000000..a12a1799 --- /dev/null +++ b/codeforlife/commands/__init__.py @@ -0,0 +1,7 @@ +""" +© Ocado Group +Created on 15/11/2024 at 12:18:24(+00:00). +""" + +from .load_fixtures import LoadFixtures +from .summarize_fixtures import SummarizeFixtures diff --git a/codeforlife/commands/load_fixtures.py b/codeforlife/commands/load_fixtures.py new file mode 100644 index 00000000..76f60adc --- /dev/null +++ b/codeforlife/commands/load_fixtures.py @@ -0,0 +1,40 @@ +""" +© Ocado Group +Created on 10/06/2024 at 10:44:45(+01:00). +""" + +import os +import typing as t + +from django.apps import apps +from django.core.management import call_command +from django.core.management.base import BaseCommand + + +# pylint: disable-next=missing-class-docstring +class LoadFixtures(BaseCommand): + help = "Loads all the fixtures of the specified apps." + + required_app_labels: t.Set[str] = set() + + def add_arguments(self, parser): + parser.add_argument("app_labels", nargs="*", type=str) + + def handle(self, *args, **options): + fixture_labels: t.List[str] = [] + for app_label in {*options["app_labels"], *self.required_app_labels}: + app_config = apps.app_configs[app_label] + fixtures_path = os.path.join(app_config.path, "fixtures") + + self.stdout.write(f"{app_label} fixtures ({fixtures_path}):") + for fixture_label in os.listdir(fixtures_path): + if fixture_label in fixture_labels: + self.stderr.write(f"Duplicate fixture: {fixture_label}") + return + + self.stdout.write(f" - {fixture_label}") + fixture_labels.append(fixture_label) + + self.stdout.write() + + call_command("loaddata", *fixture_labels) diff --git a/codeforlife/commands/summarize_fixtures.py b/codeforlife/commands/summarize_fixtures.py new file mode 100644 index 00000000..2a66d742 --- /dev/null +++ b/codeforlife/commands/summarize_fixtures.py @@ -0,0 +1,86 @@ +""" +© Ocado Group +Created on 22/02/2024 at 09:24:27(+00:00). +""" + +import json +import os +import typing as t +from dataclasses import dataclass +from itertools import groupby + +from django.apps import apps +from django.core.management.base import BaseCommand + +from ..types import JsonDict + + +@dataclass(frozen=True) +class Fixture: + """A data model fixture.""" + + model: str + pk: t.Any + fields: JsonDict + + +FixtureDict = t.Dict[str, t.List[Fixture]] + + +# pylint: disable-next=missing-class-docstring +class SummarizeFixtures(BaseCommand): + help = "Summarizes all the listed fixtures." + + required_app_labels: t.Set[str] = set() + + def add_arguments(self, parser): + parser.add_argument("app_labels", nargs="*", type=str) + + def _write_pks_per_model(self, fixtures: t.List[Fixture], indents: int = 0): + def get_model(fixture: Fixture): + return fixture.model.lower() + + fixtures.sort(key=get_model) + + self.stdout.write(f'{" " * indents}Primary keys per model:') + + for model, group in groupby(fixtures, key=get_model): + pks = [fixture.pk for fixture in group] + pks.sort() + + self.stdout.write(f'{" " * (indents + 1)}- {model}: {pks}') + + def write_pks_per_model(self, fixtures: FixtureDict): + """Write all the sorted primary keys per model.""" + self._write_pks_per_model( + [ + fixture + for file_fixtures in fixtures.values() + for fixture in file_fixtures + ] + ) + + def write_pks_per_file(self, fixtures: FixtureDict): + """Write all the sorted primary keys per file, per model.""" + self.stdout.write("Primary keys per file:") + + for file, file_fixtures in fixtures.items(): + self.stdout.write(f" - {file}") + self._write_pks_per_model(file_fixtures, indents=2) + + def handle(self, *args, **options): + fixtures: FixtureDict = {} + for app_label in {*options["app_labels"], *self.required_app_labels}: + app_config = apps.app_configs[app_label] + fixtures_path = os.path.join(app_config.path, "fixtures") + + for fixture_name in os.listdir(fixtures_path): + fixture_path = os.path.join(fixtures_path, fixture_name) + with open(fixture_path, "r", encoding="utf-8") as fixture: + fixtures[fixture_path] = [ + Fixture(**fixture) for fixture in json.load(fixture) + ] + + self.write_pks_per_model(fixtures) + self.stdout.write() + self.write_pks_per_file(fixtures) diff --git a/codeforlife/settings/custom.py b/codeforlife/settings/custom.py index f90ac722..1a250c59 100644 --- a/codeforlife/settings/custom.py +++ b/codeforlife/settings/custom.py @@ -40,3 +40,7 @@ # The name of the session metadata cookie. SESSION_METADATA_COOKIE_NAME = "session_metadata" + +# App deployment details. +APP_ID = os.getenv("APP_ID", "REPLACE_ME") +APP_VERSION = os.getenv("APP_VERSION", "REPLACE_ME") diff --git a/codeforlife/urls/patterns.py b/codeforlife/urls/patterns.py index bfcdc94c..f407ae6e 100644 --- a/codeforlife/urls/patterns.py +++ b/codeforlife/urls/patterns.py @@ -11,7 +11,7 @@ from rest_framework import status from ..settings import SERVICE_IS_ROOT, SERVICE_NAME -from ..views import CsrfCookieView, LogoutView +from ..views import CsrfCookieView, HealthCheckView, LogoutView UrlPatterns = t.List[t.Union[URLResolver, URLPattern]] @@ -75,10 +75,18 @@ def get_urlpatterns( ) ) + health_check_path = path( + "health-check/", + HealthCheckView.as_view(), + name="health-check", + ) + if SERVICE_IS_ROOT: + urlpatterns.append(health_check_path) return urlpatterns return [ + health_check_path, path( f"{SERVICE_NAME}/", include(urlpatterns), diff --git a/codeforlife/user/management/commands/load_fixtures.py b/codeforlife/user/management/commands/load_fixtures.py index 2346c933..cded5336 100644 --- a/codeforlife/user/management/commands/load_fixtures.py +++ b/codeforlife/user/management/commands/load_fixtures.py @@ -3,36 +3,9 @@ Created on 10/06/2024 at 10:44:45(+01:00). """ -import os -import typing as t - -from django.apps import apps -from django.core.management import call_command -from django.core.management.base import BaseCommand +from ....commands import LoadFixtures # pylint: disable-next=missing-class-docstring -class Command(BaseCommand): - help = "Loads all the fixtures of the specified apps." - - def add_arguments(self, parser): - parser.add_argument("app_labels", nargs="*", type=str) - - def handle(self, *args, **options): - fixture_labels: t.List[str] = [] - for app_label in {*options["app_labels"], "user"}: - app_config = apps.app_configs[app_label] - fixtures_path = os.path.join(app_config.path, "fixtures") - - self.stdout.write(f"{app_label} fixtures ({fixtures_path}):") - for fixture_label in os.listdir(fixtures_path): - if fixture_label in fixture_labels: - self.stderr.write(f"Duplicate fixture: {fixture_label}") - return - - self.stdout.write(f" - {fixture_label}") - fixture_labels.append(fixture_label) - - self.stdout.write() - - call_command("loaddata", *fixture_labels) +class Command(LoadFixtures): + required_app_labels = {"user"} diff --git a/codeforlife/user/management/commands/summarize_fixtures.py b/codeforlife/user/management/commands/summarize_fixtures.py index 48f8a38e..400380dc 100644 --- a/codeforlife/user/management/commands/summarize_fixtures.py +++ b/codeforlife/user/management/commands/summarize_fixtures.py @@ -3,82 +3,9 @@ Created on 22/02/2024 at 09:24:27(+00:00). """ -import json -import os -import typing as t -from dataclasses import dataclass -from itertools import groupby - -from django.apps import apps -from django.core.management.base import BaseCommand - -from ....types import JsonDict - - -@dataclass(frozen=True) -class Fixture: - """A data model fixture.""" - - model: str - pk: t.Any - fields: JsonDict - - -FixtureDict = t.Dict[str, t.List[Fixture]] +from ....commands import SummarizeFixtures # pylint: disable-next=missing-class-docstring -class Command(BaseCommand): - help = "Summarizes all the listed fixtures." - - def add_arguments(self, parser): - parser.add_argument("app_labels", nargs="*", type=str) - - def _write_pks_per_model(self, fixtures: t.List[Fixture], indents: int = 0): - def get_model(fixture: Fixture): - return fixture.model.lower() - - fixtures.sort(key=get_model) - - self.stdout.write(f'{" " * indents}Primary keys per model:') - - for model, group in groupby(fixtures, key=get_model): - pks = [fixture.pk for fixture in group] - pks.sort() - - self.stdout.write(f'{" " * (indents + 1)}- {model}: {pks}') - - def write_pks_per_model(self, fixtures: FixtureDict): - """Write all the sorted primary keys per model.""" - self._write_pks_per_model( - [ - fixture - for file_fixtures in fixtures.values() - for fixture in file_fixtures - ] - ) - - def write_pks_per_file(self, fixtures: FixtureDict): - """Write all the sorted primary keys per file, per model.""" - self.stdout.write("Primary keys per file:") - - for file, file_fixtures in fixtures.items(): - self.stdout.write(f" - {file}") - self._write_pks_per_model(file_fixtures, indents=2) - - def handle(self, *args, **options): - fixtures: FixtureDict = {} - for app_label in {*options["app_labels"], "user"}: - app_config = apps.app_configs[app_label] - fixtures_path = os.path.join(app_config.path, "fixtures") - - for fixture_name in os.listdir(fixtures_path): - fixture_path = os.path.join(fixtures_path, fixture_name) - with open(fixture_path, "r", encoding="utf-8") as fixture: - fixtures[fixture_path] = [ - Fixture(**fixture) for fixture in json.load(fixture) - ] - - self.write_pks_per_model(fixtures) - self.stdout.write() - self.write_pks_per_file(fixtures) +class Command(SummarizeFixtures): + required_app_labels = {"user"} diff --git a/codeforlife/views/__init__.py b/codeforlife/views/__init__.py index 3abde6bf..3df02f92 100644 --- a/codeforlife/views/__init__.py +++ b/codeforlife/views/__init__.py @@ -7,4 +7,5 @@ from .base_login import BaseLoginView from .common import CsrfCookieView, LogoutView from .decorators import action, cron_job +from .health_check import HealthCheckView from .model import BaseModelViewSet, ModelViewSet diff --git a/codeforlife/views/health_check.py b/codeforlife/views/health_check.py new file mode 100644 index 00000000..98a52645 --- /dev/null +++ b/codeforlife/views/health_check.py @@ -0,0 +1,126 @@ +""" +© Ocado Group +Created on 14/11/2024 at 16:31:56(+00:00). +""" + +import typing as t +from dataclasses import dataclass +from datetime import datetime + +from django.apps import apps +from django.conf import settings +from django.contrib.sites.models import Site +from django.views.decorators.cache import cache_page +from rest_framework import status +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from ..permissions import AllowAny + +HealthStatus = t.Literal[ + "healthy", + "startingUp", + "shuttingDown", + "unhealthy", + "unknown", +] + + +@dataclass(frozen=True) +class HealthCheck: + """The health of the current service.""" + + @dataclass(frozen=True) + class Detail: + """A health detail.""" + + name: str + description: str + health: HealthStatus + + health_status: HealthStatus + additional_info: str + details: t.Optional[t.List[Detail]] = None + + +class HealthCheckView(APIView): + """A view for load balancers to determine whether the app is healthy.""" + + http_method_names = ["get"] + permission_classes = [AllowAny] + startup_timestamp = datetime.now().isoformat() + cache_timeout: float = 30 + + def get_health_check(self, request: Request) -> HealthCheck: + """Check the health of the current service.""" + try: + if not apps.ready or not apps.apps_ready or not apps.models_ready: + return HealthCheck( + health_status="startingUp", + additional_info="Apps not ready.", + ) + + host = request.get_host() + if not Site.objects.filter(domain=host).exists(): + return HealthCheck( + health_status="unhealthy", + additional_info=f'Site "{host}" does not exist.', + ) + + return HealthCheck( + health_status="healthy", + additional_info="All healthy.", + ) + # pylint: disable-next=broad-exception-caught + except Exception as ex: + return HealthCheck( + health_status="unknown", + additional_info=str(ex), + ) + + def get(self, request: Request): + """Return a health check for the current service.""" + health_check = self.get_health_check(request) + + return Response( + data={ + "appId": settings.APP_ID, + "healthStatus": health_check.health_status, + "lastCheckedTimestamp": datetime.now().isoformat(), + "additionalInformation": health_check.additional_info, + "startupTimestamp": self.startup_timestamp, + "appVersion": settings.APP_VERSION, + "details": [ + { + "name": detail.name, + "description": detail.description, + "health": detail.health, + } + for detail in (health_check.details or []) + ], + }, + status={ + # The app is running normally. + "healthy": status.HTTP_200_OK, + # The app is performing app-specific initialisation which must + # complete before it will serve normal application requests + # (perhaps the app is warming a cache or something similar). You + # only need to use this status if your app will be in a start-up + # mode for a prolonged period of time. + "startingUp": status.HTTP_503_SERVICE_UNAVAILABLE, + # The app is shutting down. As with startingUp, you only need to + # use this status if your app takes a prolonged amount of time + # to shutdown, perhaps because it waits for a long-running + # process to complete before shutting down. + "shuttingDown": status.HTTP_503_SERVICE_UNAVAILABLE, + # The app is not running normally. + "unhealthy": status.HTTP_503_SERVICE_UNAVAILABLE, + # The app is not able to report its own state. + "unknown": status.HTTP_503_SERVICE_UNAVAILABLE, + }[health_check.health_status], + ) + + @classmethod + def as_view(cls, **initkwargs): + return cache_page(cls.cache_timeout)(super().as_view(**initkwargs))