diff --git a/NEWS b/NEWS index 442f4e6c..f7ff6b92 100644 --- a/NEWS +++ b/NEWS @@ -30,6 +30,8 @@ * Update the "--match-archives" and "--archive" flags to support Borg 2 series names or archive hashes. * Add a "--match-archives" flag to the "prune" action. + * Add a Zabbix monitoring hook. See the documentation for more information: + https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook 1.8.14 * #896: Fix an error in borgmatic rcreate/init on an empty repository directory with Borg 1.4. diff --git a/README.md b/README.md index a7000d68..45b48a4e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/). ntfy Loki Apprise +Zabbix BorgBase diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 9536164a..4dd7c76f 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1622,12 +1622,14 @@ properties: host: type: string description: | - Host name where the item is stored. Required if "itemid" is not set. + Host name where the item is stored. Required if "itemid" + is not set. example: borg-server key: type: string description: | - Key of the host where the item is stored. Required if "itemid" is not set. + Key of the host where the item is stored. Required if + "itemid" is not set. example: borg.status server: type: string @@ -1637,17 +1639,20 @@ properties: username: type: string description: | - The username used for authentication. Not needed if using an API key. + The username used for authentication. Not needed if using + an API key. example: testuser password: type: string description: | - The password used for authentication. Not needed if using an API key. + The password used for authentication. Not needed if using + an API key. example: fakepassword api_key: type: string description: | - The API key used for authentication. Not needed if using an username/password. + The API key used for authentication. Not needed if using + an username/password. example: fakekey start: type: object diff --git a/borgmatic/hooks/zabbix.py b/borgmatic/hooks/zabbix.py index 13f51b64..a33d77b4 100644 --- a/borgmatic/hooks/zabbix.py +++ b/borgmatic/hooks/zabbix.py @@ -1,4 +1,3 @@ -import json import logging import requests @@ -52,23 +51,23 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev logger.warning(f'{config_filename}: Server missing for Zabbix') return - # Determine the zabbix method used to store the value: itemid or host/key + # Determine the Zabbix method used to store the value: itemid or host/key if itemid is not None: logger.info(f'{config_filename}: Updating {itemid} on Zabbix') data = { - "jsonrpc": "2.0", - "method": "history.push", - "params": {"itemid": itemid, "value": value}, - "id": 1, + 'jsonrpc': '2.0', + 'method': 'history.push', + 'params': {'itemid': itemid, 'value': value}, + 'id': 1, } elif (host and key) is not None: logger.info(f'{config_filename}: Updating Host:{host} and Key:{key} on Zabbix') data = { - "jsonrpc": "2.0", - "method": "history.push", - "params": {"host": host, "key": key, "value": value}, - "id": 1, + 'jsonrpc': '2.0', + 'method': 'history.push', + 'params': {'host': host, 'key': key, 'value': value}, + 'id': 1, } elif host is not None: @@ -90,13 +89,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev elif (username and password) is not None: logger.info(f'{config_filename}: Using user/pass auth with user {username} for Zabbix') auth_data = { - "jsonrpc": "2.0", - "method": "user.login", - "params": { - "username": username, - "password": password - }, - "id": 1 + 'jsonrpc': '2.0', + 'method': 'user.login', + 'params': {'username': username, 'password': password}, + 'id': 1, } if not dry_run: logging.getLogger('urllib3').setLevel(logging.ERROR) @@ -107,6 +103,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev response.raise_for_status() except requests.exceptions.RequestException as error: logger.warning(f'{config_filename}: Zabbix error: {error}') + return elif username is not None: logger.warning(f'{config_filename}: Password missing for Zabbix authentication') @@ -118,7 +115,6 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev else: logger.warning(f'{config_filename}: Authentication data missing for Zabbix') return - if not dry_run: logging.getLogger('urllib3').setLevel(logging.ERROR) diff --git a/docs/how-to/monitor-your-backups.md b/docs/how-to/monitor-your-backups.md index 3ea51735..f5007401 100644 --- a/docs/how-to/monitor-your-backups.md +++ b/docs/how-to/monitor-your-backups.md @@ -566,14 +566,16 @@ Resend Notification every X times = 1 ## Zabbix hook New in version 1.9.0 -[zabbix](https://www.zabbix.com/) is an open-source monitoring tool used for tracking and managing the performance and availability of networks, servers, and applications in real-time. +[Zabbix](https://www.zabbix.com/) is an open-source monitoring tool used for +tracking and managing the performance and availability of networks, servers, +and applications in real-time. -This hook does not do any notifications on its own. Instead, it relies on -your Zabbix instance to notify and perform escalations based on the Zabbix -configuration. The `states` defined in the configuration will determine which states -will trigger the hook. The value defined in the configuration of each state is -used to populate the data of the configured Zabbix item. If none are provided, -it default to a lower-case string of the state. +This hook does not do any notifications on its own. Instead, it relies on your +Zabbix instance to notify and perform escalations based on the Zabbix +configuration. The `states` defined in the configuration determine which +states will trigger the hook. The value defined in the configuration of each +state is used to populate the data of the configured Zabbix item. If none are +provided, it defaults to a lower-case string of the state. An example configuration is shown here with all the available options. @@ -601,19 +603,22 @@ zabbix: - fail ``` -### Zabbix 7.0+ This hook requires the Zabbix server be running version 7.0+ -Authentication Methods -Authentication can be accomplished via `api_key` or `username` and `password`. -If both are declared, `api_key` will be chosen. -Items The item -to be updated can be chosen by either declaring the `itemid` or -`host` and `key`. If both are declared, `itemid` will be chosen. +### Authentication methods -Keep in mind that `host` is referring to the 'Host name' on the -Zabbix host and not the 'Visual name'. +Authentication can be accomplished via `api_key` or both `username` and +`password`. If all three are declared, only `api_key` is used. + + +### Items + +The item to be updated can be chosen by either declaring the `itemid` or both +`host` and `key`. If all three are declared, only `itemid` is used. + +Keep in mind that `host` is referring to the "Host name" on the Zabbix server +and not the "Visual name". ## Scripting borgmatic diff --git a/docs/static/zabbix.png b/docs/static/zabbix.png new file mode 100644 index 00000000..746ad2bc Binary files /dev/null and b/docs/static/zabbix.png differ diff --git a/tests/unit/hooks/test_zabbix.py b/tests/unit/hooks/test_zabbix.py index 2fe28cca..6f1a641d 100644 --- a/tests/unit/hooks/test_zabbix.py +++ b/tests/unit/hooks/test_zabbix.py @@ -1,5 +1,3 @@ -from enum import Enum - from flexmock import flexmock import borgmatic.hooks.monitor @@ -15,58 +13,70 @@ KEY = 'borg.status' VALUE = 'fail' DATA_HOST_KEY = { - "jsonrpc": "2.0", - "method": "history.push", - "params": {"host": HOST, "key": KEY, "value": VALUE}, - "id": 1, + 'jsonrpc': '2.0', + 'method': 'history.push', + 'params': {'host': HOST, 'key': KEY, 'value': VALUE}, + 'id': 1, } -DATA_HOST_KEY_WITH_TOKEN = { - "jsonrpc": "2.0", - "method": "history.push", - "params": {"host": HOST, "key": KEY, "value": VALUE}, - "id": 1, - "auth": "3fe6ed01a69ebd79907a120bcd04e494" +DATA_HOST_KEY_WITH_KEY_VALUE = { + 'jsonrpc': '2.0', + 'method': 'history.push', + 'params': {'host': HOST, 'key': KEY, 'value': VALUE}, + 'id': 1, + 'auth': '3fe6ed01a69ebd79907a120bcd04e494', } DATA_ITEMID = { - "jsonrpc": "2.0", - "method": "history.push", - "params": {"itemid": ITEMID, "value": VALUE}, - "id": 1, + 'jsonrpc': '2.0', + 'method': 'history.push', + 'params': {'itemid': ITEMID, 'value': VALUE}, + 'id': 1, } -DATA_HOST_KEY_WITH_TOKEN = { - "jsonrpc": "2.0", - "method": "history.push", - "params": {"itemid": ITEMID, "value": VALUE}, - "id": 1, - "auth": "3fe6ed01a69ebd79907a120bcd04e494" +DATA_HOST_KEY_WITH_ITEMID = { + 'jsonrpc': '2.0', + 'method': 'history.push', + 'params': {'itemid': ITEMID, 'value': VALUE}, + 'id': 1, + 'auth': '3fe6ed01a69ebd79907a120bcd04e494', } DATA_USER_LOGIN = { - "jsonrpc": "2.0", - "method": "user.login", - "params": {"username": USERNAME, "password": PASSWORD}, - "id": 1, + 'jsonrpc': '2.0', + 'method': 'user.login', + 'params': {'username': USERNAME, 'password': PASSWORD}, + 'id': 1, } AUTH_HEADERS_API_KEY = { 'Content-Type': 'application/json-rpc', - 'Authorization': f'Bearer {API_KEY}' + 'Authorization': f'Bearer {API_KEY}', } -AUTH_HEADERS_USERNAME_PASSWORD = { - 'Content-Type': 'application/json-rpc' -} +AUTH_HEADERS_USERNAME_PASSWORD = {'Content-Type': 'application/json-rpc'} + + +def test_ping_monitor_with_non_matching_state_exits_early(): + hook_config = {'api_key': API_KEY} + 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_config_with_api_key_only_exit_early(): - # This test should exit early since only providing an API KEY is not enough + # This test should exit early since only providing an API KEY is not enough # for the hook to work - hook_config = { - 'api_key': API_KEY - } + hook_config = {'api_key': API_KEY} flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, @@ -77,13 +87,13 @@ def test_ping_monitor_config_with_api_key_only_exit_early(): dry_run=False, ) + def test_ping_monitor_config_with_host_only_exit_early(): - # This test should exit early since only providing a HOST is not enough + # This test should exit early since only providing a HOST is not enough # for the hook to work - hook_config = { - 'host': HOST - } + hook_config = {'host': HOST} flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, @@ -94,13 +104,13 @@ def test_ping_monitor_config_with_host_only_exit_early(): dry_run=False, ) + def test_ping_monitor_config_with_key_only_exit_early(): - # This test should exit early since only providing a KEY is not enough + # This test should exit early since only providing a KEY is not enough # for the hook to work - hook_config = { - 'key': KEY - } + hook_config = {'key': KEY} flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, @@ -111,13 +121,13 @@ def test_ping_monitor_config_with_key_only_exit_early(): dry_run=False, ) + def test_ping_monitor_config_with_server_only_exit_early(): - # This test should exit early since only providing a SERVER is not enough + # This test should exit early since only providing a SERVER is not enough # for the hook to work - hook_config = { - 'server': SERVER - } + hook_config = {'server': SERVER} flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, @@ -128,14 +138,12 @@ def test_ping_monitor_config_with_server_only_exit_early(): dry_run=False, ) + def test_ping_monitor_config_user_password_no_zabbix_data_exit_early(): # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to - hook_config = { - 'server': SERVER, - 'username': USERNAME, - 'password': PASSWORD - } + hook_config = {'server': SERVER, 'username': USERNAME, 'password': PASSWORD} flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, @@ -146,13 +154,12 @@ def test_ping_monitor_config_user_password_no_zabbix_data_exit_early(): dry_run=False, ) + def test_ping_monitor_config_api_key_no_zabbix_data_exit_early(): # This test should exit early since there are HOST/KEY or ITEMID provided to publish data to - hook_config = { - 'server': SERVER, - 'api_key': API_KEY - } + hook_config = {'server': SERVER, 'api_key': API_KEY} flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, @@ -163,14 +170,13 @@ def test_ping_monitor_config_api_key_no_zabbix_data_exit_early(): dry_run=False, ) + def test_ping_monitor_config_itemid_no_auth_data_exit_early(): - # This test should exit early since there is no authentication provided + # This test should exit early since there is no authentication provided # and Zabbix requires authentication to use it's API - hook_config = { - 'server': SERVER, - 'itemid': ITEMID - } + hook_config = {'server': SERVER, 'itemid': ITEMID} flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, @@ -181,15 +187,13 @@ def test_ping_monitor_config_itemid_no_auth_data_exit_early(): dry_run=False, ) + def test_ping_monitor_config_host_and_key_no_auth_data_exit_early(): - # This test should exit early since there is no authentication provided + # This test should exit early since there is no authentication provided # and Zabbix requires authentication to use it's API - hook_config = { - 'server': SERVER, - 'host': HOST, - 'key': KEY - } + hook_config = {'server': SERVER, 'host': HOST, 'key': KEY} flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').never() module.ping_monitor( hook_config, @@ -200,15 +204,11 @@ def test_ping_monitor_config_host_and_key_no_auth_data_exit_early(): dry_run=False, ) + def test_ping_monitor_config_host_and_key_with_api_key_auth_data_successful(): # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY # to authenticate and HOST/KEY to know which item to populate in Zabbix. - hook_config = { - 'server': SERVER, - 'host': HOST, - 'key': KEY, - 'api_key': API_KEY - } + hook_config = {'server': SERVER, 'host': HOST, 'key': KEY, 'api_key': API_KEY} flexmock(module.requests).should_receive('post').with_args( f'{SERVER}', headers=AUTH_HEADERS_API_KEY, @@ -225,6 +225,37 @@ def test_ping_monitor_config_host_and_key_with_api_key_auth_data_successful(): dry_run=False, ) + +def test_ping_monitor_config_host_and_missing_key_exits_early(): + hook_config = {'server': SERVER, 'host': HOST, 'api_key': API_KEY} + 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_key_and_missing_host_exits_early(): + hook_config = {'server': SERVER, 'key': KEY, 'api_key': API_KEY} + 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_host_and_key_with_username_password_auth_data_successful(): # This test should simulate a successful POST to a Zabbix server. This test uses USERNAME/PASSWORD # to authenticate and HOST/KEY to know which item to populate in Zabbix. @@ -233,11 +264,13 @@ def test_ping_monitor_config_host_and_key_with_username_password_auth_data_succe 'host': HOST, 'key': KEY, 'username': USERNAME, - 'password': PASSWORD + 'password': PASSWORD, } auth_response = flexmock(ok=True) - auth_response.should_receive('json').and_return({"jsonrpc":"2.0","result":"3fe6ed01a69ebd79907a120bcd04e494","id":1}) + auth_response.should_receive('json').and_return( + {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1} + ) flexmock(module.requests).should_receive('post').with_args( f'{SERVER}', @@ -250,7 +283,7 @@ def test_ping_monitor_config_host_and_key_with_username_password_auth_data_succe flexmock(module.requests).should_receive('post').with_args( f'{SERVER}', headers=AUTH_HEADERS_USERNAME_PASSWORD, - json=DATA_HOST_KEY_WITH_TOKEN, + json=DATA_HOST_KEY_WITH_KEY_VALUE, ).and_return(flexmock(ok=True)).once() module.ping_monitor( @@ -262,14 +295,92 @@ def test_ping_monitor_config_host_and_key_with_username_password_auth_data_succe dry_run=False, ) + +def test_ping_monitor_config_host_and_key_with_username_password_auth_data_and_auth_post_error_exits_early(): + hook_config = { + 'server': SERVER, + 'host': HOST, + 'key': KEY, + 'username': USERNAME, + 'password': PASSWORD, + } + + auth_response = flexmock(ok=False) + auth_response.should_receive('json').and_return( + {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1} + ) + auth_response.should_receive('raise_for_status').and_raise( + module.requests.ConnectionError + ).once() + + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_USERNAME_PASSWORD, + json=DATA_USER_LOGIN, + ).and_return(auth_response).once() + flexmock(module.logger).should_receive('warning').once() + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_USERNAME_PASSWORD, + json=DATA_HOST_KEY_WITH_KEY_VALUE, + ).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_host_and_key_with_username_and_missing_password_exits_early(): + hook_config = { + 'server': SERVER, + 'host': HOST, + 'key': KEY, + 'username': USERNAME, + } + + 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_host_and_key_with_passing_and_missing_username_exits_early(): + hook_config = { + 'server': SERVER, + 'host': HOST, + 'key': KEY, + 'password': PASSWORD, + } + + 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_itemid_with_api_key_auth_data_successful(): # This test should simulate a successful POST to a Zabbix server. This test uses API_KEY # to authenticate and HOST/KEY to know which item to populate in Zabbix. - hook_config = { - 'server': SERVER, - 'itemid': ITEMID, - 'api_key': API_KEY - } + hook_config = {'server': SERVER, 'itemid': ITEMID, 'api_key': API_KEY} flexmock(module.requests).should_receive('post').with_args( f'{SERVER}', headers=AUTH_HEADERS_API_KEY, @@ -286,18 +397,16 @@ def test_ping_monitor_config_itemid_with_api_key_auth_data_successful(): dry_run=False, ) + def test_ping_monitor_config_itemid_with_username_password_auth_data_successful(): # This test should simulate a successful POST to a Zabbix server. This test uses USERNAME/PASSWORD # to authenticate and HOST/KEY to know which item to populate in Zabbix. - hook_config = { - 'server': SERVER, - 'itemid': ITEMID, - 'username': USERNAME, - 'password': PASSWORD - } + hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD} auth_response = flexmock(ok=True) - auth_response.should_receive('json').and_return({"jsonrpc":"2.0","result":"3fe6ed01a69ebd79907a120bcd04e494","id":1}) + auth_response.should_receive('json').and_return( + {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1} + ) flexmock(module.requests).should_receive('post').with_args( f'{SERVER}', @@ -310,7 +419,7 @@ def test_ping_monitor_config_itemid_with_username_password_auth_data_successful( flexmock(module.requests).should_receive('post').with_args( f'{SERVER}', headers=AUTH_HEADERS_USERNAME_PASSWORD, - json=DATA_HOST_KEY_WITH_TOKEN, + json=DATA_HOST_KEY_WITH_ITEMID, ).and_return(flexmock(ok=True)).once() module.ping_monitor( @@ -321,4 +430,39 @@ def test_ping_monitor_config_itemid_with_username_password_auth_data_successful( monitoring_log_level=1, dry_run=False, ) -test_ping_monitor_config_itemid_with_username_password_auth_data_successful() \ No newline at end of file + + +def test_ping_monitor_config_itemid_with_username_password_auth_data_and_push_post_error_exits_early(): + hook_config = {'server': SERVER, 'itemid': ITEMID, 'username': USERNAME, 'password': PASSWORD} + + auth_response = flexmock(ok=True) + auth_response.should_receive('json').and_return( + {'jsonrpc': '2.0', 'result': '3fe6ed01a69ebd79907a120bcd04e494', 'id': 1} + ) + + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_USERNAME_PASSWORD, + json=DATA_USER_LOGIN, + ).and_return(auth_response).once() + + push_response = flexmock(ok=False) + push_response.should_receive('raise_for_status').and_raise( + module.requests.ConnectionError + ).once() + flexmock(module.requests).should_receive('post').with_args( + f'{SERVER}', + headers=AUTH_HEADERS_USERNAME_PASSWORD, + json=DATA_HOST_KEY_WITH_ITEMID, + ).and_return(push_response).once() + + flexmock(module.logger).should_receive('warning').once() + + module.ping_monitor( + hook_config, + {}, + 'config.yaml', + borgmatic.hooks.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + )