From 87b9ba63fc450f1d14f11a05b50c2132b534233b Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 21 Apr 2024 18:13:56 -0400 Subject: [PATCH] Added healthcheck endpoint /status --- README.md | 39 ++++++++++++++++ apprise_api/api/templates/config.html | 12 ++--- apprise_api/api/urls.py | 3 ++ apprise_api/api/utils.py | 65 +++++++++++++++++++++++++++ apprise_api/api/views.py | 40 +++++++++++++++++ 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1fd2138..744bc2d 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,45 @@ The following architectures are supported: `amd64`, `arm/v7`, and `arm64`. The f ## API Details +### Health Checks + +You can perform status or health checks on your server configuration by accessing `/status`. + +| Path | Method | Description | +|------------- | ------ | ----------- | +| `/status` | GET | Simply returns a server status. The server http response code is a `200` if the server is working correcty and a `417` if there was an unexpected issue. You can set the `Accept` header to `application/json` or `text/plain` for different response outputs. + +Below is a sample of just a simple text response: +```bash +# Request a general text response +# Output will read `OK` if everything is fine, otherwise it will return +# one or more of the following separated by a comma: +# - ATTACH_PERMISSION_ISSUE: Can not write attachments (likely a permission issue) +# - CONFIG_PERMISSION_ISSUE: Can not write configuration (likely a permission issue) +curl -X GET http://localhost:8000/status +``` + +Below is a sample of a JSON response: +```bash +curl -X GET -H "Accept: application/json" http://localhost:8000/status +``` +The above output may look like this: +```json +{ + "config_lock": false, + "status": { + "can_write_config": true, + "can_write_attach": true, + "details": ["OK"] + } +} +``` + +- The `config_lock` always cross references if the `APPRISE_CONFIG_LOCK` is enabled or not. +- The `status.can_write_config` defines if the configuration directory is writable or not. If the environment variable `APPRISE_STATEFUL_MODE` is set to `disabled`, this value will always read `false` and it will not impact the `status.details` +- The `status.can_write_attach` defines if the attachment directory is writable or not. If the environment variable `APPRISE_ATTACH_SIZE` or `APPRISE_MAX_ATTACHMENTS` is set to `0` (zero) or lower, this value will always read `false` and it will not impact the `status.details`. +- The `status.details` identifies the overall status. If there is more then 1 issue to report here, they will all show in this list. In a working orderly environment, this will always be set to `OK` and the http response type will be `200`. + ### Stateless Solution Some people may wish to only have a sidecar solution that does require use of any persistent storage. The following API endpoint can be used to directly send a notification of your choice to any of the [supported services by Apprise](https://github.com/caronc/apprise/wiki) without any storage based requirements: diff --git a/apprise_api/api/templates/config.html b/apprise_api/api/templates/config.html index 1fd7f11..abe8a55 100644 --- a/apprise_api/api/templates/config.html +++ b/apprise_api/api/templates/config.html @@ -22,15 +22,15 @@

{% trans "Management for Config ID:" %} {{ key }}{% trans "Getting Started" %}

  1. - {% blocktrans %} - Here is where you can store your Apprise configuration associated with the key {{key}}. - {% endblocktrans %} - For some examples on how to build a development environment around this, click here. + {% trans "Verify your Apprise API Status:" %} click here +
  2. +
  3. + {% trans "Here is where you can store your Apprise configuration associated with the key:" %} {{key}} + {% trans "For some examples on how to build a development environment around this:" %} click here
  4. {% blocktrans %} - In the future you can return to this configuration screen at any time by placing the following into your - browser: + In the future you can return to this configuration screen at any time by placing the following into your browser: {% endblocktrans %} {{request.scheme}}://{{request.META.HTTP_HOST}}{{BASE_URL}}/cfg/{{key}}
  5. diff --git a/apprise_api/api/urls.py b/apprise_api/api/urls.py index aae908e..0cc8a08 100644 --- a/apprise_api/api/urls.py +++ b/apprise_api/api/urls.py @@ -29,6 +29,9 @@ re_path( r'^$', views.WelcomeView.as_view(), name='welcome'), + re_path( + r'^status/?$', + views.HealthCheckView.as_view(), name='health'), re_path( r'^details/?$', views.DetailsView.as_view(), name='details'), diff --git a/apprise_api/api/utils.py b/apprise_api/api/utils.py index 32f86b4..8498c2a 100644 --- a/apprise_api/api/utils.py +++ b/apprise_api/api/utils.py @@ -229,6 +229,9 @@ def parse_attachments(attachment_payload, files_request): attachment_payload = (attachment_payload, ) count += 1 + if settings.APPRISE_ATTACH_SIZE <= 0: + raise ValueError("The attachment size is restricted to 0MB") + if settings.APPRISE_MAX_ATTACHMENTS <= 0 or \ (settings.APPRISE_MAX_ATTACHMENTS > 0 and count > settings.APPRISE_MAX_ATTACHMENTS): @@ -750,3 +753,65 @@ def send_webhook(payload): logger.debug('Socket Exception: %s' % str(e)) return + +def healthcheck(): + """ + Runs a status check on the data and returns the statistics + """ + + # Some status variables we can flip + response = { + 'can_write_config': False, + 'can_write_attach': False, + 'details': [], + } + + if ConfigCache.mode != AppriseStoreMode.DISABLED: + # Update our Configuration Check Block + path = os.path.join(ConfigCache.root, '.tmp_healthcheck') + try: + os.makedirs(path, exist_ok=True) + + # Write a small file + with tempfile.TemporaryFile(mode='w+b', dir=path) as fp: + # Test writing 1 block + fp.write(b'.') + # Read it back + fp.seek(0) + fp.read(1) == b'.' + # Toggle our status + response['can_write_config'] = True + + except OSError: + # We can take an early exit + response['details'].append('CONFIG_PERMISSION_ISSUE') + + if settings.APPRISE_MAX_ATTACHMENTS > 0 and settings.APPRISE_ATTACH_SIZE > 0: + # Test our ability to access write attachments + + # Update our Configuration Check Block + path = os.path.join(settings.APPRISE_ATTACH_DIR, '.tmp_healthcheck') + try: + os.makedirs(path, exist_ok=True) + + # Write a small file + with tempfile.TemporaryFile(mode='w+b', dir=path) as fp: + # Test writing 1 block + fp.write(b'.') + # Read it back + fp.seek(0) + fp.read(1) == b'.' + # Toggle our status + response['can_write_attach'] = True + + except OSError: + # We can take an early exit + response['details'].append('ATTACH_PERMISSION_ISSUE') + + + if not response['details']: + response['details'].append('OK') + + return response + + diff --git a/apprise_api/api/views.py b/apprise_api/api/views.py index 281e68c..7f57359 100644 --- a/apprise_api/api/views.py +++ b/apprise_api/api/views.py @@ -39,6 +39,7 @@ from .utils import ConfigCache from .utils import apply_global_filters from .utils import send_webhook +from .utils import healthcheck from .forms import AddByUrlForm from .forms import AddByConfigForm from .forms import NotifyForm @@ -115,6 +116,7 @@ class ResponseCode(object): not_found = 404 method_not_allowed = 405 method_not_accepted = 406 + expectation_failed = 417 failed_dependency = 424 fields_too_large = 431 internal_server_error = 500 @@ -135,6 +137,44 @@ def get(self, request): }) +@method_decorator((gzip_page, never_cache), name='dispatch') +class HealthCheckView(View): + """ + A Django view used to return a simple healthcheck + """ + + def get(self, request): + """ + Handle a GET request + """ + # Detect the format our incoming payload + json_payload = \ + MIME_IS_JSON.match( + request.content_type + if request.content_type + else request.headers.get( + 'content-type', '')) is not None + + # Detect the format our response should be in + json_response = True if json_payload \ + and ACCEPT_ALL.match(request.headers.get('accept', '')) else \ + MIME_IS_JSON.match(request.headers.get('accept', '')) is not None + + # Run our healthcheck + response = healthcheck() + + # Prepare our response + status = ResponseCode.okay if 'OK' in response['details'] else ResponseCode.expectation_failed + if not json_response: + response = ','.join(response['details']) + + return HttpResponse(response, status=status, content_type='text/plain') \ + if not json_response else JsonResponse({ + 'config_lock': settings.APPRISE_CONFIG_LOCK, + 'status': response, + }, encoder=JSONEncoder, safe=False, status=status) + + @method_decorator((gzip_page, never_cache), name='dispatch') class DetailsView(View): """