فهرست منبع

Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic

Dan Helfman 1 سال پیش
والد
کامیت
9e14f209f1
7فایلهای تغییر یافته به همراه390 افزوده شده و 3 حذف شده
  1. 93 0
      borgmatic/config/schema.yaml
  2. 79 0
      borgmatic/hooks/apprise.py
  3. 2 0
      borgmatic/hooks/dispatch.py
  4. 1 1
      borgmatic/hooks/monitor.py
  5. 1 0
      setup.py
  6. 6 2
      test_requirements.txt
  7. 208 0
      tests/unit/hooks/test_apprise.py

+ 93 - 0
borgmatic/config/schema.yaml

@@ -1306,6 +1306,99 @@ properties:
                 example:
                 example:
                     - start
                     - start
                     - finish
                     - finish
+    apprise:
+        type: object
+        required: ['services']
+        additionalProperties: false
+        properties:
+            services:
+                type: array
+                items:
+                    type: object
+                    required:
+                        - url
+                        - label
+                    properties:
+                        url:
+                            type: string
+                            example: "gotify://hostname/token"
+                        label:
+                            type: string
+                            example: mastodon
+                description: |
+                    A list of Apprise services to publish to with URLs
+                    and labels. The labels are used for logging.
+                    A full list of services and their configuration can be found
+                    at https://github.com/caronc/apprise/wiki.
+                example:
+                    - url: "kodi://user@hostname"
+                      label: kodi
+                    - url: "line://Token@User"
+                      label: line
+            start:
+                type: object
+                required: ['body']
+                properties:
+                    title:
+                        type: string
+                        description: |
+                            Specify the message title. If left unspecified, no
+                            title is sent.
+                        example: Ping!
+                    body:
+                        type: string
+                        description: |
+                            Specify the message body.
+                        example: Starting backup process.
+            finish:
+                type: object
+                required: ['body']
+                properties:
+                    title:
+                        type: string
+                        description: |
+                            Specify the message title. If left unspecified, no
+                            title is sent.
+                        example: Ping!
+                    body:
+                        type: string
+                        description: |
+                            Specify the message body.
+                        example: Backups successfully made.
+            fail:
+                type: object
+                required: ['body']
+                properties:
+                    title:
+                        type: string
+                        description: |
+                            Specify the message title. If left unspecified, no
+                            title is sent.
+                        example: Ping!
+                    body:
+                        type: string
+                        description: |
+                            Specify the message body.
+                        example: Your backups have failed.
+            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. For each selected state, corresponding configuration
+                    for the message title and body should be given. If any is
+                    left unspecified, a generic message is emitted instead.
+                example:
+                    - start
+                    - finish
+
     healthchecks:
     healthchecks:
         type: object
         type: object
         required: ['ping_url']
         required: ['ping_url']

+ 79 - 0
borgmatic/hooks/apprise.py

@@ -0,0 +1,79 @@
+import logging
+import operator
+
+logger = logging.getLogger(__name__)
+
+
+def initialize_monitor(
+    ping_url, config, config_filename, monitoring_log_level, dry_run
+):  # pragma: no cover
+    '''
+    No initialization is necessary for this monitor.
+    '''
+    pass
+
+
+def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
+    '''
+    Ping the configured Apprise service URLs. Use the given configuration filename in any log
+    entries. If this is a dry run, then don't actually ping anything.
+    '''
+    try:
+        import apprise
+        from apprise import NotifyFormat, NotifyType
+    except ImportError:  # pragma: no cover
+        logger.warning('Unable to import Apprise in monitoring hook')
+        return
+
+    state_to_notify_type = {
+        'start': NotifyType.INFO,
+        'finish': NotifyType.SUCCESS,
+        'fail': NotifyType.FAILURE,
+        'log': NotifyType.INFO,
+    }
+
+    run_states = hook_config.get('states', ['fail'])
+
+    if state.name.lower() not in run_states:
+        return
+
+    state_config = hook_config.get(
+        state.name.lower(),
+        {
+            'title': f'A borgmatic {state.name} event happened',
+            'body': f'A borgmatic {state.name} event happened',
+        },
+    )
+
+    if not hook_config.get('services'):
+        logger.info(f'{config_filename}: No Apprise services to ping')
+        return
+
+    dry_run_string = ' (dry run; not actually pinging)' if dry_run else ''
+    labels_string = ', '.join(map(operator.itemgetter('label'), hook_config.get('services')))
+    logger.info(f'{config_filename}: Pinging Apprise services: {labels_string}{dry_run_string}')
+
+    apprise_object = apprise.Apprise()
+    apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services'))))
+
+    if dry_run:
+        return
+
+    result = apprise_object.notify(
+        title=state_config.get('title', ''),
+        body=state_config.get('body'),
+        body_format=NotifyFormat.TEXT,
+        notify_type=state_to_notify_type[state.name.lower()],
+    )
+
+    if result is False:
+        logger.warning(f'{config_filename}: Error sending some Apprise notifications')
+
+
+def destroy_monitor(
+    ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
+):  # pragma: no cover
+    '''
+    No destruction is necessary for this monitor.
+    '''
+    pass

+ 2 - 0
borgmatic/hooks/dispatch.py

@@ -1,6 +1,7 @@
 import logging
 import logging
 
 
 from borgmatic.hooks import (
 from borgmatic.hooks import (
+    apprise,
     cronhub,
     cronhub,
     cronitor,
     cronitor,
     healthchecks,
     healthchecks,
@@ -17,6 +18,7 @@ from borgmatic.hooks import (
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 HOOK_NAME_TO_MODULE = {
 HOOK_NAME_TO_MODULE = {
+    'apprise': apprise,
     'cronhub': cronhub,
     'cronhub': cronhub,
     'cronitor': cronitor,
     'cronitor': cronitor,
     'healthchecks': healthchecks,
     'healthchecks': healthchecks,

+ 1 - 1
borgmatic/hooks/monitor.py

@@ -1,6 +1,6 @@
 from enum import Enum
 from enum import Enum
 
 
-MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki')
+MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki')
 
 
 
 
 class State(Enum):
 class State(Enum):

+ 1 - 0
setup.py

@@ -36,6 +36,7 @@ setup(
         'ruamel.yaml>0.15.0,<0.18.0',
         'ruamel.yaml>0.15.0,<0.18.0',
         'setuptools',
         'setuptools',
     ),
     ),
+    extras_require={"Apprise": ["apprise"]},
     include_package_data=True,
     include_package_data=True,
     python_requires='>=3.7',
     python_requires='>=3.7',
 )
 )

+ 6 - 2
test_requirements.txt

@@ -1,6 +1,8 @@
 appdirs==1.4.4; python_version >= '3.8'
 appdirs==1.4.4; python_version >= '3.8'
+apprise==1.3.0
 attrs==22.2.0; python_version >= '3.8'
 attrs==22.2.0; python_version >= '3.8'
 black==23.3.0; python_version >= '3.8'
 black==23.3.0; python_version >= '3.8'
+certifi==2022.9.24
 chardet==5.1.0
 chardet==5.1.0
 click==8.1.3; python_version >= '3.8'
 click==8.1.3; python_version >= '3.8'
 codespell==2.2.4
 codespell==2.2.4
@@ -14,16 +16,18 @@ flexmock==0.11.3
 idna==3.4
 idna==3.4
 importlib_metadata==6.3.0; python_version < '3.8'
 importlib_metadata==6.3.0; python_version < '3.8'
 isort==5.12.0
 isort==5.12.0
+jsonschema==4.17.3
+Markdown==3.4.1
 mccabe==0.7.0
 mccabe==0.7.0
 packaging==23.1
 packaging==23.1
-pluggy==1.0.0
 pathspec==0.11.1; python_version >= '3.8'
 pathspec==0.11.1; python_version >= '3.8'
+pluggy==1.0.0
 py==1.11.0
 py==1.11.0
 pycodestyle==2.10.0
 pycodestyle==2.10.0
 pyflakes==3.0.1
 pyflakes==3.0.1
-jsonschema==4.17.3
 pytest==7.3.0
 pytest==7.3.0
 pytest-cov==4.0.0
 pytest-cov==4.0.0
+PyYAML==6.0
 regex; python_version >= '3.8'
 regex; python_version >= '3.8'
 requests==2.31.0
 requests==2.31.0
 ruamel.yaml>0.15.0,<0.18.0
 ruamel.yaml>0.15.0,<0.18.0

+ 208 - 0
tests/unit/hooks/test_apprise.py

@@ -0,0 +1,208 @@
+import apprise
+from apprise import NotifyFormat, NotifyType
+from flexmock import flexmock
+
+import borgmatic.hooks.monitor
+from borgmatic.hooks import apprise as module
+
+TOPIC = 'borgmatic-unit-testing'
+
+
+def mock_apprise():
+    apprise_mock = flexmock(
+        add=lambda servers: None, notify=lambda title, body, body_format, notify_type: None
+    )
+    flexmock(apprise.Apprise).new_instances(apprise_mock)
+    return apprise_mock
+
+
+def test_ping_monitor_adheres_dry_run():
+    mock_apprise().should_receive('notify').never()
+
+    module.ping_monitor(
+        {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=True,
+    )
+
+
+def test_ping_monitor_does_not_hit_with_no_states():
+    mock_apprise().should_receive('notify').never()
+
+    module.ping_monitor(
+        {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': []},
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=True,
+    )
+
+
+def test_ping_monitor_hits_fail_by_default():
+    mock_apprise().should_receive('notify').with_args(
+        title='A borgmatic FAIL event happened',
+        body='A borgmatic FAIL event happened',
+        body_format=NotifyFormat.TEXT,
+        notify_type=NotifyType.FAILURE,
+    ).once()
+
+    for state in borgmatic.hooks.monitor.State:
+        module.ping_monitor(
+            {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
+            {},
+            'config.yaml',
+            state,
+            monitoring_log_level=1,
+            dry_run=False,
+        )
+
+
+def test_ping_monitor_hits_with_finish_default_config():
+    mock_apprise().should_receive('notify').with_args(
+        title='A borgmatic FINISH event happened',
+        body='A borgmatic FINISH event happened',
+        body_format=NotifyFormat.TEXT,
+        notify_type=NotifyType.SUCCESS,
+    ).once()
+
+    module.ping_monitor(
+        {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['finish']},
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FINISH,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
+def test_ping_monitor_hits_with_start_default_config():
+    mock_apprise().should_receive('notify').with_args(
+        title='A borgmatic START event happened',
+        body='A borgmatic START event happened',
+        body_format=NotifyFormat.TEXT,
+        notify_type=NotifyType.INFO,
+    ).once()
+
+    module.ping_monitor(
+        {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['start']},
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.START,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
+def test_ping_monitor_hits_with_fail_default_config():
+    mock_apprise().should_receive('notify').with_args(
+        title='A borgmatic FAIL event happened',
+        body='A borgmatic FAIL event happened',
+        body_format=NotifyFormat.TEXT,
+        notify_type=NotifyType.FAILURE,
+    ).once()
+
+    module.ping_monitor(
+        {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail']},
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
+def test_ping_monitor_hits_with_log_default_config():
+    mock_apprise().should_receive('notify').with_args(
+        title='A borgmatic LOG event happened',
+        body='A borgmatic LOG event happened',
+        body_format=NotifyFormat.TEXT,
+        notify_type=NotifyType.INFO,
+    ).once()
+
+    module.ping_monitor(
+        {'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['log']},
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.LOG,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
+def test_ping_monitor_passes_through_custom_message_title():
+    mock_apprise().should_receive('notify').with_args(
+        title='foo',
+        body='bar',
+        body_format=NotifyFormat.TEXT,
+        notify_type=NotifyType.FAILURE,
+    ).once()
+
+    module.ping_monitor(
+        {
+            'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}],
+            'states': ['fail'],
+            'fail': {'title': 'foo', 'body': 'bar'},
+        },
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
+def test_ping_monitor_passes_through_custom_message_body():
+    mock_apprise().should_receive('notify').with_args(
+        title='',
+        body='baz',
+        body_format=NotifyFormat.TEXT,
+        notify_type=NotifyType.FAILURE,
+    ).once()
+
+    module.ping_monitor(
+        {
+            'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}],
+            'states': ['fail'],
+            'fail': {'body': 'baz'},
+        },
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
+def test_ping_monitor_pings_multiple_services():
+    mock_apprise().should_receive('add').with_args([f'ntfys://{TOPIC}', f'ntfy://{TOPIC}']).once()
+
+    module.ping_monitor(
+        {
+            'services': [
+                {'url': f'ntfys://{TOPIC}', 'label': 'ntfys'},
+                {'url': f'ntfy://{TOPIC}', 'label': 'ntfy'},
+            ]
+        },
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
+def test_ping_monitor_warning_for_no_services():
+    flexmock(module.logger).should_receive('info').once()
+
+    module.ping_monitor(
+        {'services': []},
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=False,
+    )