diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 4dd7c76f..d7668124 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1609,6 +1609,214 @@ properties: example: - start - finish + pushover: + type: object + additionalProperties: false + properties: + token: + type: string + description: | + Your application's API token. + example: 7ms6TXHpTokTou2P6x4SodDeentHRa + user: + type: string + description: | + Your user/group key (or that of your target user), viewable + when logged into your dashboard: often referred to as + USER_KEY in Pushover documentation and code examples. + example: hwRwoWsXMBWwgrSecfa9EfPey55WSN + start: + type: object + type: object + properties: + message: + type: string + description: | + Message to be sent to the user or group. + example: A backup job has started. + priority: + type: integer + description: | + A value of -2, -1, 0 (default), 1 or 2 that + indicates the message priority. + example: "0" + device: + type: string + description: | + The name of one of your devices to send just to that + device instead of all devices. + example: pixel8 + html: + type: integer + description: | + Set to 1 to enable HTML parsing of the message. Set + to 0 for plain text. + example: 1 + sound: + type: string + description: | + The name of a supported sound to override your + default sound choice. All options can be + found here: https://pushover.net/api#sounds + example: bike + title: + type: string + description: | + Your message's title, otherwise your app's + name is used. + example: A backup job has started. + ttl: + type: integer + description: | + The number of seconds that the message will live, + before being deleted automatically. The ttl + parameter is ignored for messages with a priority + value of 2. + example: 3600 + url: + type: string + description: | + A supplementary URL to show with your message. + example: https://pushover.net/apps/xxxxx-borgbackup + url_title: + type: string + description: | + A title for the URL specified as the url parameter, + otherwise just the URL is shown. + example: Pushover Link + finish: + type: object + type: object + properties: + message: + type: string + description: | + Message to be sent to the user or group. + example: A backup job has started. + priority: + type: integer + description: | + A value of -2, -1, 0 (default), 1 or 2 that + indicates the message priority. + example: "0" + device: + type: string + description: | + The name of one of your devices to send just to that + device instead of all devices. + example: pixel8 + html: + type: integer + description: | + Set to 1 to enable HTML parsing of the message. Set + to 0 for plain text. + example: 1 + sound: + type: string + description: | + The name of a supported sound to override your + default sound choice. All options can be + found here: https://pushover.net/api#sounds + example: bike + title: + type: string + description: | + Your message's title, otherwise your app's + name is used. + example: A backup job has started. + ttl: + type: integer + description: | + The number of seconds that the message will live, + before being deleted automatically. The ttl + parameter is ignored for messages with a priority + value of 2. + example: 3600 + url: + type: string + description: | + A supplementary URL to show with your message. + example: https://pushover.net/apps/xxxxx-borgbackup + url_title: + type: string + description: | + A title for the URL specified as the url parameter, + otherwise just the URL is shown. + example: Pushover Link + fail: + type: object + properties: + message: + type: string + description: | + Message to be sent to the user or group. + example: A backup job has started. + priority: + type: integer + description: | + A value of -2, -1, 0 (default), 1 or 2 that + indicates the message priority. + example: "0" + device: + type: string + description: | + The name of one of your devices to send just to that + device instead of all devices. + example: pixel8 + html: + type: integer + description: | + Set to 1 to enable HTML parsing of the message. Set + to 0 for plain text. + example: 1 + sound: + type: string + description: | + The name of a supported sound to override your + default sound choice. All options can be + found here: https://pushover.net/api#sounds + example: bike + title: + type: string + description: | + Your message's title, otherwise your app's + name is used. + example: A backup job has started. + ttl: + type: integer + description: | + The number of seconds that the message will live, + before being deleted automatically. The ttl + parameter is ignored for messages with a priority + value of 2. + example: 3600 + url: + type: string + description: | + A supplementary URL to show with your message. + example: https://pushover.net/apps/xxxxx-borgbackup + url_title: + type: string + description: | + A title for the URL specified as the url parameter, + otherwise just the URL is shown. + example: Pushover Link + 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 zabbix: type: object additionalProperties: false diff --git a/borgmatic/hooks/dispatch.py b/borgmatic/hooks/dispatch.py index be85af8c..4230ffb9 100644 --- a/borgmatic/hooks/dispatch.py +++ b/borgmatic/hooks/dispatch.py @@ -12,6 +12,7 @@ from borgmatic.hooks import ( ntfy, pagerduty, postgresql, + pushover, sqlite, uptimekuma, zabbix, @@ -31,6 +32,7 @@ HOOK_NAME_TO_MODULE = { 'ntfy': ntfy, 'pagerduty': pagerduty, 'postgresql_databases': postgresql, + 'pushover': pushover, 'sqlite_databases': sqlite, 'uptime_kuma': uptimekuma, 'zabbix': zabbix, diff --git a/borgmatic/hooks/monitor.py b/borgmatic/hooks/monitor.py index de4951b7..db44b567 100644 --- a/borgmatic/hooks/monitor.py +++ b/borgmatic/hooks/monitor.py @@ -8,6 +8,7 @@ MONITOR_HOOK_NAMES = ( 'loki', 'ntfy', 'pagerduty', + 'pushover', 'uptime_kuma', 'zabbix', ) diff --git a/borgmatic/hooks/pushover.py b/borgmatic/hooks/pushover.py new file mode 100644 index 00000000..73a01c89 --- /dev/null +++ b/borgmatic/hooks/pushover.py @@ -0,0 +1,82 @@ +import logging + +import requests + +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): + ''' + Post a message to the configured Pushover application. + If this is a dry run, then don't actually update anything. + ''' + + run_states = hook_config.get('states', ['fail']) + + if state.name.lower() not in run_states: + return + + dry_run_label = ' (dry run; not actually updating)' if dry_run else '' + + state_config = hook_config.get( + state.name.lower(), + { + 'message': state.name.lower(), + }, + ) + + token = hook_config.get('token') + user = hook_config.get('user') + + logger.info(f'{config_filename}: Updating Pushover {dry_run_label}') + + if token is None: + logger.warning(f'{config_filename}: Token missing for Pushover') + return + if user is None: + logger.warning(f'{config_filename}: User missing for Pushover') + return + + data = { + 'token': token, + 'user': user, + 'message': state.name.lower(), # default to state name. Can be overwritten in state_config loop below. + } + + for key in state_config: + data[key] = state_config[key] + if key == 'priority': + if data['priority'] == 2: + data['expire'] = 30 + data['retry'] = 30 + + if not dry_run: + logging.getLogger('urllib3').setLevel(logging.ERROR) + try: + response = requests.post( + 'https://api.pushover.net/1/messages.json', + headers={'Content-type': 'application/x-www-form-urlencoded'}, + data=data, + ) + if not response.ok: + response.raise_for_status() + except requests.exceptions.RequestException as error: + logger.warning(f'{config_filename}: Pushover error: {error}') + + +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/docs/how-to/monitor-your-backups.md b/docs/how-to/monitor-your-backups.md index f5007401..9069610f 100644 --- a/docs/how-to/monitor-your-backups.md +++ b/docs/how-to/monitor-your-backups.md @@ -46,6 +46,7 @@ them as backups happen: * [Healthchecks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook) * [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook) * [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook) + * [Pushover](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pushover-hook) * [Uptime Kuma](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook) * [Zabbix](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook) @@ -290,6 +291,67 @@ If you have any issues with the integration, [please contact us](https://torsion.org/borgmatic/#support-and-contributing). +## Pushover hook + +New in version 1.9.0 +[Pushover](https://pushover.net) makes it easy to get real-time notifications +on your Android, iPhone, iPad, and Desktop (Android Wear and Apple Watch, too!) + +First, create a Pushover account and login on your mobile device. Create an +Application in your Pushover dashboard. + +Then, configure borgmatic with your user's unique "User Key" found in your +Pushover dashboard and the unique "API Token" from the created Application. + +Here's a basic example: + + +```yaml +pushover: + token: 7ms6TXHpTokTou2P6x4SodDeentHRa + user: hwRwoWsXMBWwgrSecfa9EfPey55WSN +``` + + +With this configuration, borgmatic creates a Pushover event for your service +whenever backups fail, but only when any of the `create`, `prune`, `compact`, +or `check` actions are run. Note that borgmatic does not contact Pushover +when a backup starts or when it ends without error. + +You can configure Pushover to have custom parameters declared for borgmatic's +`start`, `fail` and `finish` hooks states. + +Here's a more advanced example: + + +```yaml +pushover: + token: 7ms6TXHpTokTou2P6x4SodDeentHRa + user: hwRwoWsXMBWwgrSecfa9EfPey55WSN + start: + message: "Backup Started" + priority: -2 + device: "pixel8" + title: "Backup Started" + html: 1 + sound: "bike" + ttl: 10 + fail: + message: "Backup Failed" + priority: -2 + device: "pixel8" + title: "Backup Started" + html: 1 + sound: "siren" + url: "https://ticketing-system.example.com/login" + url_title: "Login to ticketing system" + states: + - start + - finish + - fail +``` + + ## ntfy hook New in version 1.6.3 diff --git a/tests/unit/hooks/test_pushover.py b/tests/unit/hooks/test_pushover.py new file mode 100644 index 00000000..8484f5ab --- /dev/null +++ b/tests/unit/hooks/test_pushover.py @@ -0,0 +1,176 @@ +from flexmock import flexmock + +import borgmatic.hooks.monitor +from borgmatic.hooks import pushover as module + + +def test_ping_monitor_config_with_token_only_exit_early(): + # This test should exit early since only providing a token is not enough + # for the hook to work + hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92'} + flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_config_with_user_only_exit_early(): + # This test should exit early since only providing a token is not enough + # for the hook to work + hook_config = {'user': '983hfe0of902lkjfa2amanfgui'} + flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_config_with_minimum_config_fail_state_backup_successfully_send_to_pushover(): + # This test should be the minimum working configuration. The "message" + # should be auto populated with the default value which is the state name. + hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'} + flexmock(module.logger).should_receive('warning').never() + flexmock(module.requests).should_receive('post').with_args( + 'https://api.pushover.net/1/messages.json', + headers={'Content-type': 'application/x-www-form-urlencoded'}, + data={ + 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', + 'user': '983hfe0of902lkjfa2amanfgui', + 'message': 'fail', + }, + ).and_return(flexmock(ok=True)).once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_config_with_minimum_config_start_state_backup_not_send_to_pushover_exit_early(): + # This test should exit early since the hook config does not specify the + # 'start' state. Only the 'fail' state is enabled by default. + hook_config = {'token': 'ksdjfwoweijfvwoeifvjmwghagy92', 'user': '983hfe0of902lkjfa2amanfgui'} + flexmock(module.logger).should_receive('warning').never() + flexmock(module.requests).should_receive('post').never() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.START, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_start_state_backup_default_message_successfully_send_to_pushover(): + # This test should send a notification to Pushover on backup start + # since the state has been configured. It should default to sending + # the name of the state as the 'message' since it is not + # explicitly declared in the state config. + hook_config = { + 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', + 'user': '983hfe0of902lkjfa2amanfgui', + 'states': {'start', 'fail', 'finish'}, + } + flexmock(module.logger).should_receive('warning').never() + flexmock(module.requests).should_receive('post').with_args( + 'https://api.pushover.net/1/messages.json', + headers={'Content-type': 'application/x-www-form-urlencoded'}, + data={ + 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', + 'user': '983hfe0of902lkjfa2amanfgui', + 'message': 'start', + }, + ).and_return(flexmock(ok=True)).once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.START, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_start_state_backup_custom_message_successfully_send_to_pushover(): + # This test should send a notification to Pushover on backup start + # since the state has been configured. It should send a custom + # 'message' since it is explicitly declared in the state config. + hook_config = { + 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', + 'user': '983hfe0of902lkjfa2amanfgui', + 'states': {'start', 'fail', 'finish'}, + 'start': {'message': 'custom start message'}, + } + flexmock(module.logger).should_receive('warning').never() + flexmock(module.requests).should_receive('post').with_args( + 'https://api.pushover.net/1/messages.json', + headers={'Content-type': 'application/x-www-form-urlencoded'}, + data={ + 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', + 'user': '983hfe0of902lkjfa2amanfgui', + 'message': 'custom start message', + }, + ).and_return(flexmock(ok=True)).once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.START, + monitoring_log_level=1, + dry_run=False, + ) + + +def test_ping_monitor_start_state_backup_default_message_with_priority_declared_successfully_send_to_pushover(): + # This test should send a notification to Pushover on backup start + # since the state has been configured. It should default to sending + # the name of the state as the 'message' since it is not + # explicitly declared in the state config. It should also send + # with a priority of 1 (high). + hook_config = { + 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', + 'user': '983hfe0of902lkjfa2amanfgui', + 'states': {'start', 'fail', 'finish'}, + 'start': {'priority': 1}, + } + flexmock(module.logger).should_receive('warning').never() + flexmock(module.requests).should_receive('post').with_args( + 'https://api.pushover.net/1/messages.json', + headers={'Content-type': 'application/x-www-form-urlencoded'}, + data={ + 'token': 'ksdjfwoweijfvwoeifvjmwghagy92', + 'user': '983hfe0of902lkjfa2amanfgui', + 'message': 'start', + 'priority': 1, + }, + ).and_return(flexmock(ok=True)).once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.START, + monitoring_log_level=1, + dry_run=False, + )