From 04dcffa7d29d24e463a34a48056f3b4a6a79fd5b Mon Sep 17 00:00:00 2001 From: Edward <9326037+edwardoliveira@users.noreply.github.com> Date: Tue, 16 Sep 2025 19:37:33 -0300 Subject: [PATCH] Health and Ready endpoints (#3788) feat: Adiciona health check endpoints Co-authored-by: Edward <9326037+edwardoliveira@users.noreply.github.com> --- sapl/api/urls.py | 7 +- sapl/api/views.py | 15 +--- sapl/api/views_health.py | 106 ++++++++++++++++++++++++ sapl/endpoint_restriction_middleware.py | 2 +- sapl/health.py | 36 ++++++++ sapl/metrics.py | 12 +++ sapl/settings.py | 2 +- sapl/urls.py | 8 ++ 8 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 sapl/api/views_health.py create mode 100644 sapl/health.py create mode 100644 sapl/metrics.py diff --git a/sapl/api/urls.py b/sapl/api/urls.py index a51ea2a80..1ab636707 100644 --- a/sapl/api/urls.py +++ b/sapl/api/urls.py @@ -1,15 +1,13 @@ - from django.conf.urls import include, url from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, \ SpectacularRedocView from rest_framework.authtoken.views import obtain_auth_token from sapl.api.deprecated import SessaoPlenariaViewSet -from sapl.api.views import AppVersionView, recria_token,\ - SaplApiViewSetConstrutor +from sapl.api.views import recria_token, SaplApiViewSetConstrutor from .apps import AppConfig - +from .views_health import HealthzView, ReadyzView app_name = AppConfig.name @@ -38,7 +36,6 @@ urlpatterns = [ url(r'^api/', include(urlpatterns_api_doc)), url(r'^api/', include(urlpatterns_router)), - url(r'^api/version', AppVersionView.as_view()), url(r'^api/auth/token$', obtain_auth_token), url(r'^api/recriar-token/(?P\d*)$', recria_token, name="recria_token"), ] diff --git a/sapl/api/views.py b/sapl/api/views.py index a9c0686e8..fac2eeeeb 100644 --- a/sapl/api/views.py +++ b/sapl/api/views.py @@ -1,6 +1,7 @@ import logging from django.conf import settings +from django.http import HttpResponse, JsonResponse from rest_framework.authtoken.models import Token from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated, IsAdminUser @@ -21,20 +22,6 @@ def recria_token(request, pk): return Response({"message": "Token recriado com sucesso!", "token": token.key}) -class AppVersionView(APIView): - permission_classes = (IsAuthenticated,) - - def get(self, request): - content = { - 'name': 'SAPL', - 'description': 'Sistema de Apoio ao Processo Legislativo', - 'version': settings.SAPL_VERSION, - 'user': request.user.username, - 'is_authenticated': request.user.is_authenticated, - } - return Response(content) - - SaplApiViewSetConstrutor = ApiViewSetConstrutor SaplApiViewSetConstrutor.import_modules([ 'sapl.api.views_audiencia', diff --git a/sapl/api/views_health.py b/sapl/api/views_health.py new file mode 100644 index 000000000..bd7e6eaad --- /dev/null +++ b/sapl/api/views_health.py @@ -0,0 +1,106 @@ +import logging +import traceback + +from django.http import HttpResponse, JsonResponse +from django.utils import timezone +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from django.conf import settings +from sapl.health import check_app, check_db, check_cache + +COMMON_HEADERS = { + "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", + "Pragma": "no-cache", +} + + +def _format_plain(ok: bool) -> HttpResponse: + return HttpResponse("OK\n" if ok else "UNHEALTHY\n", + status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE, + content_type="text/plain") + + +class HealthzView(APIView): + authentication_classes = [] + permission_classes = [] + + logger = logging.getLogger(__name__) + + def get(self, request): + try: + ok, msg, ms = check_app() + payload = { + "status": "OK" if ok else "UNHEALTHY", + "checks": {"app": {"ok": ok, "latency_ms": round(ms, 1), "error": msg}}, + "version": settings.SAPL_VERSION, + "time": timezone.now().isoformat(), + } + if request.query_params.get("fmt") == "txt": + return _format_plain(ok) + return Response(payload, + status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE, + headers=COMMON_HEADERS) + except Exception as e: + self.logger.error(traceback.format_exc()) + return "An internal error has occurred!" + + + +class ReadyzView(APIView): + authentication_classes = [] + permission_classes = [] + + logger = logging.getLogger(__name__) + + def get(self, request): + try: + checks = { + "app": check_app(), + "db": check_db(), + "cache": check_cache(), + } + payload_checks = { + name: {"ok": r[0], "latency_ms": round(r[2], 1), "error": r[1]} + for name, r in checks.items() + } + ok = all(r[0] for r in checks.values()) + payload = { + "status": "ok" if ok else "unhealthy", + "checks": payload_checks, + "version": settings.SAPL_VERSION, + "time": timezone.now().isoformat(), + } + if request.query_params.get("fmt") == "txt": + return _format_plain(ok) + return Response(payload, + status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE, + headers=COMMON_HEADERS) + except Exception as e: + self.logger.error(traceback.format_exc()) + return "An internal error has occurred!" + + +class AppzVersionView(APIView): + authentication_classes = [] + permission_classes = [] + + logger = logging.getLogger(__name__) + + def get(self, request): + try: + payload = { + 'name': 'SAPL', + 'description': 'Sistema de Apoio ao Processo Legislativo', + 'version': settings.SAPL_VERSION, + } + if request.query_params.get("fmt") == "txt": + return HttpResponse(f"{payload['version']} {payload['name']}", + status=status.HTTP_200_OK, + content_type="text/plain") + return Response(payload, + status=status.HTTP_200_OK, + headers=COMMON_HEADERS) + except Exception as e: + self.logger.error(traceback.format_exc()) + return "An internal error has occurred!" diff --git a/sapl/endpoint_restriction_middleware.py b/sapl/endpoint_restriction_middleware.py index b815b4a2e..ac892ca6f 100644 --- a/sapl/endpoint_restriction_middleware.py +++ b/sapl/endpoint_restriction_middleware.py @@ -18,7 +18,7 @@ ALLOWED_IPS = [ 'ff00::/8' ] -RESTRICTED_ENDPOINTS = ['/metrics'] +RESTRICTED_ENDPOINTS = ['/metrics', '/health', '/ready', '/version'] class EndpointRestrictionMiddleware: diff --git a/sapl/health.py b/sapl/health.py new file mode 100644 index 000000000..fca19ad5c --- /dev/null +++ b/sapl/health.py @@ -0,0 +1,36 @@ +# core/health.py +import logging +import time +from typing import Tuple, Optional +from django.db import connection +from django.core.cache import cache + +logger = logging.getLogger(__name__) + + +def check_app() -> Tuple[bool, Optional[str], float]: + t0 = time.monotonic() + return True, None, (time.monotonic() - t0) * 1000 + + +def check_db() -> Tuple[bool, Optional[str], float]: + t0 = time.monotonic() + try: + with connection.cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + return True, None, (time.monotonic() - t0) * 1000 + except Exception as e: + logging.error(e) + return False, "An internal error has occurred!", (time.monotonic() - t0) * 1000 + + +def check_cache() -> Tuple[bool, Optional[str], float]: + t0 = time.monotonic() + try: + cache.set("_hc", "1", 5) + ok = cache.get("_hc") == "1" + return ok, None if ok else "Cache get/set failed", (time.monotonic() - t0) * 1000 + except Exception as e: + logging.error(e) + return False, "An internal error has occurred!", (time.monotonic() - t0) * 1000 diff --git a/sapl/metrics.py b/sapl/metrics.py new file mode 100644 index 000000000..02081b2ac --- /dev/null +++ b/sapl/metrics.py @@ -0,0 +1,12 @@ +from prometheus_client import CollectorRegistry, Gauge, generate_latest, CONTENT_TYPE_LATEST + + +def health_registry(check_results: dict) -> bytes: + """ + check_results: {"app": True/False, "db": True/False, ...} + """ + reg = CollectorRegistry() + g = Gauge("app_health", "1 if healthy, 0 if unhealthy", ["component"], registry=reg) + for comp, ok in check_results.items(): + g.labels(component=comp).set(1 if ok else 0) + return generate_latest(reg) diff --git a/sapl/settings.py b/sapl/settings.py index 623f857e6..bd85ae7c0 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -149,9 +149,9 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', - 'django_prometheus.middleware.PrometheusAfterMiddleware', 'waffle.middleware.WaffleMiddleware', 'sapl.middleware.CheckWeakPasswordMiddleware', + 'django_prometheus.middleware.PrometheusAfterMiddleware', ] if DEBUG: INSTALLED_APPS += ('debug_toolbar',) diff --git a/sapl/urls.py b/sapl/urls.py index 0481a246b..4943c7ca5 100644 --- a/sapl/urls.py +++ b/sapl/urls.py @@ -36,6 +36,8 @@ import sapl.redireciona_urls.urls import sapl.relatorios.urls import sapl.sessao.urls +from sapl.api.views_health import AppzVersionView, HealthzView, ReadyzView + urlpatterns = [] urlpatterns += [ @@ -69,6 +71,12 @@ urlpatterns += [ path("robots.txt", TemplateView.as_view( template_name="robots.txt", content_type="text/plain")), + # Health and Readiness + url(r'^version/$', AppzVersionView.as_view(), name="version"), + url(r"^health/$", HealthzView.as_view(), name="health"), + url(r"^ready/$", ReadyzView.as_view(), name="ready"), + + # Monitoring path(r'', include('django_prometheus.urls')), ]