Added healthcheck endpoint /status (#185)

This commit is contained in:
Chris Caron 2024-05-11 14:59:02 -04:00 committed by GitHub
parent 3a710fbbd1
commit 135dd4e98d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 435 additions and 14 deletions

View File

@ -132,6 +132,45 @@ The following architectures are supported: `amd64`, `arm/v7`, and `arm64`. The f
## API Details ## 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 ### 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: 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:

View File

@ -22,15 +22,15 @@
<h5>{% trans "Getting Started" %}</h5> <h5>{% trans "Getting Started" %}</h5>
<ol> <ol>
<li> <li>
{% blocktrans %} {% trans "Verify your Apprise API Status:" %} <strong><a href="{% url 'health' %}">click here</a></strong>
Here is where you can store your Apprise configuration associated with the key <code>{{key}}</code>. </li>
{% endblocktrans %} <li>
For some examples on how to build a development environment around this, <strong><a href="{% url 'welcome'%}?key={{key}}">click here</a></strong>. {% trans "Here is where you can store your Apprise configuration associated with the key:" %} <code>{{key}}</code>
{% trans "For some examples on how to build a development environment around this:" %} <strong><a href="{% url 'welcome'%}?key={{key}}">click here</a></strong>
</li> </li>
<li> <li>
{% blocktrans %} {% blocktrans %}
In the future you can return to this configuration screen at any time by placing the following into your In the future you can return to this configuration screen at any time by placing the following into your browser:
browser:
{% endblocktrans %} {% endblocktrans %}
<code>{{request.scheme}}://{{request.META.HTTP_HOST}}{{BASE_URL}}/cfg/{{key}}</code> <code>{{request.scheme}}://{{request.META.HTTP_HOST}}{{BASE_URL}}/cfg/{{key}}</code>
</li> </li>

View File

@ -132,6 +132,18 @@ class AttachmentTests(SimpleTestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
parse_attachments(None, files_request) 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 # Bad data provided in filename field
files_request = { files_request = {
'file1': SimpleUploadedFile( 'file1': SimpleUploadedFile(

View File

@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 Chris Caron <lead2gold@gmail.com>
# 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',
]}

View File

@ -197,7 +197,6 @@ class NotifyPayloadMapper(SimpleTestCase):
'body': 'the message', 'body': 'the message',
} }
# #
# mapping of fields don't align - test 6 # mapping of fields don't align - test 6
# #

View File

@ -129,7 +129,7 @@ class StatelessNotifyTests(SimpleTestCase):
# We sent the notification successfully (use our rule mapping) # We sent the notification successfully (use our rule mapping)
# FORM # FORM
response = self.client.post( response = self.client.post(
f'/notify/?:payload=body&:fmt=format&:extra=urls', '/notify/?:payload=body&:fmt=format&:extra=urls',
form_data) form_data)
assert response.status_code == 200 assert response.status_code == 200
assert mock_notify.call_count == 1 assert mock_notify.call_count == 1

View File

@ -85,7 +85,21 @@ class WebhookTests(SimpleTestCase):
mock_post.reset_mock() mock_post.reset_mock()
with override_settings(APPRISE_WEBHOOK_URL='invalid'): 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({}) send_webhook({})
assert mock_post.call_count == 0 assert mock_post.call_count == 0
@ -93,7 +107,7 @@ class WebhookTests(SimpleTestCase):
with override_settings( with override_settings(
APPRISE_WEBHOOK_URL='invalid://hostname'): APPRISE_WEBHOOK_URL='invalid://hostname'):
# Invalid wbhook defined # Invalid webhook defined
send_webhook({}) send_webhook({})
assert mock_post.call_count == 0 assert mock_post.call_count == 0

View File

@ -29,6 +29,9 @@ urlpatterns = [
re_path( re_path(
r'^$', r'^$',
views.WelcomeView.as_view(), name='welcome'), views.WelcomeView.as_view(), name='welcome'),
re_path(
r'^status/?$',
views.HealthCheckView.as_view(), name='health'),
re_path( re_path(
r'^details/?$', r'^details/?$',
views.DetailsView.as_view(), name='details'), views.DetailsView.as_view(), name='details'),

View File

@ -229,6 +229,9 @@ def parse_attachments(attachment_payload, files_request):
attachment_payload = (attachment_payload, ) attachment_payload = (attachment_payload, )
count += 1 count += 1
if settings.APPRISE_ATTACH_SIZE <= 0:
raise ValueError("The attachment size is restricted to 0MB")
if settings.APPRISE_MAX_ATTACHMENTS <= 0 or \ if settings.APPRISE_MAX_ATTACHMENTS <= 0 or \
(settings.APPRISE_MAX_ATTACHMENTS > 0 and (settings.APPRISE_MAX_ATTACHMENTS > 0 and
count > settings.APPRISE_MAX_ATTACHMENTS): count > settings.APPRISE_MAX_ATTACHMENTS):
@ -707,8 +710,11 @@ def send_webhook(payload):
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
if not apprise.utils.VALID_URL_RE.match( try:
settings.APPRISE_WEBHOOK_URL): if not apprise.utils.VALID_URL_RE.match(settings.APPRISE_WEBHOOK_URL).group('schema'):
raise AttributeError()
except (AttributeError, TypeError):
logger.warning( logger.warning(
'The Apprise Webhook Result URL is not a valid web based URI') 'The Apprise Webhook Result URL is not a valid web based URI')
return return
@ -750,3 +756,88 @@ def send_webhook(payload):
logger.debug('Socket Exception: %s' % str(e)) logger.debug('Socket Exception: %s' % str(e))
return 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

View File

@ -40,6 +40,7 @@ from .utils import parse_attachments
from .utils import ConfigCache from .utils import ConfigCache
from .utils import apply_global_filters from .utils import apply_global_filters
from .utils import send_webhook from .utils import send_webhook
from .utils import healthcheck
from .forms import AddByUrlForm from .forms import AddByUrlForm
from .forms import AddByConfigForm from .forms import AddByConfigForm
from .forms import NotifyForm from .forms import NotifyForm
@ -116,6 +117,7 @@ class ResponseCode(object):
not_found = 404 not_found = 404
method_not_allowed = 405 method_not_allowed = 405
method_not_accepted = 406 method_not_accepted = 406
expectation_failed = 417
failed_dependency = 424 failed_dependency = 424
fields_too_large = 431 fields_too_large = 431
internal_server_error = 500 internal_server_error = 500
@ -136,6 +138,44 @@ class WelcomeView(View):
}) })
@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') @method_decorator((gzip_page, never_cache), name='dispatch')
class DetailsView(View): class DetailsView(View):
""" """
@ -664,7 +704,7 @@ class NotifyView(View):
MIME_IS_JSON.match(request.headers.get('accept', '')) is not None MIME_IS_JSON.match(request.headers.get('accept', '')) is not None
# rules # 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 # our content
content = {} content = {}
@ -1187,7 +1227,7 @@ class StatelessNotifyView(View):
MIME_IS_JSON.match(request.headers.get('accept', '')) is not None MIME_IS_JSON.match(request.headers.get('accept', '')) is not None
# rules # 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 # our content
content = {} content = {}