mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-17 18:32:35 +00:00
Deprecate old full health check endpoint, add new admin only health check and page with email tester.
This commit is contained in:
parent
e43019454d
commit
a8d93a3c6d
39 changed files with 848 additions and 28 deletions
.env.example
backend
docker
src/baserow
api
config
core
tests/baserow
changelog/entries/unreleased
breaking_change
_remove_baserow_full_healthchecks_env_var_as_private__health_.jsondepricate_private__health_check_endpoint_and_simplify_the_ch.json
feature
deploy/all-in-one/supervisor
docker-compose.local-build.ymldocker-compose.no-caddy.ymldocker-compose.ymldocs
e2e-tests
web-frontend
locales
modules/core
|
@ -95,7 +95,6 @@ DATABASE_NAME=baserow
|
|||
# BASEROW_GROUP_STORAGE_USAGE_QUEUE=
|
||||
# DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS=
|
||||
# BASEROW_WAIT_INSTEAD_OF_409_CONFLICT_ERROR=
|
||||
# BASEROW_FULL_HEALTHCHECKS=
|
||||
# BASEROW_DISABLE_MODEL_CACHE=
|
||||
# BASEROW_JOB_SOFT_TIME_LIMIT=
|
||||
# BASEROW_JOB_CLEANUP_INTERVAL_MINUTES=
|
||||
|
|
|
@ -284,7 +284,7 @@ case "$1" in
|
|||
fi
|
||||
return 0
|
||||
}
|
||||
curlf "http://localhost:$BASEROW_BACKEND_PORT/_health/"
|
||||
curlf "http://localhost:$BASEROW_BACKEND_PORT/api/_health/"
|
||||
;;
|
||||
bash)
|
||||
exec /bin/bash -c "${@:2}"
|
||||
|
|
0
backend/src/baserow/api/health/__init__.py
Normal file
0
backend/src/baserow/api/health/__init__.py
Normal file
45
backend/src/baserow/api/health/serializers.py
Normal file
45
backend/src/baserow/api/health/serializers.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class FullHealthCheckSerializer(serializers.Serializer):
|
||||
passing = serializers.BooleanField(
|
||||
read_only=True,
|
||||
help_text="False if any of the critical service health checks are failing, "
|
||||
"true otherwise.",
|
||||
)
|
||||
checks = serializers.DictField(
|
||||
read_only=True,
|
||||
child=serializers.CharField(),
|
||||
help_text="An object keyed by the name of the "
|
||||
"health check and the value being "
|
||||
"the result.",
|
||||
)
|
||||
|
||||
|
||||
class EmailTesterResponseSerializer(serializers.Serializer):
|
||||
succeeded = serializers.BooleanField(
|
||||
help_text="Whether or not the test email was sent successfully.", required=True
|
||||
)
|
||||
error_stack = serializers.CharField(
|
||||
help_text="The full stack trace and error message if the test email failed.",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
error_type = serializers.CharField(
|
||||
help_text="The type of error that occurred if the test email failed.",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
error = serializers.CharField(
|
||||
help_text="A short message describing the error that occured if the test "
|
||||
"email failed",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
|
||||
class EmailTesterRequestSerializer(serializers.Serializer):
|
||||
target_email = serializers.EmailField(required=True)
|
17
backend/src/baserow/api/health/urls.py
Normal file
17
backend/src/baserow/api/health/urls.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
from django.http import HttpResponse
|
||||
from django.urls import re_path
|
||||
|
||||
from baserow.api.health.views import EmailTesterView, FullHealthCheckView
|
||||
|
||||
app_name = "baserow.api.health"
|
||||
|
||||
|
||||
def public_health_check(request):
|
||||
return HttpResponse("OK")
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r"full/$", FullHealthCheckView.as_view(), name="full_health_check"),
|
||||
re_path(r"email/$", EmailTesterView.as_view(), name="email_tester"),
|
||||
re_path("^$", public_health_check, name="public_health_check"),
|
||||
]
|
75
backend/src/baserow/api/health/views.py
Normal file
75
backend/src/baserow/api/health/views.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
import traceback
|
||||
from typing import Any, Dict
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from baserow.api.decorators import validate_body
|
||||
from baserow.api.health.serializers import (
|
||||
EmailTesterRequestSerializer,
|
||||
EmailTesterResponseSerializer,
|
||||
FullHealthCheckSerializer,
|
||||
)
|
||||
from baserow.api.schemas import get_error_schema
|
||||
from baserow.core.health.handler import HealthCheckHandler
|
||||
|
||||
|
||||
class FullHealthCheckView(APIView):
|
||||
permission_classes = (IsAdminUser,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Health"],
|
||||
request=None,
|
||||
operation_id="full_health_check",
|
||||
description="Runs a full health check testing as many services and systems "
|
||||
"as possible. These health checks can be expensive operations such as writing "
|
||||
"files to storage etc.",
|
||||
responses={
|
||||
200: FullHealthCheckSerializer,
|
||||
},
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
result = HealthCheckHandler.run_all_checks()
|
||||
return Response(
|
||||
FullHealthCheckSerializer(
|
||||
{"checks": result.checks, "passing": result.passing}
|
||||
).data
|
||||
)
|
||||
|
||||
|
||||
class EmailTesterView(APIView):
|
||||
permission_classes = (IsAdminUser,)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Health"],
|
||||
request=EmailTesterRequestSerializer,
|
||||
operation_id="email_tester",
|
||||
description="Sends a test email to the provided email address. Useful for "
|
||||
"testing Baserow's email configuration as errors are clearly "
|
||||
"returned.",
|
||||
responses={
|
||||
200: EmailTesterResponseSerializer,
|
||||
400: get_error_schema(["ERROR_REQUEST_BODY_VALIDATION"]),
|
||||
},
|
||||
)
|
||||
@validate_body(EmailTesterRequestSerializer, return_validated=True)
|
||||
def post(self, request: Request, data: Dict[str, Any]) -> Response:
|
||||
target_email = data["target_email"]
|
||||
try:
|
||||
HealthCheckHandler.send_test_email(target_email)
|
||||
return Response(EmailTesterResponseSerializer({"succeeded": True}).data)
|
||||
except Exception as e:
|
||||
full = traceback.format_exc()
|
||||
return Response(
|
||||
EmailTesterResponseSerializer(
|
||||
{
|
||||
"succeeded": False,
|
||||
"error_type": e.__class__.__name__,
|
||||
"error_stack": full,
|
||||
"error": str(e),
|
||||
}
|
||||
).data
|
||||
)
|
|
@ -1,4 +1,3 @@
|
|||
from django.http import HttpResponse
|
||||
from django.urls import include, path
|
||||
|
||||
from drf_spectacular.views import SpectacularRedocView
|
||||
|
@ -11,6 +10,7 @@ from baserow.core.registries import application_type_registry, plugin_registry
|
|||
|
||||
from .applications import urls as application_urls
|
||||
from .auth_provider import urls as auth_provider_urls
|
||||
from .health import urls as health_urls
|
||||
from .jobs import urls as jobs_urls
|
||||
from .settings import urls as settings_urls
|
||||
from .snapshots import urls as snapshots_urls
|
||||
|
@ -24,10 +24,6 @@ from .workspaces import urls as workspace_urls
|
|||
app_name = "baserow.api"
|
||||
|
||||
|
||||
def public_health_check(request):
|
||||
return HttpResponse("OK")
|
||||
|
||||
|
||||
urlpatterns = (
|
||||
[
|
||||
path("schema.json", CachedSpectacularJSONAPIView.as_view(), name="json_schema"),
|
||||
|
@ -46,7 +42,7 @@ urlpatterns = (
|
|||
path("trash/", include(trash_urls, namespace="trash")),
|
||||
path("jobs/", include(jobs_urls, namespace="jobs")),
|
||||
path("snapshots/", include(snapshots_urls, namespace="snapshots")),
|
||||
path("_health/", public_health_check, name="public_health_check"),
|
||||
path("_health/", include(health_urls, namespace="health")),
|
||||
# GroupDeprecation
|
||||
path("groups/", include(group_compat_urls, namespace="groups")),
|
||||
path(
|
||||
|
|
|
@ -81,6 +81,8 @@ INSTALLED_APPS = [
|
|||
"health_check.cache",
|
||||
"health_check.contrib.migrations",
|
||||
"health_check.contrib.redis",
|
||||
"health_check.contrib.celery_ping",
|
||||
"health_check.contrib.psutil",
|
||||
"baserow.core",
|
||||
"baserow.api",
|
||||
"baserow.ws",
|
||||
|
@ -122,10 +124,6 @@ AUTO_INDEX_LOCK_EXPIRY = os.getenv("BASEROW_AUTO_INDEX_LOCK_EXPIRY", 60 * 2)
|
|||
if "builder" in FEATURE_FLAGS:
|
||||
INSTALLED_APPS.append("baserow.contrib.builder")
|
||||
|
||||
BASEROW_FULL_HEALTHCHECKS = os.getenv("BASEROW_FULL_HEALTHCHECKS", None)
|
||||
if BASEROW_FULL_HEALTHCHECKS is not None:
|
||||
INSTALLED_APPS += ["health_check.storage", "health_check.contrib.psutil"]
|
||||
|
||||
ADDITIONAL_APPS = os.getenv("ADDITIONAL_APPS", "").split(",")
|
||||
if ADDITIONAL_APPS is not None:
|
||||
INSTALLED_APPS += [app.strip() for app in ADDITIONAL_APPS if app.strip() != ""]
|
||||
|
|
|
@ -68,3 +68,5 @@ CELERY_BROKER_POOL_LIMIT = min(
|
|||
CELERY_REDIS_MAX_CONNECTIONS = min(
|
||||
4 * int(os.getenv("BASEROW_AMOUNT_OF_WORKERS", "1")), 10 # noqa: F405
|
||||
)
|
||||
|
||||
HEROKU_ENABLED = True
|
||||
|
|
|
@ -1,13 +1,26 @@
|
|||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.http import HttpResponse
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from baserow.core.registries import plugin_registry
|
||||
|
||||
|
||||
def old_deprecated_health_check(request):
|
||||
"""
|
||||
This was an old health check that ran expensive and potentially dangerous when
|
||||
publicly exposed checks. These checks have been moved into the new, admin only,
|
||||
/api/_health/full/ check endpoint and this one is left for backwards compatability
|
||||
only.
|
||||
"""
|
||||
|
||||
return HttpResponse("OK")
|
||||
|
||||
|
||||
urlpatterns = (
|
||||
[
|
||||
re_path(r"^api/", include("baserow.api.urls", namespace="api")),
|
||||
re_path(r"^_health/", include("health_check.urls")),
|
||||
path("_health/", old_deprecated_health_check, name="root_health_check"),
|
||||
]
|
||||
+ plugin_registry.urls
|
||||
+ static(settings.MEDIA_URL_PATH, document_root=settings.MEDIA_ROOT)
|
||||
|
|
|
@ -246,11 +246,34 @@ class CoreConfig(AppConfig):
|
|||
|
||||
auth_provider_type_registry.register(PasswordAuthProviderType())
|
||||
|
||||
self._setup_health_checks()
|
||||
|
||||
# Clear the key after migration so we will trigger a new template sync.
|
||||
post_migrate.connect(start_sync_templates_task_after_migrate, sender=self)
|
||||
# Create all operations from registry
|
||||
post_migrate.connect(sync_operations_after_migrate, sender=self)
|
||||
|
||||
def _setup_health_checks(self):
|
||||
from health_check.contrib.s3boto_storage.backends import (
|
||||
S3BotoStorageHealthCheck,
|
||||
)
|
||||
from health_check.plugins import plugin_dir
|
||||
from health_check.storage.backends import DefaultFileStorageHealthCheck
|
||||
|
||||
from .health.custom_health_checks import (
|
||||
DebugModeHealthCheck,
|
||||
HerokuExternalFileStorageConfiguredHealthCheck,
|
||||
)
|
||||
|
||||
plugin_dir.register(DebugModeHealthCheck)
|
||||
if getattr(settings, "HEROKU_ENABLED", False):
|
||||
plugin_dir.register(HerokuExternalFileStorageConfiguredHealthCheck)
|
||||
|
||||
if settings.DEFAULT_FILE_STORAGE == "storages.backends.s3boto3.S3Boto3Storage":
|
||||
plugin_dir.register(S3BotoStorageHealthCheck)
|
||||
else:
|
||||
plugin_dir.register(DefaultFileStorageHealthCheck)
|
||||
|
||||
|
||||
# noinspection PyPep8Naming
|
||||
def start_sync_templates_task_after_migrate(sender, **kwargs):
|
||||
|
|
35
backend/src/baserow/core/health/custom_health_checks.py
Normal file
35
backend/src/baserow/core/health/custom_health_checks.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
from django.conf import settings
|
||||
|
||||
from health_check.backends import BaseHealthCheckBackend
|
||||
from health_check.exceptions import ServiceWarning
|
||||
|
||||
|
||||
class DebugModeHealthCheck(BaseHealthCheckBackend):
|
||||
critical_service = False
|
||||
|
||||
def check_status(self):
|
||||
if settings.DEBUG:
|
||||
raise ServiceWarning(
|
||||
"DEBUG is enabled, this is insecure for production or public usage."
|
||||
)
|
||||
|
||||
def identifier(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
|
||||
class HerokuExternalFileStorageConfiguredHealthCheck(BaseHealthCheckBackend):
|
||||
critical_service = False
|
||||
|
||||
def check_status(self):
|
||||
if (
|
||||
settings.DEFAULT_FILE_STORAGE
|
||||
== "baserow.core.storage.OverwriteFileSystemStorage"
|
||||
):
|
||||
raise ServiceWarning(
|
||||
"Any uploaded files will be lost on dyno restart because you have "
|
||||
"not configured an external file storage service. Please set "
|
||||
"AWS_ACCESS_KEY_ID and related env vars to prevent file loss."
|
||||
)
|
||||
|
||||
def identifier(self):
|
||||
return self.__class__.__name__
|
84
backend/src/baserow/core/health/handler.py
Normal file
84
backend/src/baserow/core/health/handler.py
Normal file
|
@ -0,0 +1,84 @@
|
|||
import copy
|
||||
from typing import Dict, NamedTuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import mail
|
||||
from django.core.mail import EmailMessage
|
||||
|
||||
from health_check.plugins import plugin_dir
|
||||
|
||||
|
||||
class HealthCheckResult(NamedTuple):
|
||||
checks: Dict[str, str]
|
||||
passing: bool
|
||||
|
||||
|
||||
class HealthCheckHandler:
|
||||
@classmethod
|
||||
def get_plugins(cls):
|
||||
return sorted(
|
||||
(
|
||||
plugin_class(**copy.deepcopy(options))
|
||||
for plugin_class, options in plugin_dir._registry
|
||||
),
|
||||
key=lambda plugin: plugin.identifier(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def run_all_checks(cls) -> HealthCheckResult:
|
||||
"""
|
||||
The health_check library provides its own template and view. However, it has
|
||||
bugs, runs health checks in threads which causes further problems and does
|
||||
not fit into our DRF based authentication. We just use this library
|
||||
for the nice suite of health check plugins it provides, and use its python
|
||||
API to run those in a simpler and safer fashion in this method.
|
||||
"""
|
||||
|
||||
checks = {}
|
||||
|
||||
passing = True
|
||||
for plugin in cls.get_plugins():
|
||||
# The plugin checks can fail and raise but we always want to catch
|
||||
# to report back that they failed.
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
plugin.run_check()
|
||||
except Exception: # nosec
|
||||
pass
|
||||
checks[str(plugin.identifier())] = str(plugin.pretty_status())
|
||||
if plugin.critical_service and not plugin.status:
|
||||
passing = False
|
||||
|
||||
return HealthCheckResult(checks, passing)
|
||||
|
||||
@classmethod
|
||||
def send_test_email(cls, target_email: str):
|
||||
"""
|
||||
This method sends a test email synchronously and will raise any exceptions
|
||||
that occur.
|
||||
|
||||
To do this it forces the backend not to be the celery one, which triggers a
|
||||
celery task when `email.send` is called, but instead the normal backend that
|
||||
celery itself would have been using within that task.
|
||||
|
||||
Sending via a celery task makes this test pointless as we can't get the result
|
||||
back from the celery task. So this way we force the email to be sent
|
||||
synchronously, so we can immediately see any errors and respond with
|
||||
them back to the user.
|
||||
|
||||
:param target_email: The email address to send the test email to.
|
||||
:raises: Will attempt to send the email and will raise any Exceptions that occur
|
||||
if the sending fails.
|
||||
"""
|
||||
|
||||
with mail.get_connection(
|
||||
fail_silently=False, backend=settings.CELERY_EMAIL_BACKEND
|
||||
) as connection:
|
||||
email = EmailMessage(
|
||||
"Test email from Baserow",
|
||||
"This is a test email sent by the email tester in Baserow",
|
||||
settings.FROM_EMAIL,
|
||||
[target_email],
|
||||
connection=connection,
|
||||
)
|
||||
email.send(fail_silently=False)
|
66
backend/tests/baserow/api/health/test_email_tester_views.py
Normal file
66
backend/tests/baserow/api/health/test_email_tester_views.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous_user_cant_trigger_test_email(data_fixture, api_client):
|
||||
response = api_client.post(
|
||||
reverse("api:health:email_tester"),
|
||||
{"target_email": "test@test.com"},
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_non_staff_user_cant_trigger_test_email(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:health:email_tester"),
|
||||
{"target_email": "test@test.com"},
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_user_can_trigger_test_email(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token(is_staff=True)
|
||||
|
||||
response = api_client.post(
|
||||
reverse("api:health:email_tester"),
|
||||
{"target_email": "test@test.com"},
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"error": None,
|
||||
"error_stack": None,
|
||||
"error_type": None,
|
||||
"succeeded": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("django.core.mail.get_connection")
|
||||
def test_staff_user_can_trigger_test_email_and_see_error_if_fails(
|
||||
patched_get_connection, data_fixture, api_client
|
||||
):
|
||||
user, token = data_fixture.create_user_and_token(is_staff=True)
|
||||
|
||||
patched_get_connection.side_effect = Exception("Failed")
|
||||
response = api_client.post(
|
||||
reverse("api:health:email_tester"),
|
||||
{"target_email": "test@test.com"},
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
json = response.json()
|
||||
assert not json["succeeded"]
|
||||
assert json["error"] == "Failed"
|
||||
assert json["error_type"] == "Exception"
|
||||
assert len(json["error_stack"]) > 1
|
93
backend/tests/baserow/api/health/test_health_views.py
Normal file
93
backend/tests/baserow/api/health/test_health_views.py
Normal file
|
@ -0,0 +1,93 @@
|
|||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from health_check.backends import BaseHealthCheckBackend
|
||||
from health_check.db.backends import DatabaseBackend
|
||||
from health_check.exceptions import ServiceUnavailable
|
||||
from health_check.plugins import plugin_dir
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
class AlwaysFailingHealthCheck(BaseHealthCheckBackend):
|
||||
def check_status(self):
|
||||
raise ServiceUnavailable("Error")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_health_checks_to_expected():
|
||||
plugin_dir.reset()
|
||||
plugin_dir.register(DatabaseBackend)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_anonymous_user_cant_get_full_health_checks(data_fixture, api_client):
|
||||
response = api_client.get(
|
||||
reverse("api:health:full_health_check"),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_non_staff_user_cant_get_full_health_checks(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token()
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:health:full_health_check"),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_staff_user_can_get_full_health_checks(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token(is_staff=True)
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:health:full_health_check"),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_full_health_check_endpoint_returns_dict_of_checks_vs_status_with_200_status(
|
||||
data_fixture, api_client
|
||||
):
|
||||
user, token = data_fixture.create_user_and_token(is_staff=True)
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:health:full_health_check"),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"passing": True,
|
||||
"checks": {
|
||||
"DatabaseBackend": "working",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_passing_is_false_when_one_critical_service_fails(data_fixture, api_client):
|
||||
user, token = data_fixture.create_user_and_token(is_staff=True)
|
||||
|
||||
plugin_dir.register(AlwaysFailingHealthCheck)
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:health:full_health_check"),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"JWT {token}",
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response.json() == {
|
||||
"checks": {
|
||||
"AlwaysFailingHealthCheck": "unavailable: Error",
|
||||
"DatabaseBackend": "working",
|
||||
},
|
||||
"passing": False,
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from health_check.exceptions import ServiceWarning
|
||||
|
||||
from baserow.core.health.custom_health_checks import (
|
||||
DebugModeHealthCheck,
|
||||
HerokuExternalFileStorageConfiguredHealthCheck,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(DEBUG=True)
|
||||
def test_debug_health_check_raises_when_debug_true():
|
||||
with pytest.raises(ServiceWarning):
|
||||
DebugModeHealthCheck().check_status()
|
||||
|
||||
|
||||
@override_settings(DEBUG=False)
|
||||
def test_debug_health_check_does_not_raise_when_debug_false():
|
||||
DebugModeHealthCheck().check_status()
|
||||
|
||||
|
||||
@override_settings(
|
||||
DEFAULT_FILE_STORAGE="baserow.core.storage.OverwriteFileSystemStorage"
|
||||
)
|
||||
def test_heroku_health_check_raises_when_default_storage_set():
|
||||
with pytest.raises(ServiceWarning):
|
||||
HerokuExternalFileStorageConfiguredHealthCheck().check_status()
|
||||
|
||||
|
||||
@override_settings(DEFAULT_FILE_STORAGE="storages.backends.s3boto3.S3Boto3Storage")
|
||||
def test_heroku_health_check_doesnt_raise_when_boto_set():
|
||||
HerokuExternalFileStorageConfiguredHealthCheck().check_status()
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "breaking_change",
|
||||
"message": "Remove BASEROW_FULL_HEALTHCHECKS env var as private _health check endpoint which this env var affected has been simplified, depricated and replaced with the new /api/_health/full/ endpoint.",
|
||||
"issue_number": " ",
|
||||
"bullet_points": [],
|
||||
"created_at": "2023-04-14"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "breaking_change",
|
||||
"message": "Depricate private _health/ check endpoint and simplify the check it performs for security reasons.",
|
||||
"issue_number": null,
|
||||
"bullet_points": [],
|
||||
"created_at": "2023-04-14"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "feature",
|
||||
"message": "Add a new admin health check page for seeing the status of your Baserow server.",
|
||||
"issue_number": 521,
|
||||
"bullet_points": [],
|
||||
"created_at": "2023-04-14"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "feature",
|
||||
"message": "Add email tester to health check page for easily debugging Baserow email sending issues.",
|
||||
"issue_number": 521,
|
||||
"bullet_points": [],
|
||||
"created_at": "2023-04-14"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "feature",
|
||||
"message": "Add new /api/_health/full/ JSON API healthcheck endpoint for admins only to run a full set of health checks against Baserow.",
|
||||
"issue_number": 521,
|
||||
"bullet_points": [],
|
||||
"created_at": "2023-04-14"
|
||||
}
|
|
@ -14,7 +14,7 @@ baserow_ready() {
|
|||
return 0
|
||||
}
|
||||
|
||||
if curlf "http://localhost:3000/_health/" && curlf "http://localhost:8000/_health/"; then
|
||||
if curlf "http://localhost:3000/_health/" && curlf "http://localhost:8000/api/_health/"; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
|
|
|
@ -92,7 +92,6 @@ x-backend-variables: &backend-variables
|
|||
BASEROW_GROUP_STORAGE_USAGE_QUEUE:
|
||||
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
|
||||
BASEROW_WAIT_INSTEAD_OF_409_CONFLICT_ERROR:
|
||||
BASEROW_FULL_HEALTHCHECKS:
|
||||
BASEROW_DISABLE_MODEL_CACHE:
|
||||
BASEROW_PLUGIN_DIR:
|
||||
BASEROW_JOB_EXPIRATION_TIME_LIMIT:
|
||||
|
|
|
@ -111,7 +111,6 @@ x-backend-variables: &backend-variables
|
|||
BASEROW_GROUP_STORAGE_USAGE_QUEUE:
|
||||
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
|
||||
BASEROW_WAIT_INSTEAD_OF_409_CONFLICT_ERROR:
|
||||
BASEROW_FULL_HEALTHCHECKS:
|
||||
BASEROW_DISABLE_MODEL_CACHE:
|
||||
BASEROW_PLUGIN_DIR:
|
||||
BASEROW_JOB_EXPIRATION_TIME_LIMIT:
|
||||
|
|
|
@ -108,7 +108,6 @@ x-backend-variables: &backend-variables
|
|||
BASEROW_GROUP_STORAGE_USAGE_QUEUE:
|
||||
DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS:
|
||||
BASEROW_WAIT_INSTEAD_OF_409_CONFLICT_ERROR:
|
||||
BASEROW_FULL_HEALTHCHECKS:
|
||||
BASEROW_DISABLE_MODEL_CACHE:
|
||||
BASEROW_PLUGIN_DIR:
|
||||
BASEROW_JOB_EXPIRATION_TIME_LIMIT:
|
||||
|
|
|
@ -43,7 +43,7 @@ The src directory contains the full source code of the Baserow backend module.
|
|||
is included by the root url config under the namespace `api`.
|
||||
* `config`: is a module that contains base settings and some settings that are for
|
||||
specific environments. It also contains the root url config that includes the api
|
||||
under the namespace `api` and adds a `_health` route for health checking. There is
|
||||
under the namespace `api`. There is
|
||||
also a wsgi.py file which can be used to expose the applications.
|
||||
* `contrib`: contains extra apps that can be installed. For now it only contains the
|
||||
backend part of the database plugin. This app is installed by default, but it is
|
||||
|
|
|
@ -117,7 +117,6 @@ The installation methods referred to in the variable descriptions are:
|
|||
| HOURS\_UNTIL\_TRASH\_PERMANENTLY\_DELETED | Items from the trash will be permanently deleted after this number of hours. | |
|
||||
| DISABLE\_ANONYMOUS\_PUBLIC\_VIEW\_WS\_CONNECTIONS | When sharing views publicly a websocket connection is opened to provide realtime updates to viewers of the public link. To disable this set any non empty value. When disabled publicly shared links will need to be refreshed to see any updates to the view. | |
|
||||
| BASEROW\_WAIT\_INSTEAD\_OF\_409\_CONFLICT\_ERROR | When updating or creating various resources in Baserow if another concurrent operation is ongoing (like a snapshot, duplication, import etc) which would be affected by your modification a 409 HTTP error will be returned. If you instead would prefer Baserow to not return a 409 and just block waiting until the operation finishes and then to perform the requested operation set this flag to any non-empty value. | |
|
||||
| BASEROW\_FULL\_HEALTHCHECKS | When set to any non empty value will additionally check in the backend's healthcheck at /_health/ if storage can be written to (causes lots of small filesystem writes) and a more general check if enough disk and memory is available. | |
|
||||
| BASEROW\_JOB\_CLEANUP\_INTERVAL\_MINUTES | How often the job cleanup task will run. | 5 |
|
||||
| BASEROW\_JOB\_EXPIRATION\_TIME\_LIMIT | How long before a Baserow job will be kept before being cleaned up. | 30 * 24 * 60 (24 days) |
|
||||
| BASEROW\_JOB\_SOFT\_TIME\_LIMIT | The number of seconds a Baserow job can run before being terminated. | 1800 |
|
||||
|
|
|
@ -63,12 +63,6 @@ metadata:
|
|||
nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
|
||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "172800"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 20m
|
||||
# Prevent access to the full internal healthcheck
|
||||
nginx.ingress.kubernetes.io/server-snippet: |
|
||||
location ~* "^/_health" {
|
||||
deny all;
|
||||
return 403;
|
||||
}
|
||||
kubernetes.io/ingress.class: nginx
|
||||
# removed ssl settings, add in your own desired ones
|
||||
spec:
|
||||
|
|
|
@ -24,7 +24,7 @@ baserow_ready() {
|
|||
return 22
|
||||
}
|
||||
|
||||
if curlf "${PUBLIC_WEB_FRONTEND_URL:-http://web-frontend:3000}/_health/" && curlf "${PUBLIC_BACKEND_URL:-http://backend:8000}/_health/" && templates_ready; then
|
||||
if curlf "${PUBLIC_WEB_FRONTEND_URL:-http://web-frontend:3000}/_health/" && curlf "${PUBLIC_BACKEND_URL:-http://backend:8000}/api/_health/" && templates_ready; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
|
|
|
@ -35,7 +35,8 @@
|
|||
"copyToClipboard": "Copy to clipboard"
|
||||
},
|
||||
"adminType": {
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"health": "Health"
|
||||
},
|
||||
"applicationType": {
|
||||
"database": "Database",
|
||||
|
@ -404,5 +405,17 @@
|
|||
"urlCheck": {
|
||||
"invalidUrlEnvVarTitle": "Invalid {name}",
|
||||
"invalidUrlEnvVarDescription": "The {name} environment variable has been set to an invalid value. Your site admin must change {name} to a valid URL starting with http:// or https:// and then restart Baserow to fix this error."
|
||||
},
|
||||
"health": {
|
||||
"title": "Baserow health checks",
|
||||
"description": "These checks show the current status of your Baserow installation."
|
||||
},
|
||||
"emailTester": {
|
||||
"title": "Email tester",
|
||||
"targetEmailLabel": "Target Email",
|
||||
"invalidTargetEmail": "Invalid email address",
|
||||
"submit": "Send test email",
|
||||
"success": "Successfully sent test email",
|
||||
"configLink": "Email Configuration Help"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -99,3 +99,26 @@ export class SettingsAdminType extends AdminType {
|
|||
return 9999
|
||||
}
|
||||
}
|
||||
|
||||
export class HealthCheckAdminType extends AdminType {
|
||||
static getType() {
|
||||
return 'health'
|
||||
}
|
||||
|
||||
getIconClass() {
|
||||
return 'medkit'
|
||||
}
|
||||
|
||||
getName() {
|
||||
const { i18n } = this.app
|
||||
return i18n.t('adminType.health')
|
||||
}
|
||||
|
||||
getRouteName() {
|
||||
return 'admin-health'
|
||||
}
|
||||
|
||||
getOrder() {
|
||||
return 10000
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
.admin-health {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.admin-health__group {
|
||||
max-width: 30vw;
|
||||
min-width: 160px;
|
||||
|
||||
&:not(:last-child) {
|
||||
padding-bottom: 30px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: solid 1px $color-neutral-200;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-health__description {
|
||||
font-size: 13px;
|
||||
color: $color-neutral-600;
|
||||
line-height: 160%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-health__check-item {
|
||||
display: flex;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-health__check-item-label {
|
||||
flex: 0 0 50%;
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
.admin-health__check-item-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-health__check-item-description {
|
||||
font-size: 13px;
|
||||
color: $color-warning-600;
|
||||
line-height: 160%;
|
||||
}
|
||||
|
||||
.admin-health__icon {
|
||||
min-width: 0;
|
||||
|
||||
.warning {
|
||||
max-width: 500px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-health__icon--success {
|
||||
color: $color-success-500;
|
||||
}
|
||||
|
||||
.admin-health__icon--fail {
|
||||
color: $color-error-500;
|
||||
}
|
|
@ -89,6 +89,7 @@
|
|||
@import 'separator';
|
||||
@import 'quote';
|
||||
@import 'admin_settings';
|
||||
@import 'admin_health';
|
||||
@import 'templates';
|
||||
@import 'paginator';
|
||||
@import 'sortable';
|
||||
|
@ -115,3 +116,4 @@
|
|||
@import 'badge';
|
||||
@import 'builder/all';
|
||||
@import 'expandable_card';
|
||||
@import 'email_tester';
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
.email-tester__full-stack {
|
||||
overflow: auto;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 20px;
|
||||
}
|
115
web-frontend/modules/core/components/health/EmailTester.vue
Normal file
115
web-frontend/modules/core/components/health/EmailTester.vue
Normal file
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2>
|
||||
{{ $t('emailTester.title') }}
|
||||
|
||||
<a
|
||||
href="https://baserow.io/docs/installation%2Fconfiguration#email-configuration"
|
||||
target="_blank"
|
||||
><i class="fas fa-question-circle"
|
||||
/></a>
|
||||
</h2>
|
||||
<Error :error="error" />
|
||||
<div v-if="testResult.succeeded != null">
|
||||
<Alert
|
||||
v-if="!testResult.succeeded"
|
||||
type="error"
|
||||
:title="testResult.error_type"
|
||||
>
|
||||
<span>{{ testResult.error }}</span>
|
||||
<pre class="email-tester__full-stack">{{ trimmedFullStack }}</pre>
|
||||
</Alert>
|
||||
<Alert v-else type="success" :title="$t('emailTester.success')"> </Alert>
|
||||
</div>
|
||||
<form @submit.prevent="submit">
|
||||
<FormElement :error="fieldHasErrors('targetEmail')" class="control">
|
||||
<label class="control__label">{{
|
||||
$t('emailTester.targetEmailLabel')
|
||||
}}</label>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="name"
|
||||
v-model="values.targetEmail"
|
||||
:class="{ 'input--error': fieldHasErrors('targetEmail') }"
|
||||
type="text"
|
||||
class="input input--large"
|
||||
:disabled="loading"
|
||||
@blur="$v.values.targetEmail.$touch()"
|
||||
/>
|
||||
<div v-if="fieldHasErrors('targetEmail')" class="error">
|
||||
{{ $t('emailTester.invalidTargetEmail') }}
|
||||
</div>
|
||||
</div>
|
||||
</FormElement>
|
||||
<button
|
||||
class="button button--primary"
|
||||
:class="{ 'button--loading': loading }"
|
||||
:disabled="loading || $v.$invalid"
|
||||
>
|
||||
{{ $t('emailTester.submit') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import error from '@baserow/modules/core/mixins/error'
|
||||
import HealthService from '@baserow/modules/core/services/health'
|
||||
import form from '@baserow/modules/core/mixins/form'
|
||||
|
||||
import { required, email } from 'vuelidate/lib/validators'
|
||||
import { mapGetters } from 'vuex'
|
||||
export default {
|
||||
name: 'EmailerTester',
|
||||
mixins: [error, form],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
values: {
|
||||
targetEmail: 'test@example.com',
|
||||
},
|
||||
testResult: {
|
||||
succeeded: null,
|
||||
error_type: null,
|
||||
error: null,
|
||||
error_stack: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ username: 'auth/getUsername' }),
|
||||
trimmedFullStack() {
|
||||
return this.testResult?.error_stack?.trim()
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.username) {
|
||||
this.values.targetEmail = this.username
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
this.loading = true
|
||||
this.testResult = {}
|
||||
this.hideError()
|
||||
|
||||
try {
|
||||
const { data } = await HealthService(this.$client).testEmail(
|
||||
this.values.targetEmail
|
||||
)
|
||||
this.testResult = data
|
||||
} catch (e) {
|
||||
this.handleError(e, 'health')
|
||||
this.testResult = {}
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
values: {
|
||||
targetEmail: { required, email },
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
74
web-frontend/modules/core/pages/admin/health.vue
Normal file
74
web-frontend/modules/core/pages/admin/health.vue
Normal file
|
@ -0,0 +1,74 @@
|
|||
<template>
|
||||
<div class="layout__col-2-scroll">
|
||||
<div class="admin-health">
|
||||
<h1>
|
||||
{{ $t('health.title') }}
|
||||
</h1>
|
||||
<div class="admin-health__group">
|
||||
<div class="admin-health__description">
|
||||
{{ $t('health.description') }}
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
v-for="(status, checkName) in healthChecks"
|
||||
:key="status"
|
||||
class="admin-health__check-item"
|
||||
>
|
||||
<div class="admin-health__check-item-label">
|
||||
<div class="admin-health__check-item-name">
|
||||
{{ camelCaseToSpaceSeparated(checkName) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="admin-health__icon"
|
||||
:class="status !== 'working' ? 'warning' : ''"
|
||||
>
|
||||
<i
|
||||
class="fas"
|
||||
:class="
|
||||
status === 'working'
|
||||
? 'fa-check admin-health__icon--success'
|
||||
: 'fa-times admin-health__icon--fail'
|
||||
"
|
||||
></i>
|
||||
<div
|
||||
v-if="status !== 'working'"
|
||||
class="admin-health__check-item-description"
|
||||
>
|
||||
{{ status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-health__group">
|
||||
<EmailerTester></EmailerTester>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HealthService from '@baserow/modules/core/services/health'
|
||||
import EmailerTester from '@baserow/modules/core/components/health/EmailTester.vue'
|
||||
|
||||
export default {
|
||||
components: { EmailerTester },
|
||||
layout: 'app',
|
||||
middleware: 'staff',
|
||||
async asyncData({ app }) {
|
||||
const { data } = await HealthService(app.$client).getAll()
|
||||
return { healthChecks: data.checks }
|
||||
},
|
||||
methods: {
|
||||
camelCaseToSpaceSeparated(camelCaseString) {
|
||||
if (!camelCaseString) {
|
||||
return 'unknown'
|
||||
} else {
|
||||
camelCaseString = camelCaseString.toString()
|
||||
}
|
||||
return camelCaseString.replace(/([A-Z])/g, ' $1')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -16,7 +16,10 @@ import {
|
|||
UploadFileUserFileUploadType,
|
||||
UploadViaURLUserFileUploadType,
|
||||
} from '@baserow/modules/core/userFileUploadTypes'
|
||||
import { SettingsAdminType } from '@baserow/modules/core/adminTypes'
|
||||
import {
|
||||
HealthCheckAdminType,
|
||||
SettingsAdminType,
|
||||
} from '@baserow/modules/core/adminTypes'
|
||||
|
||||
import {
|
||||
BasicPermissionManagerType,
|
||||
|
@ -103,6 +106,7 @@ export default (context, inject) => {
|
|||
new UploadViaURLUserFileUploadType(context)
|
||||
)
|
||||
registry.register('admin', new SettingsAdminType(context))
|
||||
registry.register('admin', new HealthCheckAdminType(context))
|
||||
inject('registry', registry)
|
||||
|
||||
store.registerModule('settings', settingsStore)
|
||||
|
|
|
@ -51,6 +51,11 @@ export const routes = [
|
|||
path: '/admin/settings',
|
||||
component: path.resolve(__dirname, 'pages/admin/settings.vue'),
|
||||
},
|
||||
{
|
||||
name: 'admin-health',
|
||||
path: '/admin/health',
|
||||
component: path.resolve(__dirname, 'pages/admin/health.vue'),
|
||||
},
|
||||
{
|
||||
name: 'style-guide',
|
||||
path: '/style-guide',
|
||||
|
|
12
web-frontend/modules/core/services/health.js
Normal file
12
web-frontend/modules/core/services/health.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
export default (client) => {
|
||||
return {
|
||||
getAll() {
|
||||
return client.get('/_health/full/')
|
||||
},
|
||||
testEmail(targetEmail) {
|
||||
return client.post('/_health/email/', {
|
||||
target_email: targetEmail,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue