Healthcheck web improvements + PUID & PGID support added to Docker (#198)

This commit is contained in:
Chris Caron
2024-06-29 22:07:12 -04:00
committed by GitHub
parent 6e57e33b8f
commit c6b9c1161d
15 changed files with 390 additions and 108 deletions

View File

@ -37,9 +37,9 @@
</a>
<h1>{% trans "Apprise API" %}</h1>
<ul>
<li>APPRISE v{{APPRISE_VERSION}}</li>
<li class="theme"><a href="{{ request.path }}?theme={{request.next_theme}}"><i class="material-icons">invert_colors</i></a></li>
</ul>
<li>APPRISE v{{APPRISE_VERSION}}</li>
<li class="theme"><a href="{{ request.path }}?theme={{request.next_theme}}"><i class="material-icons">invert_colors</i></a></li>
</ul>
</div>
</div>
<!-- Page Layout here -->
@ -50,10 +50,10 @@
<ul class="collection z-depth-1">
<a class="collection-item" href="{% url 'config' DEFAULT_CONFIG_ID %}"><i class="material-icons">settings</i>
{% trans "Configuration Manager" %}</a>
{% if not CONFIG_LOCK %}
{% if not CONFIG_LOCK %}
<a class="collection-item" href="{% url 'config' UNIQUE_CONFIG_ID %}"><i class="material-icons">refresh</i>
{% trans "New Configuration" %}</a>
{% endif %}
{% endif %}
</ul>
{% endif %}
<ul class="collection z-depth-1">
@ -80,6 +80,28 @@
</div>
<div class="col s9">
<div id="health-check" class="section" style="display: none">
<h4><i class="material-icons" style="color: orange">warning</i>&nbsp;{% trans "Apprise Health Check Failed" %}&nbsp;<i class="material-icons" style="color: orange">warning</i></h4>
{% blocktrans %}The following disk access errors have been detected with your Apprise instance{% endblocktrans %}:
<ul>
<li class="can_write_config" style="display: none"><strong>
<i class="material-icons"
style="color: red">cancel</i>&nbsp;{% trans "Configuration Write Failure" %}</strong>
<p>{% blocktrans %}Apprise can not write new configuration information to the directory:{% endblocktrans %} <code>{{CONFIG_DIR}}</code>.</p>
<p>{% blocktrans %}<em>Note:</em> If this is the expected behavior, you should pre-set the environment variable <code>APPRISE_CONFIG_LOCK=yes</code> and reload your Apprise instance.{% endblocktrans %}</p>
</li>
<li class="can_write_attach" style="display: none"><strong>
<i class="material-icons"
style="color: red">cancel</i>&nbsp;{% trans "Attachment Temporary Storage Write Failure" %}</strong>
<p>{% blocktrans %}Apprise can not circulate attachments (if provided) along to supported endpoints due to not having write access to the directory:{% endblocktrans %} <code>{{ATTACH_DIR}}</code>.</p>
<p>{% blocktrans %}<em>Note:</em> If this is the expected behavior, you should pre-set the environment variable <code>APPRISE_ATTACH_SIZE=0</code> and reload your Apprise instance.{% endblocktrans %}</p>
</p>
</li>
</ul>
<p>{% blocktrans %}Under most circumstances, the issue(s) identified here are usually related to permission issues. Make sure you set the correct <code>PUID</code> and <code>GUID</code> to reflect the permissions you wish Apprise to utilize when it is reading and writing its files. In addition to this, you may need to make sure the permissions are set correctly on the directories you mapped them too.{% endblocktrans %}</p>
<p>{% blocktrans %}The issue(s) identified here can also be associated with SELinux too. You may wish to rule out SELinux by first temporarily disabling it using the command <code>setenforce 0</code>. You can always re-enstate it with <code>setenforce 1</code>{% endblocktrans %}.</p>
</div>
{% block body %}{% endblock %}
</div>
@ -91,9 +113,37 @@
M.AutoInit();
// highlightjs
hljs.initHighlightingOnLoad();
{% block onload %} {% endblock %}
{% block onload %}{% endblock %}
// healthcheck
health_check()
});
{% block jsfooter %} {% endblock %}
function health_check() {
// perform our health check
document.querySelector('#health-check').style.display = 'none';
document.querySelector('#health-check li.can_write_config').style.display = 'none';
document.querySelector('#health-check li.can_write_attach').style.display = 'none';
let response = fetch('{% url "health" %}', {
method: 'GET',
headers: {
'Accept': 'application/json;charset=utf-8'
},
}).then(function(response) {
if(response.status != 200)
{
response.json().then(function(content) {
if (content['status']['can_write_config'] === false && content['config_lock'] === false) {
document.querySelector('#health-check li.can_write_config').style.display = '';
}
if (content['status']['can_write_attach'] === false && content['attach_lock'] === false) {
document.querySelector('#health-check li.can_write_attach').style.display = '';
}
document.querySelector('#health-check').style.display = '';
})
}
});
}
</script>
</body>

View File

@ -188,7 +188,7 @@
{% endif %}
{% endblock %}
{% block jsfooter %}
{{ block.super }}
{% if STATEFUL_MODE != 'disabled' %}
function update_count() {
const p_count = document.querySelectorAll('#url-list li.card-panel.selected').length;
@ -695,8 +695,8 @@ function notify_init() {
{% endblock %}
{% block onload %}
{% if STATEFUL_MODE != 'disabled' %}
{{ block.super }}
{% if STATEFUL_MODE != 'disabled' %}
document.querySelector('label [for="id_tag"]')
{
// create a new div with the class 'chips' assigned to it

View File

@ -1,6 +1,5 @@
{% extends 'base.html' %}
{% load i18n %}
{% block body %}
<h4>{% trans "The Apprise API" %}</h4>
<p>

View File

@ -64,6 +64,7 @@ class HealthCheckTests(SimpleTestCase):
content = loads(response.content)
assert content == {
'config_lock': False,
'attach_lock': False,
'status': {
'can_write_config': True,
'can_write_attach': True,
@ -87,6 +88,7 @@ class HealthCheckTests(SimpleTestCase):
content = loads(response.content)
assert content == {
'config_lock': True,
'attach_lock': False,
'status': {
'can_write_config': False,
'can_write_attach': True,
@ -109,6 +111,7 @@ class HealthCheckTests(SimpleTestCase):
content = loads(response.content)
assert content == {
'config_lock': False,
'attach_lock': False,
'status': {
'can_write_config': False,
'can_write_attach': True,
@ -131,6 +134,7 @@ class HealthCheckTests(SimpleTestCase):
content = loads(response.content)
assert content == {
'config_lock': False,
'attach_lock': True,
'status': {
'can_write_config': True,
'can_write_attach': False,
@ -153,9 +157,10 @@ class HealthCheckTests(SimpleTestCase):
content = loads(response.content)
assert content == {
'config_lock': False,
'attach_lock': False,
'status': {
'can_write_config': True,
'can_write_attach': False,
'can_write_attach': True,
'details': ['OK']
}
}

View File

@ -181,6 +181,7 @@ class NotifyTests(SimpleTestCase):
# Reset our mock object
mock_notify.reset_mock()
# A setting of zero means unlimited attachments are allowed
with override_settings(APPRISE_MAX_ATTACHMENTS=0):
# Preare our form data
@ -196,6 +197,67 @@ class NotifyTests(SimpleTestCase):
form = NotifyForm(form_data, attach_data)
assert form.is_valid()
# Send our notification
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)
# We're good!
assert response.status_code == 200
assert mock_notify.call_count == 1
# Reset our mock object
mock_notify.reset_mock()
# Only allow 1 attachment, but we'll attempt to send more...
with override_settings(APPRISE_MAX_ATTACHMENTS=1):
# Preare our form data
form_data = {
'body': 'test notifiction',
}
# At a minimum, just a body is required
form = NotifyForm(form_data)
assert form.is_valid()
# Required to prevent None from being passed into self.client.post()
del form.cleaned_data['attachment']
data = {
**form.cleaned_data,
'file1': SimpleUploadedFile(
"attach1.txt", b"content here", content_type="text/plain"),
'file2': SimpleUploadedFile(
"attach2.txt", b"more content here", content_type="text/plain"),
}
# Send our notification
response = self.client.post(
'/notify/{}'.format(key), data, format='multipart')
# Too many attachments
assert response.status_code == 400
assert mock_notify.call_count == 0
# Reset our mock object
mock_notify.reset_mock()
# A setting of zero means unlimited attachments are allowed
with override_settings(APPRISE_ATTACH_SIZE=0):
# Preare our form data
form_data = {
'body': 'test notifiction',
}
attach_data = {
'attachment': SimpleUploadedFile(
"attach.txt", b"content here", content_type="text/plain")
}
# At a minimum, just a body is required
form = NotifyForm(form_data, attach_data)
assert form.is_valid()
# Send our notification
response = self.client.post(
'/notify/{}'.format(key), form.cleaned_data)

View File

@ -207,6 +207,22 @@ class HTTPAttachment(A_MGR['http']):
pass
def touch(fname, mode=0o666, dir_fd=None, **kwargs):
"""
Acts like a Linux touch and updates a file with a current timestamp
"""
flags = os.O_CREAT | os.O_APPEND
try:
with os.fdopen(os.open(fname, flags=flags, mode=mode, dir_fd=dir_fd)) as f:
os.utime(f.fileno() if os.utime in os.supports_fd else fname,
dir_fd=None if os.supports_fd else dir_fd, **kwargs)
except OSError:
return False
return True
def parse_attachments(attachment_payload, files_request):
"""
Takes the payload provided in a `/notify` call and extracts the
@ -230,15 +246,12 @@ def parse_attachments(attachment_payload, files_request):
count += 1
if settings.APPRISE_ATTACH_SIZE <= 0:
raise ValueError("The attachment size is restricted to 0MB")
raise ValueError("Attachment support has been disabled")
if settings.APPRISE_MAX_ATTACHMENTS <= 0 or \
(settings.APPRISE_MAX_ATTACHMENTS > 0 and
count > settings.APPRISE_MAX_ATTACHMENTS):
if settings.APPRISE_MAX_ATTACHMENTS > 0 and count > settings.APPRISE_MAX_ATTACHMENTS:
raise ValueError(
"There is a maximum of %d attachments" %
settings.APPRISE_MAX_ATTACHMENTS
if settings.APPRISE_MAX_ATTACHMENTS > 0 else 0)
settings.APPRISE_MAX_ATTACHMENTS)
if isinstance(attachment_payload, (tuple, list, set)):
for no, entry in enumerate(attachment_payload, start=1):
@ -772,67 +785,70 @@ def healthcheck(lazy=True):
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')
path = os.path.join(ConfigCache.root, '.tmp_hc')
if lazy:
try:
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
delta = (datetime.now() - modify_date).total_seconds()
if delta <= 7200.00: # 2hrs
if delta <= 30.00: # 30s
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:
# Permission Issue or something else likely
# We can take an early exit
response['details'].append('CONFIG_PERMISSION_ISSUE')
if settings.APPRISE_MAX_ATTACHMENTS > 0 and settings.APPRISE_ATTACH_SIZE > 0:
if not (response['can_write_config'] or 'CONFIG_PERMISSION_ISSUE' in response['details']):
try:
os.makedirs(ConfigCache.root, exist_ok=True)
if touch(path):
# Toggle our status
response['can_write_config'] = True
else:
# We can take an early exit as there is already a permission issue detected
response['details'].append('CONFIG_PERMISSION_ISSUE')
except OSError:
# We can take an early exit as there is already a permission issue detected
response['details'].append('CONFIG_PERMISSION_ISSUE')
if 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')
path = os.path.join(settings.APPRISE_ATTACH_DIR, '.tmp_hc')
if lazy:
try:
modify_date = datetime.fromtimestamp(os.path.getmtime(path))
delta = (datetime.now() - modify_date).total_seconds()
if delta <= 7200.00: # 2hrs
if delta <= 30.00: # 30s
response['can_write_attach'] = True
except FileNotFoundError:
# No worries... continue with below testing
pass
if not response['can_write_attach']:
except OSError:
# We can take an early exit as there is already a permission issue detected
response['details'].append('ATTACH_PERMISSION_ISSUE')
if not (response['can_write_attach'] or 'ATTACH_PERMISSION_ISSUE' in response['details']):
# 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'.'
os.makedirs(settings.APPRISE_ATTACH_DIR, exist_ok=True)
if touch(path):
# Toggle our status
response['can_write_attach'] = True
else:
# We can take an early exit as there is already a permission issue detected
response['details'].append('ATTACH_PERMISSION_ISSUE')
except OSError:
# We can take an early exit
response['details'].append('ATTACH_PERMISSION_ISSUE')

View File

@ -161,8 +161,8 @@ class HealthCheckView(View):
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()
# Run our healthcheck; allow ?force which will cause the check to run each time
response = healthcheck(lazy='force' not in request.GET)
# Prepare our response
status = ResponseCode.okay if 'OK' in response['details'] else ResponseCode.expectation_failed
@ -172,6 +172,7 @@ class HealthCheckView(View):
return HttpResponse(response, status=status, content_type='text/plain') \
if not json_response else JsonResponse({
'config_lock': settings.APPRISE_CONFIG_LOCK,
'attach_lock': settings.APPRISE_ATTACH_SIZE <= 0,
'status': response,
}, encoder=JSONEncoder, safe=False, status=status)