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