Browse Source

Add Zabbix monitoring hook.

Merge pull request #85 from tony1661/zabbix-hook
Dan Helfman 7 tháng trước cách đây
mục cha
commit
c85bf46ad9

+ 80 - 0
borgmatic/config/schema.yaml

@@ -1609,6 +1609,86 @@ properties:
                 example:
                     - start
                     - finish
+    zabbix:
+        type: object
+        additionalProperties: false
+        properties:
+            itemid:
+                type: integer
+                description: |
+                    The ID of the Zabbix item used for collecting data.
+                    Unique across the entire Zabbix system.
+                example: 55105
+            host:
+                type: string
+                description: |
+                    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.
+                example: borg.status
+            server:
+                type: string
+                description: |
+                    The address of your Zabbix instance.
+                example: https://zabbix.your-domain.com
+            username:
+                type: string
+                description: |
+                    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.
+                example: fakepassword
+            api_key:
+                type: string
+                description: |
+                    The API key used for authentication. Not needed if using an username/password.
+                example: fakekey
+            start:
+                type: object
+                properties:
+                    value:
+                        type: ["integer", "string"]
+                        description: |
+                            The value to set the item to on start.
+                        example: STARTED
+            finish:
+                type: object
+                properties:
+                    value:
+                        type: ["integer", "string"]
+                        description: |
+                            The value to set the item to on finish.
+                        example: FINISH
+            fail:
+                type: object
+                properties:
+                    value:
+                        type: ["integer", "string"]
+                        description: |
+                            The value to set the item to on fail.
+                        example: ERROR
+            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
     apprise:
         type: object
         required: ['services']

+ 2 - 0
borgmatic/hooks/dispatch.py

@@ -14,6 +14,7 @@ from borgmatic.hooks import (
     postgresql,
     sqlite,
     uptimekuma,
+    zabbix,
 )
 
 logger = logging.getLogger(__name__)
@@ -32,6 +33,7 @@ HOOK_NAME_TO_MODULE = {
     'postgresql_databases': postgresql,
     'sqlite_databases': sqlite,
     'uptime_kuma': uptimekuma,
+    'zabbix': zabbix,
 }
 
 

+ 1 - 0
borgmatic/hooks/monitor.py

@@ -9,6 +9,7 @@ MONITOR_HOOK_NAMES = (
     'ntfy',
     'pagerduty',
     'uptime_kuma',
+    'zabbix',
 )
 
 

+ 139 - 0
borgmatic/hooks/zabbix.py

@@ -0,0 +1,139 @@
+import json
+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):
+    '''
+    Update the configured Zabbix item using either the itemid, or a host and key.
+    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(),
+        {
+            'value': state.name.lower(),
+        },
+    )
+
+    server = hook_config.get('server')
+    username = hook_config.get('username')
+    password = hook_config.get('password')
+    api_key = hook_config.get('api_key')
+    itemid = hook_config.get('itemid')
+    host = hook_config.get('host')
+    key = hook_config.get('key')
+    value = state_config.get('value')
+    headers = {'Content-Type': 'application/json-rpc'}
+
+    logger.info(f'{config_filename}: Updating Zabbix{dry_run_label}')
+    logger.debug(f'{config_filename}: Using Zabbix URL: {server}')
+
+    if server is None:
+        logger.warning(f'{config_filename}: Server missing for Zabbix')
+        return
+
+    # 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,
+        }
+
+    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,
+        }
+
+    elif host is not None:
+        logger.warning(f'{config_filename}: Key missing for Zabbix')
+        return
+
+    elif key is not None:
+        logger.warning(f'{config_filename}: Host missing for Zabbix.')
+        return
+    else:
+        logger.warning(f'{config_filename}: No zabbix itemid or host/key provided.')
+        return
+
+    # Determine the authentication method: API key or username/password
+    if api_key is not None:
+        logger.info(f'{config_filename}: Using API key auth for Zabbix')
+        headers['Authorization'] = 'Bearer ' + api_key
+
+    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
+        }
+        if not dry_run:
+            logging.getLogger('urllib3').setLevel(logging.ERROR)
+            try:
+                response = requests.post(server, headers=headers, json=auth_data)
+                data['auth'] = response.json().get('result')
+                if not response.ok:
+                    response.raise_for_status()
+            except requests.exceptions.RequestException as error:
+                logger.warning(f'{config_filename}: Zabbix error: {error}')
+
+    elif username is not None:
+        logger.warning(f'{config_filename}: Password missing for Zabbix authentication')
+        return
+
+    elif password is not None:
+        logger.warning(f'{config_filename}: Username missing for Zabbix authentication')
+        return
+    else:
+        logger.warning(f'{config_filename}: Authentication data missing for Zabbix')
+        return
+    
+
+    if not dry_run:
+        logging.getLogger('urllib3').setLevel(logging.ERROR)
+        try:
+            response = requests.post(server, headers=headers, json=data)
+            if not response.ok:
+                response.raise_for_status()
+        except requests.exceptions.RequestException as error:
+            logger.warning(f'{config_filename}: Zabbix 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

+ 53 - 0
docs/how-to/monitor-your-backups.md

@@ -47,6 +47,7 @@ them as backups happen:
  * [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)
  * [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)
 
 The idea is that you'll receive an alert when something goes wrong or when the
 service doesn't hear from borgmatic for a configured interval (if supported).
@@ -562,6 +563,58 @@ Heartbeat Retry = 360          # = 10 minutes
 Resend Notification every X times = 1
 ```
 
+## Zabbix hook
+
+<span class="minilink minilink-addedin">New in version 1.9.0</span>
+[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.
+
+An example configuration is shown here with all the available options.
+
+```yaml
+zabbix:
+    server: http://cloud.zabbix.com/zabbix/api_jsonrpc.php
+    
+    username: myuser
+    password: secret
+    api_key: b2ecba64d8beb47fc161ae48b164cfd7104a79e8e48e6074ef5b141d8a0aeeca
+
+    host: "borg-server"
+    key: borg.status
+    itemid: 55105
+
+    start:
+        value: "STARTED"
+    finish:
+        value: "OK"
+    fail:
+        value: "ERROR"
+    states:
+        - start
+        - finish
+        - fail
+```
+
+###  Zabbix 7.0+
+This hook requires the Zabbix server be running version 7.0+
+
+<span class="minilink minilink-addedin">Authentication Methods</span>
+Authentication can be accomplished via `api_key` or `username` and `password`. 
+If both are declared, `api_key` will be chosen.
+
+<span class="minilink minilink-addedin">Items</span> 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.
+
+Keep in mind that `host` is referring to the 'Host name' on the 
+Zabbix host and not the 'Visual name'.
+
 
 ## Scripting borgmatic
 

+ 324 - 0
tests/unit/hooks/test_zabbix.py

@@ -0,0 +1,324 @@
+from enum import Enum
+
+from flexmock import flexmock
+
+import borgmatic.hooks.monitor
+from borgmatic.hooks import zabbix as module
+
+SERVER = 'https://zabbix.com/zabbix/api_jsonrpc.php'
+ITEMID = 55105
+USERNAME = 'testuser'
+PASSWORD = 'fakepassword'
+API_KEY = 'fakekey'
+HOST = 'borg-server'
+KEY = 'borg.status'
+VALUE = 'fail'
+
+DATA_HOST_KEY = {
+    "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_ITEMID = {
+    "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_USER_LOGIN = {
+    "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}'
+}
+
+AUTH_HEADERS_USERNAME_PASSWORD = {
+    'Content-Type': 'application/json-rpc'
+}
+
+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 
+    # for the hook to work
+    hook_config = {
+        'api_key': API_KEY
+    }
+    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,
+    )
+
+def test_ping_monitor_config_with_host_only_exit_early():
+    # This test should exit early since only providing a HOST is not enough 
+    # for the hook to work
+    hook_config = {
+        'host': HOST
+    }
+    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,
+    )
+
+def test_ping_monitor_config_with_key_only_exit_early():
+    # This test should exit early since only providing a KEY is not enough 
+    # for the hook to work
+    hook_config = {
+        'key': KEY
+    }
+    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,
+    )
+
+def test_ping_monitor_config_with_server_only_exit_early():
+    # This test should exit early since only providing a SERVER is not enough 
+    # for the hook to work
+    hook_config = {
+        'server': SERVER
+    }
+    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,
+    )
+
+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
+    }
+    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,
+    )
+
+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
+    }
+    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,
+    )
+
+def test_ping_monitor_config_itemid_no_auth_data_exit_early():
+    # 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
+    }
+    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,
+    )
+
+def test_ping_monitor_config_host_and_key_no_auth_data_exit_early():
+    # 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
+    }
+    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,
+    )
+
+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
+    }
+    flexmock(module.requests).should_receive('post').with_args(
+        f'{SERVER}',
+        headers=AUTH_HEADERS_API_KEY,
+        json=DATA_HOST_KEY,
+    ).and_return(flexmock(ok=True)).once()
+    flexmock(module.logger).should_receive('warning').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.
+    hook_config = {
+        'server': SERVER,
+        'host': HOST,
+        'key': KEY,
+        '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()
+
+    flexmock(module.logger).should_receive('warning').never()
+
+    flexmock(module.requests).should_receive('post').with_args(
+        f'{SERVER}',
+        headers=AUTH_HEADERS_USERNAME_PASSWORD,
+        json=DATA_HOST_KEY_WITH_TOKEN,
+    ).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_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
+    }
+    flexmock(module.requests).should_receive('post').with_args(
+        f'{SERVER}',
+        headers=AUTH_HEADERS_API_KEY,
+        json=DATA_ITEMID,
+    ).and_return(flexmock(ok=True)).once()
+    flexmock(module.logger).should_receive('warning').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_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
+    }
+
+    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()
+
+    flexmock(module.logger).should_receive('warning').never()
+
+    flexmock(module.requests).should_receive('post').with_args(
+        f'{SERVER}',
+        headers=AUTH_HEADERS_USERNAME_PASSWORD,
+        json=DATA_HOST_KEY_WITH_TOKEN,
+    ).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,
+    )
+test_ping_monitor_config_itemid_with_username_password_auth_data_successful()