2
0
Эх сурвалжийг харах

Add Zabbix monitoring hook.

Merge pull request #85 from tony1661/zabbix-hook
Dan Helfman 7 сар өмнө
parent
commit
c85bf46ad9

+ 80 - 0
borgmatic/config/schema.yaml

@@ -1609,6 +1609,86 @@ properties:
                 example:
                 example:
                     - start
                     - start
                     - finish
                     - 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:
     apprise:
         type: object
         type: object
         required: ['services']
         required: ['services']

+ 2 - 0
borgmatic/hooks/dispatch.py

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

+ 1 - 0
borgmatic/hooks/monitor.py

@@ -9,6 +9,7 @@ MONITOR_HOOK_NAMES = (
     'ntfy',
     'ntfy',
     'pagerduty',
     'pagerduty',
     'uptime_kuma',
     '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)
  * [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)
  * [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)
  * [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
 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).
 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
 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
 ## 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()