From a8b8d507b62af028465a251bea6408161d599b1c Mon Sep 17 00:00:00 2001 From: Gavin Chappell Date: Thu, 9 Jun 2022 21:10:34 +0100 Subject: [PATCH] add a hook for sending push notifications via ntfy.sh --- borgmatic/config/schema.yaml | 101 +++++++++++++++++++++ borgmatic/hooks/dispatch.py | 20 +++-- borgmatic/hooks/monitor.py | 2 +- borgmatic/hooks/ntfy.py | 73 +++++++++++++++ docs/how-to/monitor-your-backups.md | 46 ++++++++++ tests/unit/hooks/test_ntfy.py | 135 ++++++++++++++++++++++++++++ 6 files changed, 371 insertions(+), 6 deletions(-) create mode 100644 borgmatic/hooks/ntfy.py create mode 100644 tests/unit/hooks/test_ntfy.py diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index c472b8f..a738e4e 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -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'] diff --git a/borgmatic/hooks/dispatch.py b/borgmatic/hooks/dispatch.py index a689e70..ebe52c4 100644 --- a/borgmatic/hooks/dispatch.py +++ b/borgmatic/hooks/dispatch.py @@ -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, } diff --git a/borgmatic/hooks/monitor.py b/borgmatic/hooks/monitor.py index c4cf576..846fca1 100644 --- a/borgmatic/hooks/monitor.py +++ b/borgmatic/hooks/monitor.py @@ -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): diff --git a/borgmatic/hooks/ntfy.py b/borgmatic/hooks/ntfy.py new file mode 100644 index 0000000..54e89f0 --- /dev/null +++ b/borgmatic/hooks/ntfy.py @@ -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 diff --git a/docs/how-to/monitor-your-backups.md b/docs/how-to/monitor-your-backups.md index 167c36f..ea9f001 100644 --- a/docs/how-to/monitor-your-backups.md +++ b/docs/how-to/monitor-your-backups.md @@ -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 diff --git a/tests/unit/hooks/test_ntfy.py b/tests/unit/hooks/test_ntfy.py new file mode 100644 index 0000000..ec89136 --- /dev/null +++ b/tests/unit/hooks/test_ntfy.py @@ -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, + )