add a hook for sending push notifications via ntfy.sh
This commit is contained in:
parent
3561c93d74
commit
a8b8d507b6
6 changed files with 371 additions and 6 deletions
|
@ -900,6 +900,107 @@ properties:
|
|||
https://docs.mongodb.com/database-tools/mongodump/ and
|
||||
https://docs.mongodb.com/database-tools/mongorestore/ for
|
||||
details.
|
||||
ntfy:
|
||||
type: object
|
||||
required: ['topic']
|
||||
additionalProperties: false
|
||||
properties:
|
||||
topic:
|
||||
type: string
|
||||
description: |
|
||||
The topic to publish to
|
||||
(https://ntfy.sh/docs/publish/)
|
||||
example: topic
|
||||
server:
|
||||
type: string
|
||||
description: |
|
||||
The address of your self-hosted ntfy.sh installation
|
||||
example: https://ntfy.your-domain.com
|
||||
start:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: |
|
||||
The title of the message
|
||||
example: Ping!
|
||||
message:
|
||||
type: string
|
||||
description: |
|
||||
The message body to publish
|
||||
example: Your backups have failed.
|
||||
priority:
|
||||
type: string
|
||||
description: |
|
||||
The priority to set
|
||||
example: urgent
|
||||
tags:
|
||||
type: string
|
||||
description: |
|
||||
Tags to attach to the message
|
||||
example: incoming_envelope
|
||||
finish:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: |
|
||||
The title of the message
|
||||
example: Ping!
|
||||
message:
|
||||
type: string
|
||||
description: |
|
||||
The message body to publish
|
||||
example: Your backups have failed.
|
||||
priority:
|
||||
type: string
|
||||
description: |
|
||||
The priority to set
|
||||
example: urgent
|
||||
tags:
|
||||
type: string
|
||||
description: |
|
||||
Tags to attach to the message
|
||||
example: incoming_envelope
|
||||
fail:
|
||||
type: object
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
description: |
|
||||
The title of the message
|
||||
example: Ping!
|
||||
message:
|
||||
type: string
|
||||
description: |
|
||||
The message body to publish
|
||||
example: Your backups have failed.
|
||||
priority:
|
||||
type: string
|
||||
description: |
|
||||
The priority to set
|
||||
example: urgent
|
||||
tags:
|
||||
type: string
|
||||
description: |
|
||||
Tags to attach to the message
|
||||
example: incoming_envelope
|
||||
states:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
uniqueItems: true
|
||||
description: |
|
||||
List of one or more monitoring states to ping for:
|
||||
"start", "finish", and/or "fail". Defaults to
|
||||
pinging for failure only.
|
||||
example:
|
||||
- start
|
||||
- finish
|
||||
healthchecks:
|
||||
type: object
|
||||
required: ['ping_url']
|
||||
|
|
|
@ -1,17 +1,27 @@
|
|||
import logging
|
||||
|
||||
from borgmatic.hooks import cronhub, cronitor, healthchecks, mongodb, mysql, pagerduty, postgresql
|
||||
from borgmatic.hooks import (
|
||||
cronhub,
|
||||
cronitor,
|
||||
healthchecks,
|
||||
mongodb,
|
||||
mysql,
|
||||
ntfy,
|
||||
pagerduty,
|
||||
postgresql,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOOK_NAME_TO_MODULE = {
|
||||
'healthchecks': healthchecks,
|
||||
'cronitor': cronitor,
|
||||
'cronhub': cronhub,
|
||||
'cronitor': cronitor,
|
||||
'healthchecks': healthchecks,
|
||||
'mongodb_databases': mongodb,
|
||||
'mysql_databases': mysql,
|
||||
'ntfy': ntfy,
|
||||
'pagerduty': pagerduty,
|
||||
'postgresql_databases': postgresql,
|
||||
'mysql_databases': mysql,
|
||||
'mongodb_databases': mongodb,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
from enum import Enum
|
||||
|
||||
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty')
|
||||
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy')
|
||||
|
||||
|
||||
class State(Enum):
|
||||
|
|
73
borgmatic/hooks/ntfy.py
Normal file
73
borgmatic/hooks/ntfy.py
Normal file
|
@ -0,0 +1,73 @@
|
|||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from borgmatic.hooks import monitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MONITOR_STATE_TO_NTFY = {
|
||||
monitor.State.START: None,
|
||||
monitor.State.FINISH: None,
|
||||
monitor.State.FAIL: None,
|
||||
}
|
||||
|
||||
|
||||
def initialize_monitor(
|
||||
ping_url, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Ping the configured Ntfy topic. Use the given configuration filename in any log entries.
|
||||
If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
|
||||
run_states = hook_config.get('states', ['fail'])
|
||||
|
||||
if state.name.lower() in run_states:
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
|
||||
state_config = hook_config.get(
|
||||
state.name.lower(),
|
||||
{
|
||||
'title': f'A Borgmatic {state.name} event happened',
|
||||
'message': f'A Borgmatic {state.name} event happened',
|
||||
'priority': 'default',
|
||||
'tags': 'borgmatic',
|
||||
},
|
||||
)
|
||||
|
||||
base_url = hook_config.get('server', 'https://ntfy.sh')
|
||||
topic = hook_config.get('topic')
|
||||
|
||||
logger.info(f'{config_filename}: Pinging ntfy topic {topic}{dry_run_label}')
|
||||
logger.debug(f'{config_filename}: Using Ntfy ping URL {base_url}/{topic}')
|
||||
|
||||
headers = {
|
||||
'X-Title': state_config.get('title'),
|
||||
'X-Message': state_config.get('message'),
|
||||
'X-Priority': state_config.get('priority'),
|
||||
'X-Tags': state_config.get('tags'),
|
||||
}
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
try:
|
||||
requests.post(f'{base_url}/{topic}', headers=headers)
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Ntfy error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
|
@ -270,6 +270,52 @@ If you have any issues with the integration, [please contact
|
|||
us](https://torsion.org/borgmatic/#support-and-contributing).
|
||||
|
||||
|
||||
## Ntfy hook
|
||||
|
||||
[Ntfy](https://ntfy.sh) is a free, simple, service (either hosted or self-hosted)
|
||||
which offers simple pub/sub push notifications to multiple platforms including
|
||||
[web](https://ntfy.sh/stats), [Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
|
||||
and [iOS](https://apps.apple.com/us/app/ntfy/id1625396347).
|
||||
|
||||
Since push notifications for regular events might soon become quite annoying,
|
||||
this hook only fires on any errors by default in order to instantly alert you to issues.
|
||||
The `states` list can override this.
|
||||
|
||||
As Ntfy is unauthenticated, it isn't a suitable channel for any private information
|
||||
so the default messages are intentionally generic. These can be overridden, depending
|
||||
on your risk assessment. Each `state` can have its own custom messages, priorities and tags
|
||||
or, if none are provided, will use the default.
|
||||
|
||||
An example configuration is shown here, with all the available options, including
|
||||
[priorities](https://ntfy.sh/docs/publish/#message-priority) and
|
||||
[tags](https://ntfy.sh/docs/publish/#tags-emojis):
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
ntfy:
|
||||
topic: my-unique-topic
|
||||
server: https://ntfy.my-domain.com
|
||||
start:
|
||||
title: A Borgmatic backup started
|
||||
message: Watch this space...
|
||||
tags: borgmatic
|
||||
priority: min
|
||||
finish:
|
||||
title: A Borgmatic backup completed successfully
|
||||
message: Nice!
|
||||
tags: borgmatic,+1
|
||||
priority: min
|
||||
fail:
|
||||
title: A Borgmatic backup failed
|
||||
message: You should probably fix it
|
||||
tags: borgmatic,-1,skull
|
||||
priority: max
|
||||
states:
|
||||
- start
|
||||
- finish
|
||||
- fail
|
||||
```
|
||||
|
||||
## Scripting borgmatic
|
||||
|
||||
To consume the output of borgmatic in other software, you can include an
|
||||
|
|
135
tests/unit/hooks/test_ntfy.py
Normal file
135
tests/unit/hooks/test_ntfy.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
from enum import Enum
|
||||
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.hooks import ntfy as module
|
||||
|
||||
default_base_url = 'https://ntfy.sh'
|
||||
custom_base_url = 'https://ntfy.example.com'
|
||||
topic = 'borgmatic-unit-testing'
|
||||
|
||||
custom_message_config = {
|
||||
'title': 'Borgmatic unit testing',
|
||||
'message': 'Borgmatic unit testing',
|
||||
'priority': 'min',
|
||||
'tags': '+1',
|
||||
}
|
||||
|
||||
custom_message_headers = {
|
||||
'X-Title': custom_message_config['title'],
|
||||
'X-Message': custom_message_config['message'],
|
||||
'X-Priority': custom_message_config['priority'],
|
||||
'X-Tags': custom_message_config['tags'],
|
||||
}
|
||||
|
||||
|
||||
def return_default_message_headers(state=Enum):
|
||||
headers = {
|
||||
'X-Title': f'A Borgmatic {state.name} event happened',
|
||||
'X-Message': f'A Borgmatic {state.name} event happened',
|
||||
'X-Priority': 'default',
|
||||
'X-Tags': 'borgmatic',
|
||||
}
|
||||
return headers
|
||||
|
||||
|
||||
def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
|
||||
hook_config = {'topic': topic}
|
||||
flexmock(module.requests).should_receive('post').with_args(
|
||||
f'{default_base_url}/{topic}',
|
||||
headers=return_default_message_headers(module.monitor.State.FAIL),
|
||||
).once()
|
||||
|
||||
module.ping_monitor(
|
||||
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
|
||||
)
|
||||
|
||||
|
||||
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
|
||||
hook_config = {'topic': topic}
|
||||
flexmock(module.requests).should_receive('post').never()
|
||||
|
||||
module.ping_monitor(
|
||||
hook_config,
|
||||
'config.yaml',
|
||||
module.monitor.State.START,
|
||||
monitoring_log_level=1,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
|
||||
hook_config = {'topic': topic}
|
||||
flexmock(module.requests).should_receive('post').never()
|
||||
|
||||
module.ping_monitor(
|
||||
hook_config,
|
||||
'config.yaml',
|
||||
module.monitor.State.FINISH,
|
||||
monitoring_log_level=1,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
|
||||
hook_config = {'topic': topic, 'server': custom_base_url}
|
||||
flexmock(module.requests).should_receive('post').with_args(
|
||||
f'{custom_base_url}/{topic}',
|
||||
headers=return_default_message_headers(module.monitor.State.FAIL),
|
||||
).once()
|
||||
|
||||
module.ping_monitor(
|
||||
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
|
||||
)
|
||||
|
||||
|
||||
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
|
||||
hook_config = {'topic': topic}
|
||||
flexmock(module.requests).should_receive('post').never()
|
||||
|
||||
module.ping_monitor(
|
||||
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=True
|
||||
)
|
||||
|
||||
|
||||
def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
|
||||
hook_config = {'topic': topic, 'fail': custom_message_config}
|
||||
flexmock(module.requests).should_receive('post').with_args(
|
||||
f'{default_base_url}/{topic}', headers=custom_message_headers,
|
||||
).once()
|
||||
|
||||
module.ping_monitor(
|
||||
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
|
||||
)
|
||||
|
||||
|
||||
def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
|
||||
hook_config = {'topic': topic, 'states': ['start', 'fail']}
|
||||
flexmock(module.requests).should_receive('post').with_args(
|
||||
f'{default_base_url}/{topic}',
|
||||
headers=return_default_message_headers(module.monitor.State.START),
|
||||
).once()
|
||||
|
||||
module.ping_monitor(
|
||||
hook_config,
|
||||
'config.yaml',
|
||||
module.monitor.State.START,
|
||||
monitoring_log_level=1,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
def test_ping_monitor_with_connection_error_does_not_raise():
|
||||
hook_config = {'topic': topic}
|
||||
flexmock(module.requests).should_receive('post').with_args(
|
||||
f'{default_base_url}/{topic}',
|
||||
headers=return_default_message_headers(module.monitor.State.FAIL),
|
||||
).and_raise(module.requests.exceptions.ConnectionError)
|
||||
|
||||
module.ping_monitor(
|
||||
hook_config,
|
||||
'config.yaml',
|
||||
module.monitor.State.FAIL,
|
||||
monitoring_log_level=1,
|
||||
dry_run=False,
|
||||
)
|
Loading…
Reference in a new issue