add a hook for sending push notifications via ntfy.sh

This commit is contained in:
Gavin Chappell 2022-06-09 21:10:34 +01:00
parent 3561c93d74
commit a8b8d507b6
No known key found for this signature in database
GPG key ID: 9D8FCF658B1F908B
6 changed files with 371 additions and 6 deletions

View file

@ -900,6 +900,107 @@ properties:
https://docs.mongodb.com/database-tools/mongodump/ and https://docs.mongodb.com/database-tools/mongodump/ and
https://docs.mongodb.com/database-tools/mongorestore/ for https://docs.mongodb.com/database-tools/mongorestore/ for
details. 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: healthchecks:
type: object type: object
required: ['ping_url'] required: ['ping_url']

View file

@ -1,17 +1,27 @@
import logging 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__) logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = { HOOK_NAME_TO_MODULE = {
'healthchecks': healthchecks,
'cronitor': cronitor,
'cronhub': cronhub, 'cronhub': cronhub,
'cronitor': cronitor,
'healthchecks': healthchecks,
'mongodb_databases': mongodb,
'mysql_databases': mysql,
'ntfy': ntfy,
'pagerduty': pagerduty, 'pagerduty': pagerduty,
'postgresql_databases': postgresql, 'postgresql_databases': postgresql,
'mysql_databases': mysql,
'mongodb_databases': mongodb,
} }

View file

@ -1,6 +1,6 @@
from enum import Enum from enum import Enum
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty') MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy')
class State(Enum): class State(Enum):

73
borgmatic/hooks/ntfy.py Normal file
View 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

View file

@ -270,6 +270,52 @@ If you have any issues with the integration, [please contact
us](https://torsion.org/borgmatic/#support-and-contributing). 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 ## Scripting borgmatic
To consume the output of borgmatic in other software, you can include an To consume the output of borgmatic in other software, you can include an

View 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,
)