Add a hook for sending push notifications via ntfy.sh.
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/543
This commit is contained in:
commit
7648bcff39
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/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']
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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
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).
|
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
|
||||||
|
|
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