diff --git a/CHANGES.md b/CHANGES.md index 2a6ca3229..6902a4f67 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,11 @@ +3.1.164-RC3 / 2025-09-16 +======================== + + * Health and Ready endpoints (#3788) + * Fix read-only mount on k8s + * Remove setup.py do projeto SAPL + 3.1.164-RC2 / 2025-09-08 ======================== diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 21eb5915f..20c75374a 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -33,11 +33,11 @@ services: networks: - sapl-net sapl: -# image: eribeiro/sapl:debug-k8s - build: - context: ../ - dockerfile: ./docker/Dockerfile - container_name: sapl + image: interlegis/sapl:3.1.164-RC3 +# build: +# context: ../ +# dockerfile: ./docker/Dockerfile +# container_name: sapl labels: NAME: "sapl" restart: always diff --git a/docker/k8s/sapl-deploy.sh b/docker/k8s/sapl-deploy.sh new file mode 100755 index 000000000..0133704b8 --- /dev/null +++ b/docker/k8s/sapl-deploy.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +kubectl create namespace sapl +mkdir -p ./sapl-secret-data +kubectl -n sapl create secret generic sapl-secretkey --from-file=./sapl-secret-data/ +kubectl apply -f sapl-k8s.yaml + +kubectl rollout status deployment/sapl -n sapl + +POD=$(kubectl get pod -n sapl -l app=sapl -o jsonpath='{.items[0].metadata.name}') +kubectl exec -n sapl "$POD" -- ls -l /var/interlegis/sapl/data + diff --git a/docker/k8s/sapl-k8s.yaml b/docker/k8s/sapl-k8s.yaml new file mode 100644 index 000000000..8a45e015e --- /dev/null +++ b/docker/k8s/sapl-k8s.yaml @@ -0,0 +1,218 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: sapl +--- +apiVersion: v1 +kind: Service +metadata: + name: sapldb + namespace: sapl +spec: + selector: + app: sapldb + ports: + - name: postgres + port: 5432 + targetPort: 5432 +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: sapldb-data + namespace: sapl +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path + resources: + requests: + storage: 5Gi # or 1Gi for solr-configsets +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: sapldb + namespace: sapl +spec: + serviceName: sapldb + replicas: 1 + selector: + matchLabels: + app: sapldb + template: + metadata: + labels: + app: sapldb + spec: + containers: + - name: postgres + image: postgres:10.5-alpine + env: + - name: POSTGRES_PASSWORD + value: "sapl" + - name: POSTGRES_USER + value: "sapl" + - name: POSTGRES_DB + value: "sapl" + - name: PGDATA + value: /var/lib/postgresql/data/ + - name: TZ + value: UTC + - name: PG_TZ + value: UTC + ports: + - containerPort: 5432 + volumeMounts: + - name: sapldb-data + mountPath: /var/lib/postgresql/data/ + volumes: + - name: sapldb-data + persistentVolumeClaim: + claimName: sapldb-data +--- +apiVersion: v1 +kind: Service +metadata: + name: saplsolr + namespace: sapl +spec: + selector: + app: saplsolr + ports: + - name: solr + port: 8983 + targetPort: 8983 +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: solr-data + namespace: sapl +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path + resources: + requests: + storage: 5Gi # or 1Gi for solr-configsets +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: solr-configsets + namespace: sapl +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path + resources: + requests: + storage: 5Gi # or 1Gi for solr-configsets +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: saplsolr + namespace: sapl +spec: + serviceName: saplsolr + replicas: 1 + selector: + matchLabels: + app: saplsolr + template: + metadata: + labels: + app: saplsolr + spec: + containers: + - name: solr + image: solr:8.11 + command: ["bash","-lc","bin/solr start -c -f"] + ports: + - containerPort: 8983 + volumeMounts: + - name: solr-data + mountPath: /var/solr + - name: solr-configsets + mountPath: /opt/solr/server/solr/configsets + volumes: + - name: solr-data + persistentVolumeClaim: + claimName: solr-data + - name: solr-configsets + persistentVolumeClaim: + claimName: solr-configsets +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: sapl-media + namespace: sapl +spec: + accessModes: ["ReadWriteOnce"] + storageClassName: local-path + resources: + requests: + storage: 5Gi # or 1Gi for solr-configsets +--- +apiVersion: v1 +kind: Service +metadata: + name: sapl + namespace: sapl +spec: + selector: + app: sapl + type: NodePort + ports: + - name: http + port: 80 + targetPort: 80 + nodePort: 30080 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sapl + namespace: sapl +spec: + replicas: 1 + selector: + matchLabels: + app: sapl + template: + metadata: + labels: + app: sapl + spec: + containers: + - name: sapl + image: eribeiro/sapl:debug-k8s-1 + ports: + - containerPort: 80 + volumeMounts: + - name: data + mountPath: /var/interlegis/sapl/data + readOnly: true # secrets are always mounted read-only + volumes: + - name: data + secret: + secretName: sapl-secretkey + defaultMode: 0440 # ensures read-only + env: + - name: ADMIN_PASSWORD + value: "interlegis" + - name: ADMIN_EMAIL + value: "email@dominio.net" + - name: DEBUG + value: "False" + - name: EMAIL_PORT + value: "587" + - name: EMAIL_USE_TLS + value: "False" + - name: EMAIL_HOST + value: "smtp.dominio.net" + - name: EMAIL_HOST_USER + value: "usuariosmtp" + - name: EMAIL_SEND_USER + + diff --git a/docker/startup_scripts/start.sh b/docker/startup_scripts/start.sh index 69f1333f4..316d73f8a 100755 --- a/docker/startup_scripts/start.sh +++ b/docker/startup_scripts/start.sh @@ -6,17 +6,14 @@ APP_DIR="/var/interlegis/sapl" DATA_DIR="/var/interlegis/sapl/data" MEDIA_DIR="/var/interlegis/sapl/media" RUN_DIR="/var/interlegis/sapl/run" -GUNICORN_DIR="/run/gunicorn" ENV_FILE="$APP_DIR/.env" SECRET_FILE="$DATA_DIR/secret.key" chown -R root:nginx "$RUN_DIR" || true chown -R root:nginx "$MEDIA_DIR" || true -chown -R root:nginx "$GUNICORN_DIR" || true chmod -R g+rwX "$RUN_DIR" || true chmod -R g+rwX "$MEDIA_DIR" || true -chmod -R g+rwX "$GUNICORN_DIR" || true # setgid bit on our writable trees (not data/) find "$RUN_DIR" "$MEDIA_DIR" -type d -exec chmod g+s {} + 2>/dev/null || true 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..5a0fdef73 100644 --- a/sapl/settings.py +++ b/sapl/settings.py @@ -43,7 +43,7 @@ ALLOWED_HOSTS = ['*'] LOGIN_REDIRECT_URL = '/' LOGIN_URL = '/login/?next=' -SAPL_VERSION = '3.1.164-RC2' +SAPL_VERSION = '3.1.164-RC3' if DEBUG: EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' @@ -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/templates/base.html b/sapl/templates/base.html index bc25fdabe..5679f718a 100644 --- a/sapl/templates/base.html +++ b/sapl/templates/base.html @@ -200,7 +200,7 @@ Desenvolvido pelo Interlegis em software livre e aberto. - Release: 3.1.164-RC2 + Release: 3.1.164-RC3

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')), ]