diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index a2ba64eb..f8a10d6f 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1306,6 +1306,99 @@ properties: example: - start - 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: type: object required: ['ping_url'] diff --git a/borgmatic/hooks/apprise.py b/borgmatic/hooks/apprise.py new file mode 100644 index 00000000..212bf7df --- /dev/null +++ b/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 diff --git a/borgmatic/hooks/dispatch.py b/borgmatic/hooks/dispatch.py index 24793b5a..d437c980 100644 --- a/borgmatic/hooks/dispatch.py +++ b/borgmatic/hooks/dispatch.py @@ -1,6 +1,7 @@ import logging from borgmatic.hooks import ( + apprise, cronhub, cronitor, healthchecks, @@ -17,6 +18,7 @@ from borgmatic.hooks import ( logger = logging.getLogger(__name__) HOOK_NAME_TO_MODULE = { + 'apprise': apprise, 'cronhub': cronhub, 'cronitor': cronitor, 'healthchecks': healthchecks, diff --git a/borgmatic/hooks/monitor.py b/borgmatic/hooks/monitor.py index 118639f5..0cbfef4b 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', 'ntfy', 'loki') +MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki') class State(Enum): diff --git a/setup.py b/setup.py index c9c16d37..f1b7bc4a 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ setup( 'ruamel.yaml>0.15.0,<0.18.0', 'setuptools', ), + extras_require={"Apprise": ["apprise"]}, include_package_data=True, python_requires='>=3.7', ) diff --git a/test_requirements.txt b/test_requirements.txt index 56160dc8..138de968 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,6 +1,8 @@ appdirs==1.4.4; python_version >= '3.8' +apprise==1.3.0 attrs==22.2.0; python_version >= '3.8' black==23.3.0; python_version >= '3.8' +certifi==2022.9.24 chardet==5.1.0 click==8.1.3; python_version >= '3.8' codespell==2.2.4 @@ -14,16 +16,18 @@ flexmock==0.11.3 idna==3.4 importlib_metadata==6.3.0; python_version < '3.8' isort==5.12.0 +jsonschema==4.17.3 +Markdown==3.4.1 mccabe==0.7.0 packaging==23.1 -pluggy==1.0.0 pathspec==0.11.1; python_version >= '3.8' +pluggy==1.0.0 py==1.11.0 pycodestyle==2.10.0 pyflakes==3.0.1 -jsonschema==4.17.3 pytest==7.3.0 pytest-cov==4.0.0 +PyYAML==6.0 regex; python_version >= '3.8' requests==2.31.0 ruamel.yaml>0.15.0,<0.18.0 diff --git a/tests/unit/hooks/test_apprise.py b/tests/unit/hooks/test_apprise.py new file mode 100644 index 00000000..b11f7691 --- /dev/null +++ b/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, + )