From 4792c784bf052a9db075fec549c244074694a9e5 Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Wed, 10 Sep 2025 16:34:51 -0300 Subject: [PATCH 1/3] Fix read-only mount on k8s --- docker/Dockerfile | 2 +- docker/k8s/sapl-deploy.sh | 12 ++ docker/k8s/sapl-k8s.yaml | 218 ++++++++++++++++++++++++++++++++ docker/startup_scripts/start.sh | 18 ++- 4 files changed, 243 insertions(+), 7 deletions(-) create mode 100755 docker/k8s/sapl-deploy.sh create mode 100644 docker/k8s/sapl-k8s.yaml diff --git a/docker/Dockerfile b/docker/Dockerfile index 9fe3d6b75..831627ec8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -62,7 +62,7 @@ RUN set -eux; \ # Usuários/grupos (idempotente) RUN useradd --system --no-create-home --shell /usr/sbin/nologin sapl || true \ - && groupadd -r nginx || true \ + && groupadd -g 101 -r nginx || true \ && usermod -aG nginx www-data || true \ && usermod -aG nginx sapl || true 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 b612532bc..316d73f8a 100755 --- a/docker/startup_scripts/start.sh +++ b/docker/startup_scripts/start.sh @@ -2,12 +2,21 @@ set -Eeuo pipefail IFS=$'\n\t' +APP_DIR="/var/interlegis/sapl" DATA_DIR="/var/interlegis/sapl/data" -APP_DIR="/var/interlegis/sapl/sapl" +MEDIA_DIR="/var/interlegis/sapl/media" +RUN_DIR="/var/interlegis/sapl/run" + ENV_FILE="$APP_DIR/.env" SECRET_FILE="$DATA_DIR/secret.key" -mkdir -p "$DATA_DIR" "$APP_DIR" +chown -R root:nginx "$RUN_DIR" || true +chown -R root:nginx "$MEDIA_DIR" || true +chmod -R g+rwX "$RUN_DIR" || true +chmod -R g+rwX "$MEDIA_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 log() { printf '[%s] %s\n' "$(date -Is)" "$*"; } err() { printf '[%s] ERROR: %s\n' "$(date -Is)" "$*" >&2; } @@ -76,7 +85,6 @@ create_secret() { SECRET_KEY="$(python3 genkey.py)" umask 177 printf '%s\n' "$SECRET_KEY" > "$SECRET_FILE" - chmod 600 "$SECRET_FILE" fi export SECRET_KEY } @@ -225,9 +233,7 @@ fix_logging_and_socket_perms() { # dirs mkdir -p "$APP_DIR/run" - chown -R root:nginx "$APP_DIR" - chmod 2775 "$APP_DIR" "$APP_DIR/run" - chmod -R g+rwX "$APP_DIR" + chmod 2775 "$APP_DIR/run" # new files/sockets → 660 umask 0007 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 2/3] 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')), ] From bda00ac9c996b8d2dfd27b43071447a6d25d860c Mon Sep 17 00:00:00 2001 From: Edward Oliveira Date: Tue, 16 Sep 2025 20:04:37 -0300 Subject: [PATCH 3/3] Release: 3.1.164-RC3 --- CHANGES.md | 7 +++++++ docker/docker-compose.yaml | 2 +- sapl/settings.py | 2 +- sapl/templates/base.html | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) 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 e84924050..20c75374a 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -33,7 +33,7 @@ services: networks: - sapl-net sapl: - image: interlegis/sapl:3.1.164-RC2 + image: interlegis/sapl:3.1.164-RC3 # build: # context: ../ # dockerfile: ./docker/Dockerfile diff --git a/sapl/settings.py b/sapl/settings.py index bd85ae7c0..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' 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