add a hook for sending push notifications via ntfy.sh #543
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
witten marked this conversation as resolved
Outdated
witten
commented
Might not need to 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.
g-a-c
commented
It is an 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?
witten
commented
Right you are! My bad.. I didn't remember that the 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',
|
||||
witten
commented
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}')
|
||||
witten
commented
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.)
g-a-c
commented
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
|
|
@ -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
|
||||
witten
commented
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
|
||||
|
|
|
@ -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():
|
||||
witten
commented
Could all be on one line. (Do not feel strongly.) Similar elsewhere. Could all be on one line. (Do not feel strongly.)
Similar elsewhere.
g-a-c
commented
I think one of the auto-formatters may have done this, as I don't remember doing it for these simple single-element I'll clean it up and see if it comes back when 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,
|
||||
)
|
This is not really necessary, but had to be done to keep
flake8
happy because otherwiseborgmatic.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 onmodule.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!