add a hook for sending push notifications via ntfy.sh #543

Merged
witten merged 1 commits from g-a-c/borgmatic:feat/ntfy into master 2022-06-09 20:26:06 +00:00
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/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']

View File

@ -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,
}

View File

@ -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
View File

@ -0,0 +1,73 @@
import logging
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
MONITOR_STATE_TO_NTFY = {
Outdated
Review

This is not really necessary, but had to be done to keep flake8 happy because otherwise borgmatic.hooks.monitor was imported due to being required by the tests, even though it was not referenced within this hook directly.

This is not really necessary, but had to be done to keep `flake8` happy because otherwise `borgmatic.hooks.monitor` was imported due to being required by the tests, even though it was not referenced within this hook directly.

That seems unnecessary to me, given that it's never used...? Your tests can always import directly from borgmatic.hooks.monitor rather than relying on module.monitor. That's just a little convention in borgmatic tests to make sure you're using the exact same import that the code under test is importing. But no need to bend over backwards to do it!

That seems unnecessary to me, given that it's never used...? Your tests can always import directly from `borgmatic.hooks.monitor` rather than relying on `module.monitor`. That's just a little convention in borgmatic tests to make sure you're using the exact same import that the code under test is importing. But no need to bend over backwards to do it!
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:
witten marked this conversation as resolved Outdated

Might not need to .lower() this, since states in configuration is an enum? Meaning there's no way to make those values uppercase..

Similar below.

Might not need to `.lower()` this, since `states` in configuration is an `enum`? Meaning there's no way to make those values uppercase.. Similar below.
Outdated
Review

It is an enum, but my understanding (which appears to be backed up by my testing) is that when it's retrieved, because the enum is capitalised on the left hand side the .name is uppercase, and it feels clunky making the user fill out this state list in the config in uppercase when nothing else is?

It is an `enum`, but my understanding (which _appears_ to be backed up by my testing) is that when it's retrieved, because the `enum` is capitalised on the left hand side the `.name` is uppercase, and it feels clunky making the user fill out this state list in the config in uppercase when nothing else is?

Right you are! My bad.. I didn't remember that the state passed into ping_monitor() didn't come from the configuration schema enum.

Right you are! My bad.. I didn't remember that the `state` passed into `ping_monitor()` didn't come from the configuration schema enum.
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',

You could interpolate the state name into the title/message here. (Do not feel strongly.)

You *could* interpolate the state name into the title/message here. (Do not feel strongly.)
'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}')

You could use a Python f-string here and elsewhere. (Do not feel strongly.)

You could use a Python f-string here and elsewhere. (Do not feel strongly.)
Outdated
Review

Yeah, I actually wasn't aware of the f-string until I was poking around in other modules to try and work out that test issue, but I do find that cleaner to read so I think I'll refactor to use those.

Yeah, I actually wasn't aware of the f-string until I was poking around in other modules to try and work out that test issue, but I do find that cleaner to read so I think I'll refactor to use those.
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).
## 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

Love it. (The skull.)

Love it. (The skull.)
priority: max
states:
- start
- finish
- fail
```
## Scripting borgmatic
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():

Could all be on one line. (Do not feel strongly.)

Similar elsewhere.

Could all be on one line. (Do not feel strongly.) Similar elsewhere.
Outdated
Review

I think one of the auto-formatters may have done this, as I don't remember doing it for these simple single-element dicts.

I'll clean it up and see if it comes back when black or flake8 get run.

I _think_ one of the auto-formatters may have done this, as I don't remember doing it for these simple single-element `dict`s. I'll clean it up and see if it comes back when `black` or `flake8` get run.
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,
)