Explorar o código

Add Uptime Kuma monitoring hook (#885).

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/885
Reviewed-by: Dan Helfman <witten@torsion.org>
Dan Helfman hai 1 ano
pai
achega
4a0c167c1c

+ 1 - 0
README.md

@@ -62,6 +62,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
 <a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
+<a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
 <a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>

+ 32 - 0
borgmatic/config/schema.yaml

@@ -1766,6 +1766,38 @@ properties:
             an account at https://healthchecks.io (or self-host Healthchecks) if
             an account at https://healthchecks.io (or self-host Healthchecks) if
             you'd like to use this service. See borgmatic monitoring
             you'd like to use this service. See borgmatic monitoring
             documentation for details.
             documentation for details.
+    uptimekuma:
+        type: object
+        required: ['push_url']
+        additionalProperties: false
+        properties:
+            push_url:
+                type: string
+                description: |
+                  Uptime Kuma push URL without query string (do not include the
+                  question mark or anything after it).
+                example: https://example.uptime.kuma/api/push/abcd1234
+            states:
+                type: array
+                items:
+                    type: string
+                    enum:
+                        - start
+                        - finish
+                        - fail
+                    uniqueItems: true
+                description: |
+                    List of one or more monitoring states to push for: "start",
+                    "finish", and/or "fail". Defaults to pushing for all
+                    states.
+                example:
+                    - start
+                    - finish
+                    - fail
+        description: |
+            Configuration for a monitoring integration with Uptime Kuma using
+            the Push monitor type.
+            See more information here: https://uptime.kuma.pet
     cronitor:
     cronitor:
         type: object
         type: object
         required: ['ping_url']
         required: ['ping_url']

+ 3 - 1
borgmatic/hooks/dispatch.py

@@ -13,6 +13,7 @@ from borgmatic.hooks import (
     pagerduty,
     pagerduty,
     postgresql,
     postgresql,
     sqlite,
     sqlite,
+    uptimekuma,
 )
 )
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -22,6 +23,7 @@ HOOK_NAME_TO_MODULE = {
     'cronhub': cronhub,
     'cronhub': cronhub,
     'cronitor': cronitor,
     'cronitor': cronitor,
     'healthchecks': healthchecks,
     'healthchecks': healthchecks,
+    'loki': loki,
     'mariadb_databases': mariadb,
     'mariadb_databases': mariadb,
     'mongodb_databases': mongodb,
     'mongodb_databases': mongodb,
     'mysql_databases': mysql,
     'mysql_databases': mysql,
@@ -29,7 +31,7 @@ HOOK_NAME_TO_MODULE = {
     'pagerduty': pagerduty,
     'pagerduty': pagerduty,
     'postgresql_databases': postgresql,
     'postgresql_databases': postgresql,
     'sqlite_databases': sqlite,
     'sqlite_databases': sqlite,
-    'loki': loki,
+    'uptimekuma': uptimekuma,
 }
 }
 
 
 
 

+ 10 - 1
borgmatic/hooks/monitor.py

@@ -1,6 +1,15 @@
 from enum import Enum
 from enum import Enum
 
 
-MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki')
+MONITOR_HOOK_NAMES = (
+    'apprise',
+    'healthchecks',
+    'cronitor',
+    'cronhub',
+    'pagerduty',
+    'ntfy',
+    'loki',
+    'uptimekuma',
+)
 
 
 
 
 class State(Enum):
 class State(Enum):

+ 51 - 0
borgmatic/hooks/uptimekuma.py

@@ -0,0 +1,51 @@
+import logging
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+
+def initialize_monitor(
+    push_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):
+    '''
+    Make a get request to the configured Uptime Kuma push_url.
+    Use the given configuration filename in any log entries.
+    If this is a dry run, then don't actually push anything.
+    '''
+    run_states = hook_config.get('states', ['start', 'finish', 'fail'])
+    if state.name.lower() not in run_states:
+        return
+    dry_run_label = ' (dry run; not actually pushing)' if dry_run else ''
+    status = 'down' if state.name.lower() == 'fail' else 'up'
+    push_url = hook_config.get('push_url', 'https://example.uptime.kuma/api/push/abcd1234')
+    query = f'status={status}&msg={state.name.lower()}'
+    logger.info(
+        f'{config_filename}: Pushing Uptime Kuma push_url {push_url}?{query} {dry_run_label}'
+    )
+    logger.debug(f'{config_filename}: Full Uptime Kuma state URL {push_url}?{query}')
+    if dry_run:
+        return
+    logging.getLogger('urllib3').setLevel(logging.ERROR)
+    try:
+        response = requests.get(f'{push_url}?{query}')
+        if not response.ok:
+            response.raise_for_status()
+    except requests.exceptions.RequestException as error:
+        logger.warning(f'{config_filename}: Uptime Kuma error: {error}')
+
+
+def destroy_monitor(
+    push_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
+):  # pragma: no cover
+    '''
+    No destruction is necessary for this monitor.
+    '''
+    pass

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

@@ -46,6 +46,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)
  * [Grafana Loki](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook)
  * [Grafana Loki](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook)
  * [Apprise](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook)
  * [Apprise](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook)
+ * [Uptime Kuma](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptimekuma-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).
@@ -505,6 +506,60 @@ See the [configuration
 reference](https://torsion.org/borgmatic/docs/reference/configuration/) for
 reference](https://torsion.org/borgmatic/docs/reference/configuration/) for
 details.
 details.
 
 
+## Uptime Kuma hook
+
+[Uptime Kuma](https://uptime.kuma.pet) is an easy-to-use self-hosted 
+monitoring tool and can provide a Push monitor type to accept 
+HTTP `GET` requests from a service instead of contacting it
+directly.
+
+Uptime Kuma allows you to see a history of monitor states and 
+can in turn alert via Ntfy, Gotify, Matrix, Apprise, Email, and many more.
+
+An example configuration is shown here with all the available options:
+
+```yaml
+uptimekuma:
+    push_url: https://kuma.my-domain.com/api/push/abcd1234
+    states:
+        - start
+        - finish
+        - fail
+```
+The `push_url` is provided to your from your Uptime Kuma service and 
+includes a query string; the text including and after the question mark ('?').
+Please do not include the query string in the `push_url` configuration, 
+borgmatic will add this automatically depending on the state of your backup. 
+
+Using `start`, `finish` and `fail` states means you will get two 'up beats' in 
+Uptime Kuma for successful backups and the ability to see on failures if 
+and when the backup started (was there a `start` beat?).
+
+A reasonable base-level configuration for an Uptime Kuma Monitor 
+for a backup is below:
+
+```ini
+# These are to be entered into Uptime Kuma and not into your
+# borgmatic configuration.
+
+Monitor Type = Push
+# Push monitors wait for the client to contact Uptime Kuma
+# instead of Uptime Kuma contacting the client.
+# This is perfect for backup monitoring.
+
+Heartbeat Interval = 90000     # = 25 hours = 1 day + 1 hour
+
+# Wait 6 times the Heartbeat Retry (below) before logging a heartbeat missed
+Retries = 6
+
+# Multiplied by Retries this gives a grace period within which 
+# the monitor goes into the "Pending" state
+Heartbeat Retry = 360          # = 10 minutes
+
+# For each Heartbeat Interval if the backup fails repeatedly, 
+# a notification is sent each time.
+Resend Notification every X times = 1
+```
 
 
 ## Scripting borgmatic
 ## Scripting borgmatic
 
 

BIN=BIN
docs/static/uptimekuma.png


+ 165 - 0
tests/unit/hooks/test_uptimekuma.py

@@ -0,0 +1,165 @@
+from flexmock import flexmock
+
+import borgmatic.hooks.monitor
+from borgmatic.hooks import uptimekuma as module
+
+DEFAULT_PUSH_URL = 'https://example.uptime.kuma/api/push/abcd1234'
+CUSTOM_PUSH_URL = 'https://uptime.example.com/api/push/efgh5678'
+
+
+def test_ping_monitor_hits_default_uptimekuma_on_fail():
+    hook_config = {}
+    flexmock(module.requests).should_receive('get').with_args(
+        f'{DEFAULT_PUSH_URL}?status=down&msg=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_hits_custom_uptimekuma_on_fail():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    flexmock(module.requests).should_receive('get').with_args(
+        f'{CUSTOM_PUSH_URL}?status=down&msg=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_custom_uptimekuma_on_start():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    flexmock(module.requests).should_receive('get').with_args(
+        f'{CUSTOM_PUSH_URL}?status=up&msg=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_custom_uptimekuma_on_finish():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    flexmock(module.requests).should_receive('get').with_args(
+        f'{CUSTOM_PUSH_URL}?status=up&msg=finish'
+    ).and_return(flexmock(ok=True)).once()
+
+    module.ping_monitor(
+        hook_config,
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FINISH,
+        monitoring_log_level=1,
+        dry_run=False,
+    )
+
+
+def test_ping_monitor_does_not_hit_custom_uptimekuma_on_fail_dry_run():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    flexmock(module.requests).should_receive('get').never()
+
+    module.ping_monitor(
+        hook_config,
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FAIL,
+        monitoring_log_level=1,
+        dry_run=True,
+    )
+
+
+def test_ping_monitor_does_not_hit_custom_uptimekuma_on_start_dry_run():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    flexmock(module.requests).should_receive('get').never()
+
+    module.ping_monitor(
+        hook_config,
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.START,
+        monitoring_log_level=1,
+        dry_run=True,
+    )
+
+
+def test_ping_monitor_does_not_hit_custom_uptimekuma_on_finish_dry_run():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    flexmock(module.requests).should_receive('get').never()
+
+    module.ping_monitor(
+        hook_config,
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.FINISH,
+        monitoring_log_level=1,
+        dry_run=True,
+    )
+
+
+def test_ping_monitor_with_connection_error_logs_warning():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    flexmock(module.requests).should_receive('get').with_args(
+        f'{CUSTOM_PUSH_URL}?status=down&msg=fail'
+    ).and_raise(module.requests.exceptions.ConnectionError)
+    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_with_other_error_logs_warning():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    response = flexmock(ok=False)
+    response.should_receive('raise_for_status').and_raise(
+        module.requests.exceptions.RequestException
+    )
+    flexmock(module.requests).should_receive('post').with_args(
+        f'{CUSTOM_PUSH_URL}?status=down&msg=fail'
+    ).and_return(response)
+    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_with_invalid_run_state():
+    hook_config = {'push_url': CUSTOM_PUSH_URL}
+    flexmock(module.requests).should_receive('get').never()
+
+    module.ping_monitor(
+        hook_config,
+        {},
+        'config.yaml',
+        borgmatic.hooks.monitor.State.LOG,
+        monitoring_log_level=1,
+        dry_run=True,
+    )