فهرست منبع

Add a hook for sending push notifications via ntfy.sh.

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/543
Dan Helfman 3 سال پیش
والد
کامیت
7648bcff39

+ 101 - 0
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']

+ 15 - 5
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,
 }
 
 

+ 1 - 1
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):

+ 73 - 0
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

+ 46 - 0
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

+ 135 - 0
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,
+    )