Skip to content

Commit de669b5

Browse files
authored
Merge pull request #66 from homeylab/61-notification
61 notification
2 parents 6354605 + ae1b5d0 commit de669b5

File tree

14 files changed

+336
-37
lines changed

14 files changed

+336
-37
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ Table of Contents
1717
- [Modify Markdown Files](#modify-markdown-files)
1818
- [Object Storage](#object-storage)
1919
- [Minio Backups](#minio-backups)
20+
- [Notifications](#notifications)
21+
- [apprise](#apprise)
2022
- [Potential Breaking Upgrades](#potential-breaking-upgrades)
2123
- [Future Items](#future-items)
2224

@@ -251,6 +253,15 @@ assets:
251253
export_meta: false
252254
keep_last: 5
253255
run_interval: 0
256+
notifications:
257+
apprise:
258+
service_urls:
259+
- "json://localhost:8080/notify"
260+
config_path: ""
261+
plugin_paths: []
262+
storage_path: ""
263+
custom_title: ""
264+
custom_attachment_path: ""
254265
```
255266

256267
#### Options and Descriptions
@@ -480,6 +491,25 @@ minio:
480491
| `path` | `str` | `false` | Optional, path of the backup to use. Will use root bucket path if not set. `<bucket_name>:/<path>/bookstack-<timestamp>.tgz` |
481492
| `keep_last` | `int` | `false` | Optional (default: `0`), if exporter can delete older archives in minio.<br>- set to `1+` if you want to retain a certain number of archives<br>- `0` will result in no action done |
482493

494+
## Notifications
495+
It is possible to send notifications when an export run fails. Currently, the only supported notification service is [apprise](https://github.com/caronc/apprise). Apprise is a general purpose notification service and has a variety of integrations and includes generic HTTP POST.
496+
497+
### apprise
498+
The apprise configuration is a part of the configuration yaml file and can be modified under `notifications.apprise`.
499+
500+
| Item | Type | Description |
501+
| ---- | ---- | ----------- |
502+
| `apprise.service_urls` | `List<str>` | Provide the apprise urls for apprise to send notifications to. Can also be provided as environment variable: `APPRISE_URLS`, see example further below. |
503+
| `apprise.config_path` | `str` | If specified, overrides `apprise.service_urls`. Can specify the path to an apprise configuration file |
504+
| `apprise.plugin_paths` | `List<str>` | Provide the plugin paths for apprise to use |
505+
| `apprise.storage_path` | `str` | For persistent storage, specify a path for apprise to use |
506+
| `apprise.custom_title` | `str` | Replace the default message title for apprise notifications |
507+
| `apprise.custom_attachment_path` | `str` | To include a custom attachment to the apprise notification, specify the path to a file |
508+
509+
`apprise.service_urls` can contain sensitive information and can be specified as an environment variable instead as a string list, example: `export APPRISE_URLS='["json://localhost:8080/notify"]'`.
510+
511+
**If using apprise for notifications, one of `apprise.service_urls` or `apprise.config_path` should be specified.**
512+
483513
## Potential Breaking Upgrades
484514
Below are versions that have major changes to the way configuration or exporter runs.
485515

bookstack_file_exporter/archiver/minio_archiver.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,9 @@ def _scan_objects(self, file_extension: str) -> List[MinioObject]:
8080
# get all objects in archive path/directory
8181
full_list: List[MinioObject] = self._client.list_objects(self.bucket, prefix=path_prefix)
8282
# validate and filter out non managed objects
83-
if full_list:
84-
return [object for object in full_list
85-
if object.object_name.endswith(file_extension)
86-
and filter_str in object.object_name]
87-
return []
83+
return [object for object in full_list
84+
if object.object_name.endswith(file_extension)
85+
and filter_str in object.object_name]
8886

8987
def _get_stale_objects(self, file_extension: str) -> List[MinioObject]:
9088
minio_objects = self._scan_objects(file_extension)

bookstack_file_exporter/common/util.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
2-
from typing import Dict
2+
import os
3+
from typing import Dict, Union
34
import urllib3
45
# pylint: disable=import-error
56
import requests
@@ -70,3 +71,27 @@ def should_verify(url: str) -> str:
7071
if url.startswith("https"):
7172
return "https://"
7273
return "http://"
74+
75+
def check_var(env_key: str, default_val: Union[list[str],str], can_error: bool = False) -> str:
76+
"""
77+
:param: env_key = the environment variable to check
78+
:param: default_val = the default value if any to set if env variable not set
79+
:param: can_error = whether or not missing both env_key and default_val should
80+
trigger an exception
81+
82+
:return: env_key if present or default_val if not
83+
:throws: ValueError if both parameters are empty.
84+
"""
85+
env_value = os.environ.get(env_key, "")
86+
# env value takes precedence
87+
if env_value:
88+
log.debug("""env key: %s specified.
89+
Will override configuration file value if set.""", env_key)
90+
return env_value
91+
# check for optional inputs, if env and input is missing
92+
if not can_error:
93+
if not env_value and not default_val:
94+
raise ValueError(f"""{env_key} is not specified in env and is
95+
missing from configuration - at least one should be set""")
96+
# fall back to configuration file value if present
97+
return default_val

bookstack_file_exporter/config_helper/config_helper.py

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# pylint: disable=import-error
66
import yaml
77

8+
from bookstack_file_exporter.common.util import check_var
89
from bookstack_file_exporter.config_helper import models
910
from bookstack_file_exporter.config_helper.remote import StorageProviderConfig
1011

@@ -83,17 +84,17 @@ def _generate_credentials(self) -> Tuple[str, str]:
8384
token_secret = self.user_inputs.credentials.token_secret
8485

8586
# check to see if env var is specified, if so, it takes precedence
86-
token_id = self._check_var(_BOOKSTACK_TOKEN_FIELD, token_id)
87-
token_secret = self._check_var(_BOOKSTACK_TOKEN_SECRET_FIELD, token_secret)
87+
token_id = check_var(_BOOKSTACK_TOKEN_FIELD, token_id)
88+
token_secret = check_var(_BOOKSTACK_TOKEN_SECRET_FIELD, token_secret)
8889
return token_id, token_secret
8990

9091
def _generate_remote_config(self) -> Dict[str, StorageProviderConfig]:
9192
object_config = {}
9293
# check for optional minio credentials if configuration is set in yaml configuration file
9394
if self.user_inputs.minio:
94-
minio_access_key = self._check_var(_MINIO_ACCESS_KEY_FIELD,
95+
minio_access_key = check_var(_MINIO_ACCESS_KEY_FIELD,
9596
self.user_inputs.minio.access_key)
96-
minio_secret_key = self._check_var(_MINIO_SECRET_KEY_FIELD,
97+
minio_secret_key = check_var(_MINIO_SECRET_KEY_FIELD,
9798
self.user_inputs.minio.secret_key)
9899

99100
object_config["minio"] = StorageProviderConfig(minio_access_key,
@@ -177,24 +178,24 @@ def object_storage_config(self) -> Dict[str, StorageProviderConfig]:
177178
"""return remote storage configuration"""
178179
return self._object_storage_config
179180

180-
@staticmethod
181-
def _check_var(env_key: str, default_val: str) -> str:
182-
"""
183-
:param: env_key = the environment variable to check
184-
:param: default_val = the default value if any to set if env variable not set
185-
186-
:return: env_key if present or default_val if not
187-
:throws: ValueError if both parameters are empty.
188-
"""
189-
env_value = os.environ.get(env_key, "")
190-
# env value takes precedence
191-
if env_value:
192-
log.debug("""env key: %s specified.
193-
Will override configuration file value if set.""", env_key)
194-
return env_value
195-
# check for optional inputs, if env and input is missing
196-
if not env_value and not default_val:
197-
raise ValueError(f"""{env_key} is not specified in env and is
198-
missing from configuration - at least one should be set""")
199-
# fall back to configuration file value if present
200-
return default_val
181+
# @staticmethod
182+
# def _check_var(env_key: str, default_val: str) -> str:
183+
# """
184+
# :param: env_key = the environment variable to check
185+
# :param: default_val = the default value if any to set if env variable not set
186+
187+
# :return: env_key if present or default_val if not
188+
# :throws: ValueError if both parameters are empty.
189+
# """
190+
# env_value = os.environ.get(env_key, "")
191+
# # env value takes precedence
192+
# if env_value:
193+
# log.debug("""env key: %s specified.
194+
# Will override configuration file value if set.""", env_key)
195+
# return env_value
196+
# # check for optional inputs, if env and input is missing
197+
# if not env_value and not default_val:
198+
# raise ValueError(f"""{env_key} is not specified in env and is
199+
# missing from configuration - at least one should be set""")
200+
# # fall back to configuration file value if present
201+
# return default_val

bookstack_file_exporter/config_helper/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ class HttpConfig(BaseModel):
3636
retry_count: Optional[int] = 5
3737
additional_headers: Optional[Dict[str, str]] = {}
3838

39+
class AppRiseNotifyConfig(BaseModel):
40+
"""YAML schema for user provided app rise settings"""
41+
service_urls: Optional[List[str]] = []
42+
config_path: Optional[str] = ""
43+
plugin_paths: Optional[List[str]] = []
44+
storage_path: Optional[str] = ""
45+
custom_title: Optional[str] = ""
46+
custom_attachment_path: Optional[str] = ""
47+
48+
class Notifications(BaseModel):
49+
"""YAML schema for user provided notification settings"""
50+
apprise: Optional[AppRiseNotifyConfig] = None
51+
3952
# pylint: disable=too-few-public-methods
4053
class UserInput(BaseModel):
4154
"""YAML schema for user provided configuration file"""
@@ -48,3 +61,4 @@ class UserInput(BaseModel):
4861
keep_last: Optional[int] = 0
4962
run_interval: Optional[int] = 0
5063
http_config: Optional[HttpConfig] = HttpConfig()
64+
notifications: Optional[Notifications] = None
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import json
2+
import logging
3+
import os
4+
from typing import Union
5+
6+
from bookstack_file_exporter.config_helper import models
7+
from bookstack_file_exporter.common.util import check_var
8+
9+
log = logging.getLogger(__name__)
10+
11+
_APPRISE_FIELDS = {
12+
"urls": "APPRISE_URLS",
13+
# "config_path": "APPRISE_CONFIG_PATH",
14+
# "plugin_paths": "APPRISE_PLUGIN_PATHS",
15+
# "storage_path": "APPRISE_STORAGE_PATH"
16+
}
17+
18+
_DEFAULT_TITLE = "Bookstack File Exporter Failed"
19+
20+
# pylint: disable=too-few-public-methods
21+
class AppRiseNotifyConfig:
22+
"""
23+
Convenience class to hold apprise notification configuration
24+
25+
Args:
26+
:config: <models.AppRiseNotifyConfig> = user input configuration
27+
28+
Returns:
29+
AppRiseNotifyConfig instance for holding configuration
30+
"""
31+
def __init__(self, config: models.AppRiseNotifyConfig):
32+
self.service_urls: Union[str, list] = check_var(_APPRISE_FIELDS["urls"],
33+
config.service_urls, can_error=True)
34+
self.config_path = config.config_path
35+
self.plugin_paths = config.plugin_paths
36+
self.storage_path = config.storage_path
37+
self.custom_title = config.custom_title
38+
self.custom_attachment = config.custom_attachment_path
39+
40+
def validate(self) -> None:
41+
"""validate apprise configuration"""
42+
if not self.config_path and not self.service_urls:
43+
raise ValueError("""apprise config_path and service_urls are
44+
missing from configuration - at least one should be set""")
45+
46+
# if not config path/file given, then we use service_urls
47+
if not self.config_path:
48+
# if not config path style, we try service_urls
49+
# if service_urls defined in env, override main config file value
50+
if os.environ.get(_APPRISE_FIELDS["urls"]) is not None:
51+
try:
52+
new_urls = json.loads(self.service_urls)
53+
self.service_urls = new_urls
54+
# json errors can be hard to debug, add helpful log message
55+
except json.decoder.JSONDecodeError as url_err:
56+
log.Error("Failed to parse env var for apprise urls. \
57+
Ensure proper json string format")
58+
raise url_err
59+
60+
# set default custom_title if not provided
61+
if not self.custom_title:
62+
self.custom_title = _DEFAULT_TITLE

bookstack_file_exporter/config_helper/remote.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def __init__(self, access_key: str, secret_key: str, config: ObjectStorageConfig
2525
self.config = config
2626
self._access_key = access_key
2727
self._secret_key = secret_key
28-
self._valid_checker = {'minio': self._is_minio_valid()}
28+
self._valid_checker = {'minio': self._is_minio_valid}
2929

3030
@property
3131
def access_key(self) -> str:
@@ -40,7 +40,7 @@ def secret_key(self) -> str:
4040
def is_valid(self, storage_type: str) -> bool:
4141
"""check if object storage config is valid"""
4242
return self._valid_checker[storage_type]
43-
43+
4444
def _is_minio_valid(self) -> bool:
4545
"""check if minio config is valid"""
4646
# required values - keys and bucket already checked so skip

bookstack_file_exporter/notify/__init__.py

Whitespace-only changes.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import logging
2+
3+
from bookstack_file_exporter.config_helper import models, notifications
4+
from bookstack_file_exporter.notify import notifiers
5+
6+
7+
log = logging.getLogger(__name__)
8+
9+
# pylint: disable=too-few-public-methods
10+
class NotifyHandler:
11+
"""
12+
NotifyHandler helps push out notifications for failed export runs
13+
14+
Args:
15+
:config: <models.Notifications> = User input configuration for notification handlers
16+
17+
Returns:
18+
NotifyHandler instance to help handle notification integrations.
19+
"""
20+
def __init__(self, config: models.Notifications):
21+
self.targets = self._get_targets(config)
22+
self._supported_notifiers={
23+
"apprise": self._handle_apprise
24+
}
25+
26+
def _get_targets(self, config: models.Notifications):
27+
targets = {}
28+
29+
if config.apprise:
30+
targets["apprise"] = config.apprise
31+
32+
return targets
33+
34+
def do_notify(self, excep: Exception) -> None:
35+
"""handle notification sending for all configured targets"""
36+
if len(self.targets) == 0:
37+
log.debug("No notification targets found")
38+
return
39+
for target, config in self.targets.items():
40+
log.debug("Starting notification handling for: %s", target)
41+
self._supported_notifiers[target](config, excep)
42+
43+
44+
def _handle_apprise(self, config: models.AppRiseNotifyConfig, excep: Exception):
45+
a_config = notifications.AppRiseNotifyConfig(config)
46+
a_config.validate()
47+
apprise = notifiers.AppRiseNotify(a_config)
48+
apprise.notify(excep)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from datetime import datetime
2+
from apprise import Apprise, AppriseAsset, AppriseConfig
3+
4+
from bookstack_file_exporter.config_helper import notifications
5+
6+
# pylint: disable=too-few-public-methods
7+
class AppRiseNotify:
8+
"""
9+
AppRiseNotify helps send notifications via apprise for failed export runs
10+
11+
Args:
12+
:config: <notifications.AppRiseNotifyConfig> = Configuration with user inputs and
13+
general options
14+
15+
Returns:
16+
AppRiseNotify instance to help handle apprise notification integration.
17+
"""
18+
def __init__(self, config: notifications.AppRiseNotifyConfig):
19+
self.config = config
20+
self._client = self._create_client()
21+
22+
def _create_client(self):
23+
client = Apprise()
24+
asset = AppriseAsset()
25+
26+
if self.config.storage_path:
27+
asset.storage_path=self.config.storage_path
28+
29+
if self.config.plugin_paths:
30+
asset.plugin_paths = self.config.plugin_paths
31+
32+
if self.config.config_path:
33+
app_config = AppriseConfig()
34+
app_config.add(self.config.config_path)
35+
client.add(app_config)
36+
else:
37+
client.add(self.config.service_urls)
38+
39+
client.asset=asset
40+
return client
41+
42+
def _get_message_body(self, error_msg: str) -> str:
43+
timestamp = datetime.today().strftime('%Y-%m-%d %H:%M:%S')
44+
body = f"""
45+
Bookstack File Exporter encountered an unrecoverable error.
46+
47+
Occurred At: {timestamp}
48+
49+
Error message: {error_msg}
50+
"""
51+
return body
52+
53+
def notify(self, excep: Exception):
54+
"""send notification with exception message"""
55+
custom_body = self._get_message_body(str(excep))
56+
if self.config.custom_attachment:
57+
self._client.notify(
58+
title=self.config.custom_title,
59+
body=custom_body,
60+
attach=self.config.custom_attachment
61+
)
62+
else:
63+
self._client.notify(
64+
title=self.config.custom_title,
65+
body=custom_body
66+
)

0 commit comments

Comments
 (0)