Browse Source

Merge branch '3.1.x' into tipo_votacao_multiplas_materias

pull/3781/head
Edward 1 month ago
committed by GitHub
parent
commit
f6106ff7eb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      CHANGES.md
  2. 10
      docker/docker-compose.yaml
  3. 12
      docker/k8s/sapl-deploy.sh
  4. 218
      docker/k8s/sapl-k8s.yaml
  5. 3
      docker/startup_scripts/start.sh
  6. 7
      sapl/api/urls.py
  7. 15
      sapl/api/views.py
  8. 106
      sapl/api/views_health.py
  9. 2
      sapl/endpoint_restriction_middleware.py
  10. 36
      sapl/health.py
  11. 12
      sapl/metrics.py
  12. 4
      sapl/settings.py
  13. 2
      sapl/templates/base.html
  14. 8
      sapl/urls.py

7
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 3.1.164-RC2 / 2025-09-08
======================== ========================

10
docker/docker-compose.yaml

@ -33,11 +33,11 @@ services:
networks: networks:
- sapl-net - sapl-net
sapl: sapl:
# image: eribeiro/sapl:debug-k8s image: interlegis/sapl:3.1.164-RC3
build: # build:
context: ../ # context: ../
dockerfile: ./docker/Dockerfile # dockerfile: ./docker/Dockerfile
container_name: sapl # container_name: sapl
labels: labels:
NAME: "sapl" NAME: "sapl"
restart: always restart: always

12
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

218
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

3
docker/startup_scripts/start.sh

@ -6,17 +6,14 @@ APP_DIR="/var/interlegis/sapl"
DATA_DIR="/var/interlegis/sapl/data" DATA_DIR="/var/interlegis/sapl/data"
MEDIA_DIR="/var/interlegis/sapl/media" MEDIA_DIR="/var/interlegis/sapl/media"
RUN_DIR="/var/interlegis/sapl/run" RUN_DIR="/var/interlegis/sapl/run"
GUNICORN_DIR="/run/gunicorn"
ENV_FILE="$APP_DIR/.env" ENV_FILE="$APP_DIR/.env"
SECRET_FILE="$DATA_DIR/secret.key" SECRET_FILE="$DATA_DIR/secret.key"
chown -R root:nginx "$RUN_DIR" || true chown -R root:nginx "$RUN_DIR" || true
chown -R root:nginx "$MEDIA_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 "$RUN_DIR" || true
chmod -R g+rwX "$MEDIA_DIR" || true chmod -R g+rwX "$MEDIA_DIR" || true
chmod -R g+rwX "$GUNICORN_DIR" || true
# setgid bit on our writable trees (not data/) # setgid bit on our writable trees (not data/)
find "$RUN_DIR" "$MEDIA_DIR" -type d -exec chmod g+s {} + 2>/dev/null || true find "$RUN_DIR" "$MEDIA_DIR" -type d -exec chmod g+s {} + 2>/dev/null || true

7
sapl/api/urls.py

@ -1,15 +1,13 @@
from django.conf.urls import include, url from django.conf.urls import include, url
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, \ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, \
SpectacularRedocView SpectacularRedocView
from rest_framework.authtoken.views import obtain_auth_token from rest_framework.authtoken.views import obtain_auth_token
from sapl.api.deprecated import SessaoPlenariaViewSet from sapl.api.deprecated import SessaoPlenariaViewSet
from sapl.api.views import AppVersionView, recria_token,\ from sapl.api.views import recria_token, SaplApiViewSetConstrutor
SaplApiViewSetConstrutor
from .apps import AppConfig from .apps import AppConfig
from .views_health import HealthzView, ReadyzView
app_name = AppConfig.name app_name = AppConfig.name
@ -38,7 +36,6 @@ urlpatterns = [
url(r'^api/', include(urlpatterns_api_doc)), url(r'^api/', include(urlpatterns_api_doc)),
url(r'^api/', include(urlpatterns_router)), url(r'^api/', include(urlpatterns_router)),
url(r'^api/version', AppVersionView.as_view()),
url(r'^api/auth/token$', obtain_auth_token), url(r'^api/auth/token$', obtain_auth_token),
url(r'^api/recriar-token/(?P<pk>\d*)$', recria_token, name="recria_token"), url(r'^api/recriar-token/(?P<pk>\d*)$', recria_token, name="recria_token"),
] ]

15
sapl/api/views.py

@ -1,6 +1,7 @@
import logging import logging
from django.conf import settings from django.conf import settings
from django.http import HttpResponse, JsonResponse
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAdminUser 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}) 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 = ApiViewSetConstrutor
SaplApiViewSetConstrutor.import_modules([ SaplApiViewSetConstrutor.import_modules([
'sapl.api.views_audiencia', 'sapl.api.views_audiencia',

106
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!"

2
sapl/endpoint_restriction_middleware.py

@ -18,7 +18,7 @@ ALLOWED_IPS = [
'ff00::/8' 'ff00::/8'
] ]
RESTRICTED_ENDPOINTS = ['/metrics'] RESTRICTED_ENDPOINTS = ['/metrics', '/health', '/ready', '/version']
class EndpointRestrictionMiddleware: class EndpointRestrictionMiddleware:

36
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

12
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)

4
sapl/settings.py

@ -43,7 +43,7 @@ ALLOWED_HOSTS = ['*']
LOGIN_REDIRECT_URL = '/' LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/login/?next=' LOGIN_URL = '/login/?next='
SAPL_VERSION = '3.1.164-RC2' SAPL_VERSION = '3.1.164-RC3'
if DEBUG: if DEBUG:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
@ -149,9 +149,9 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
'waffle.middleware.WaffleMiddleware', 'waffle.middleware.WaffleMiddleware',
'sapl.middleware.CheckWeakPasswordMiddleware', 'sapl.middleware.CheckWeakPasswordMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
] ]
if DEBUG: if DEBUG:
INSTALLED_APPS += ('debug_toolbar',) INSTALLED_APPS += ('debug_toolbar',)

2
sapl/templates/base.html

@ -200,7 +200,7 @@
<small> <small>
Desenvolvido pelo <a href="http://www.interlegis.leg.br/">Interlegis</a> em software livre e aberto. Desenvolvido pelo <a href="http://www.interlegis.leg.br/">Interlegis</a> em software livre e aberto.
</small> </small>
<span>Release: 3.1.164-RC2</span> <span>Release: 3.1.164-RC3</span>
</p> </p>
</div> </div>

8
sapl/urls.py

@ -36,6 +36,8 @@ import sapl.redireciona_urls.urls
import sapl.relatorios.urls import sapl.relatorios.urls
import sapl.sessao.urls import sapl.sessao.urls
from sapl.api.views_health import AppzVersionView, HealthzView, ReadyzView
urlpatterns = [] urlpatterns = []
urlpatterns += [ urlpatterns += [
@ -69,6 +71,12 @@ urlpatterns += [
path("robots.txt", TemplateView.as_view( path("robots.txt", TemplateView.as_view(
template_name="robots.txt", content_type="text/plain")), 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')), path(r'', include('django_prometheus.urls')),
] ]

Loading…
Cancel
Save