From 135dd4e98d80a715bfe94709243add4dbc077e9a Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 11 May 2024 14:59:02 -0400 Subject: [PATCH] Added healthcheck endpoint /status (#185) --- README.md | 39 +++ apprise_api/api/templates/config.html | 12 +- apprise_api/api/tests/test_attachment.py | 12 + apprise_api/api/tests/test_healthecheck.py | 223 ++++++++++++++++++ apprise_api/api/tests/test_payload_mapper.py | 1 - .../api/tests/test_stateless_notify.py | 2 +- apprise_api/api/tests/test_webhook.py | 18 +- apprise_api/api/urls.py | 3 + apprise_api/api/utils.py | 95 +++++++- apprise_api/api/views.py | 44 +++- 10 files changed, 435 insertions(+), 14 deletions(-) create mode 100644 apprise_api/api/tests/test_healthecheck.py diff --git a/README.md b/README.md index 021797b..20b6e2c 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,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/tests/test_attachment.py b/apprise_api/api/tests/test_attachment.py index af6fd22..73d6fec 100644 --- a/apprise_api/api/tests/test_attachment.py +++ b/apprise_api/api/tests/test_attachment.py @@ -132,6 +132,18 @@ def test_form_file_attachment_parsing(self): with self.assertRaises(ValueError): parse_attachments(None, files_request) + # Test Attachment Size seto t zer0 + with override_settings(APPRISE_ATTACH_SIZE=0): + files_request = { + 'file1': SimpleUploadedFile( + "attach.txt", + # More then 1 MB in size causing error to trip + ("content" * 1024 * 1024).encode('utf-8'), + content_type="text/plain") + } + with self.assertRaises(ValueError): + parse_attachments(None, files_request) + # Bad data provided in filename field files_request = { 'file1': SimpleUploadedFile( diff --git a/apprise_api/api/tests/test_healthecheck.py b/apprise_api/api/tests/test_healthecheck.py new file mode 100644 index 0000000..2d65eba --- /dev/null +++ b/apprise_api/api/tests/test_healthecheck.py @@ -0,0 +1,223 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import mock +from django.test import SimpleTestCase +from json import loads +from django.test.utils import override_settings +from ..utils import healthcheck + + +class HealthCheckTests(SimpleTestCase): + + def test_post_not_supported(self): + """ + Test POST requests + """ + response = self.client.post('/status') + # 405 as posting is not allowed + assert response.status_code == 405 + + def test_healthcheck_simple(self): + """ + Test retrieving basic successful health-checks + """ + + # First Status Check + response = self.client.get('/status') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'OK') + assert response['Content-Type'].startswith('text/plain') + + # Second Status Check (Lazy Mode kicks in) + response = self.client.get('/status') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'OK') + assert response['Content-Type'].startswith('text/plain') + + # JSON Response + response = self.client.get( + '/status', content_type='application/json', + **{'HTTP_CONTENT_TYPE': 'application/json'}) + self.assertEqual(response.status_code, 200) + content = loads(response.content) + assert content == { + 'config_lock': False, + 'status': { + 'can_write_config': True, + 'can_write_attach': True, + 'details': ['OK'] + } + } + assert response['Content-Type'].startswith('application/json') + + with override_settings(APPRISE_CONFIG_LOCK=True): + # Status Check (Form based) + response = self.client.get('/status') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'OK') + assert response['Content-Type'].startswith('text/plain') + + # JSON Response + response = self.client.get( + '/status', content_type='application/json', + **{'HTTP_CONTENT_TYPE': 'application/json'}) + self.assertEqual(response.status_code, 200) + content = loads(response.content) + assert content == { + 'config_lock': True, + 'status': { + 'can_write_config': False, + 'can_write_attach': True, + 'details': ['OK'] + } + } + + with override_settings(APPRISE_STATEFUL_MODE='disabled'): + # Status Check (Form based) + response = self.client.get('/status') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'OK') + assert response['Content-Type'].startswith('text/plain') + + # JSON Response + response = self.client.get( + '/status', content_type='application/json', + **{'HTTP_CONTENT_TYPE': 'application/json'}) + self.assertEqual(response.status_code, 200) + content = loads(response.content) + assert content == { + 'config_lock': False, + 'status': { + 'can_write_config': False, + 'can_write_attach': True, + 'details': ['OK'] + } + } + + with override_settings(APPRISE_ATTACH_SIZE=0): + # Status Check (Form based) + response = self.client.get('/status') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'OK') + assert response['Content-Type'].startswith('text/plain') + + # JSON Response + response = self.client.get( + '/status', content_type='application/json', + **{'HTTP_CONTENT_TYPE': 'application/json'}) + self.assertEqual(response.status_code, 200) + content = loads(response.content) + assert content == { + 'config_lock': False, + 'status': { + 'can_write_config': True, + 'can_write_attach': False, + 'details': ['OK'] + } + } + + with override_settings(APPRISE_MAX_ATTACHMENTS=0): + # Status Check (Form based) + response = self.client.get('/status') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b'OK') + assert response['Content-Type'].startswith('text/plain') + + # JSON Response + response = self.client.get( + '/status', content_type='application/json', + **{'HTTP_CONTENT_TYPE': 'application/json'}) + self.assertEqual(response.status_code, 200) + content = loads(response.content) + assert content == { + 'config_lock': False, + 'status': { + 'can_write_config': True, + 'can_write_attach': False, + 'details': ['OK'] + } + } + + def test_healthcheck_library(self): + """ + Test underlining healthcheck library + """ + + result = healthcheck(lazy=True) + assert result == { + 'can_write_config': True, + 'can_write_attach': True, + 'details': ['OK'] + } + + # A Double lazy check + result = healthcheck(lazy=True) + assert result == { + 'can_write_config': True, + 'can_write_attach': True, + 'details': ['OK'] + } + + # Force a lazy check where we can't acquire the modify time + with mock.patch('os.path.getmtime') as mock_getmtime: + mock_getmtime.side_effect = FileNotFoundError() + result = healthcheck(lazy=True) + # We still succeed; we just don't leverage our lazy check + # which prevents addition (unnessisary) writes + assert result == { + 'can_write_config': True, + 'can_write_attach': True, + 'details': ['OK'], + } + + # Force a non-lazy check + with mock.patch('os.makedirs') as mock_makedirs: + mock_makedirs.side_effect = OSError() + result = healthcheck(lazy=False) + assert result == { + 'can_write_config': False, + 'can_write_attach': False, + 'details': [ + 'CONFIG_PERMISSION_ISSUE', + 'ATTACH_PERMISSION_ISSUE', + ]} + + mock_makedirs.side_effect = (None, OSError()) + result = healthcheck(lazy=False) + assert result == { + 'can_write_config': True, + 'can_write_attach': False, + 'details': [ + 'ATTACH_PERMISSION_ISSUE', + ]} + + mock_makedirs.side_effect = (OSError(), None) + result = healthcheck(lazy=False) + assert result == { + 'can_write_config': False, + 'can_write_attach': True, + 'details': [ + 'CONFIG_PERMISSION_ISSUE', + ]} diff --git a/apprise_api/api/tests/test_payload_mapper.py b/apprise_api/api/tests/test_payload_mapper.py index da7dd4a..9845e7f 100644 --- a/apprise_api/api/tests/test_payload_mapper.py +++ b/apprise_api/api/tests/test_payload_mapper.py @@ -197,7 +197,6 @@ def test_remap_fields(self): 'body': 'the message', } - # # mapping of fields don't align - test 6 # diff --git a/apprise_api/api/tests/test_stateless_notify.py b/apprise_api/api/tests/test_stateless_notify.py index 3eccb71..18804fd 100644 --- a/apprise_api/api/tests/test_stateless_notify.py +++ b/apprise_api/api/tests/test_stateless_notify.py @@ -129,7 +129,7 @@ def test_notify(self, mock_notify): # We sent the notification successfully (use our rule mapping) # FORM response = self.client.post( - f'/notify/?:payload=body&:fmt=format&:extra=urls', + '/notify/?:payload=body&:fmt=format&:extra=urls', form_data) assert response.status_code == 200 assert mock_notify.call_count == 1 diff --git a/apprise_api/api/tests/test_webhook.py b/apprise_api/api/tests/test_webhook.py index a46859a..51ce44a 100644 --- a/apprise_api/api/tests/test_webhook.py +++ b/apprise_api/api/tests/test_webhook.py @@ -85,7 +85,21 @@ def test_webhook_testing(self, mock_post): mock_post.reset_mock() with override_settings(APPRISE_WEBHOOK_URL='invalid'): - # Invalid wbhook defined + # Invalid webhook defined + send_webhook({}) + assert mock_post.call_count == 0 + + mock_post.reset_mock() + + with override_settings(APPRISE_WEBHOOK_URL=None): + # Invalid webhook defined + send_webhook({}) + assert mock_post.call_count == 0 + + mock_post.reset_mock() + + with override_settings(APPRISE_WEBHOOK_URL='http://$#@'): + # Invalid hostname defined send_webhook({}) assert mock_post.call_count == 0 @@ -93,7 +107,7 @@ def test_webhook_testing(self, mock_post): with override_settings( APPRISE_WEBHOOK_URL='invalid://hostname'): - # Invalid wbhook defined + # Invalid webhook defined send_webhook({}) assert mock_post.call_count == 0 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..b0e28d8 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): @@ -707,8 +710,11 @@ def send_webhook(payload): 'Content-Type': 'application/json', } - if not apprise.utils.VALID_URL_RE.match( - settings.APPRISE_WEBHOOK_URL): + try: + if not apprise.utils.VALID_URL_RE.match(settings.APPRISE_WEBHOOK_URL).group('schema'): + raise AttributeError() + + except (AttributeError, TypeError): logger.warning( 'The Apprise Webhook Result URL is not a valid web based URI') return @@ -750,3 +756,88 @@ def send_webhook(payload): logger.debug('Socket Exception: %s' % str(e)) return + + +def healthcheck(lazy=True): + """ + 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 not (settings.APPRISE_STATEFUL_MODE == AppriseStoreMode.DISABLED or settings.APPRISE_CONFIG_LOCK): + # Update our Configuration Check Block + path = os.path.join(ConfigCache.root, '.tmp_healthcheck') + if lazy: + try: + modify_date = datetime.fromtimestamp(os.path.getmtime(path)) + delta = (datetime.now() - modify_date).total_seconds() + if delta <= 7200.00: # 2hrs + response['can_write_config'] = True + + except FileNotFoundError: + # No worries... continue with below testing + pass + + if not response['can_write_config']: + 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') + if lazy: + try: + modify_date = datetime.fromtimestamp(os.path.getmtime(path)) + delta = (datetime.now() - modify_date).total_seconds() + if delta <= 7200.00: # 2hrs + response['can_write_attach'] = True + + except FileNotFoundError: + # No worries... continue with below testing + pass + + if not response['can_write_attach']: + # No lazy mode set or content require a refresh + 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 3e569c2..4d42865 100644 --- a/apprise_api/api/views.py +++ b/apprise_api/api/views.py @@ -40,6 +40,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 @@ -116,6 +117,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 @@ -136,6 +138,44 @@ def get(self, request): }) +@method_decorator((gzip_page, never_cache), name='dispatch') +class HealthCheckView(View): + """ + A Django view used to return a simple status/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): """ @@ -664,7 +704,7 @@ def post(self, request, key): MIME_IS_JSON.match(request.headers.get('accept', '')) is not None # rules - rules = {k[1:]: v for k,v in request.GET.items() if k[0] == ':'} + rules = {k[1:]: v for k, v in request.GET.items() if k[0] == ':'} # our content content = {} @@ -1187,7 +1227,7 @@ def post(self, request): MIME_IS_JSON.match(request.headers.get('accept', '')) is not None # rules - rules = {k[1:]: v for k,v in request.GET.items() if k[0] == ':'} + rules = {k[1:]: v for k, v in request.GET.items() if k[0] == ':'} # our content content = {}