Skip to content

Commit 45b6045

Browse files
authored
Merge pull request #9 from GabrielSalla/enable-plugins
Create plugins feature
2 parents def6649 + ac340cc commit 45b6045

File tree

36 files changed

+627
-314
lines changed

36 files changed

+627
-314
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ Sentinela is also well-suited for monitoring state machines, where it is essenti
2525
# Documentation
2626
1. [Overview](./docs/overview.md)
2727
2. [Monitor](./docs/monitor.md)
28+
2. [Plugins](./docs/plugins.md)
29+
1. [Slack](./docs/plugin_slack.md)
2830
3. [Querying data from databases](./docs/querying.md)
2931
4. [Registering a monitor](./docs/monitor_registering.md)
3032
5. [Usage](./docs/usage.md)

docs/monitor.md

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@ To create a monitor, import specific dependencies from `monitor_utils`. Availabl
2828
- [`ValueRule`](#value-rule)
2929
- [`PriorityLevels`](#priority-levels)
3030

31-
**Notifications**
32-
- [`SlackNotification`](#slack-notification)
33-
3431
**Functions**
3532
- [`query`](#query)
3633
- [`read_file`](#read-file)
@@ -225,33 +222,7 @@ result = await asyncio.to_thread(blocking_function)
225222
# Notifications
226223
Notifications are optional and can be configured to send notifications to different targets without needing extensive settings for ech monitor. Configure notifications by creating the `notification_options` variable with a list of the desired notifications. Each notification has it's own settings and behaviors.
227224

228-
Available notifications are:
229-
- `SlackNotification`: Sends notifications to a specified Slack channel.
230-
231-
## Slack notification
232-
The **SlackNotification** class manages sending notifications for alerts to a specified Slack channel.
233-
234-
Parameters:
235-
- `channel`: The Slack channel where notifications will be sent.
236-
- `title`: A title for the notification to help users to identify the problem.
237-
- `issues_fields`: A list of fields from the issue data to include in the notification.
238-
- `mention`: Slack user or group to mention if the alert reaches a specified priority. Provide the Slack identifier for a user (e.g., `U0011223344`) or a group (e.g., `G0011223344`). Set to `None` to avoid mentioning anyone. Defaults to `None`.
239-
- `min_priority_to_mention`: Minimum alert priority that triggers a mention. Mentions will occur if the alert is not acknowledged at the current priority level and it's is greater than or equal to this setting. Defaults to `moderate` (P3).
240-
241-
The Slack message will show the alert and it's issues information. The notification will persist and will be updated until the alert is solved, even if it's priority falls to P5.
242-
243-
The notification also includes buttons to interact with the alert, allowing it to be acknowledged, locked or solved. The latest is only included if the issues setting was set as **not solvable**.
244-
245-
```python
246-
notification_options = [
247-
SlackNotification(
248-
channel="C0011223344",
249-
title="Alert name",
250-
issues_fields=["id", "name"],
251-
mention="U0011223344",
252-
)
253-
]
254-
```
225+
Notifications are provided as plugins. Check the [plugins documentation](./plugins.md) for more information.
255226

256227
# Reactions
257228
Reactions are optional and can be configured reactions to specific events by creating a `reaction_options` variable with an instance of the `ReactionOptions` class, available in the `monitor_utils` module.

docs/plugin_slack.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Slack Plugin
2+
The Slack plugin offers an interface to interact with Sentinela through Slack. It allows users to receive notifications from Sentinela in a Slack channel while also providing useful commands from notification buttons or Slack messages mentioning the Sentinela bot.
3+
4+
## Slack commands
5+
Sentinela provides two main ways to interact through Slack:
6+
1. **Buttons** in notifications sent to a Slack channel.
7+
2. **Messages** mentioning the Sentinela Slack app directly.
8+
9+
### Buttons in notifications
10+
Slack notifications can include buttons to interact with the notifications, depending on it's priority and status. Buttons are shown only for active notifications.
11+
12+
Possible buttons:
13+
- **Ack**: Acknowledge the alert. Visible if the alert has not yet been acknowledged at the priority level.
14+
- **Lock**: Lock the alert. Visible if the alert is not already locked.
15+
- **Solve**: Solves the alert. Visible only if the monitor’s issue settings is set as **not solvable**.
16+
17+
![Slack message with buttons](./images/slack_notification_message_with_buttons.png)
18+
19+
### Messages mentioning Sentinela
20+
As a Slack app, Sentinela can also respond to direct commands sent in a message. To interact this way, mention the Sentinela app, followed by the desired action.
21+
22+
Available commands:
23+
- `disable monitor {monitor_name}`: Disable the specified monitor.
24+
- `enable monitor {monitor_name}`: Enable the specified monitor.
25+
- `ack {alert_id}`: Acknowledge the specified alert.
26+
- `lock {alert_id}`: Lock the specified alert.
27+
- `solve {alert_id}`: Solve the specified alert.
28+
- `drop issue {issue_id}`: Drop the specified issue.
29+
- `resend notifications`: Delete and resend all active notifications for the current channel. Sometimes a Slack channel can have a lot of messages and a notification might get lost in the past. This command will resend the notification message so it'll be among the latest messages.
30+
31+
Examples:
32+
- `@Sentinela disable monitor some_monitor`
33+
- `@Sentinela enable monitor some_monitor`
34+
- `@Sentinela ack 1234`
35+
- `@Sentinela lock 2345`
36+
- `@Sentinela solve 3456`
37+
- `@Sentinela drop issue 1212`
38+
- `@Sentinela resend notifications`
39+
40+
> [!WARNING]
41+
> Ensure the message is using the correct `@` mention for the Sentinela Slack app in your workspace.
42+
43+
## Actions
44+
Slack plugin implements a single action to handle the `resend notification` command.
45+
46+
## Notifications
47+
```python
48+
from plugins.slack.notifications import SlackNotification
49+
```
50+
51+
The **SlackNotification** class manages sending notifications for alerts to a specified Slack channel.
52+
53+
Parameters:
54+
- `channel`: The Slack channel where notifications will be sent.
55+
- `title`: A title for the notification to help users to identify the problem.
56+
- `issues_fields`: A list of fields from the issue data to include in the notification.
57+
- `mention`: Slack user or group to mention if the alert reaches a specified priority. Provide the Slack identifier for a user (e.g., `U0011223344`) or a group (e.g., `G0011223344`). Set to `None` to avoid mentioning anyone. Defaults to `None`.
58+
- `min_priority_to_mention`: Minimum alert priority that triggers a mention. Mentions will occur if the alert is not acknowledged at the current priority level and it's is greater than or equal to this setting. Defaults to `moderate` (P3).
59+
60+
The Slack message will show the alert and it's issues information. The notification will persist and will be updated until the alert is solved, even if it's priority falls to P5.
61+
62+
The notification also includes buttons to interact with the alert, allowing it to be acknowledged, locked or solved. The latest is only included if the issues setting was set as **not solvable**.
63+
64+
```python
65+
notification_options = [
66+
SlackNotification(
67+
channel="C0011223344",
68+
title="Alert name",
69+
issues_fields=["id", "name"],
70+
mention="U0011223344",
71+
)
72+
]
73+
```
74+
75+
## Services
76+
The Slack plugin includes a service that connects to the Slack websocket API to receive mentions and button press events. Any event received will queue an action to be processed by Sentinela.

docs/plugins.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Plugins
2+
Plugins are a way to add functionality to Sentinela without needing to edit the application code. Plugins can be added to the `src/plugins` directory and will be automatically loaded by Sentinela.
3+
4+
Each plugin is a module that can implement it's own behaviors. Each plugin should follow the following basic structure:
5+
6+
```
7+
src/plugins/my_plugin
8+
├── __init__.py
9+
├── actions
10+
│   └── __init__.py
11+
├── notifications
12+
│   └── __init__.py
13+
└── services
14+
   └── __init__.py
15+
```
16+
17+
More files or functionalities can be included with the plugin as long as they are available in each internal `actions`, `notifications` or `services` module.
18+
19+
**Each plugin should provide it's own documentation to guide users on how to use their functionalities.**
20+
21+
## Actions
22+
Actions are used as custom behaviors to requests received by sentinela. If sentinela receives an action request named `plugin.my_plugin.some_action`, it'll look for the `some_action` function in the `actions` module of `my_plugin`.
23+
24+
Actions must have the following signature:
25+
```python
26+
async def action_name(message_payload: dict[Any, Any]):
27+
```
28+
29+
An example of the action call made by Sentinela is:
30+
```python
31+
await plugin.my_plugin.actions.action_name({"key": "value"})
32+
```
33+
34+
## Notifications
35+
Notifications are used by monitors, usually to notify about an event. Each notification has it's own settings and behaviors.
36+
37+
Notifications must inherit from the `src.notifications.base_notification.BaseNotification` class. The method `reactions_list` must return a list of reactions that will trigger an action. Each reaction is a tuple with the reaction name and a list of coroutines that must be called when the reaction is triggered.
38+
39+
Example:
40+
```python
41+
def reactions_list(self) -> list[tuple[str, list[Coroutine | partial[Coroutine]]]]:
42+
"""Get a list of events that the notification will react to"""
43+
return [
44+
("alert_acknowledged", [handle_event_acknowledged]),
45+
("alert_created", [handle_event_created]),
46+
("alert_solved", [handle_event_solved]),
47+
]
48+
```
49+
50+
The reaction functions must follow the same structure presented in the [Monitor](./monitors.md) documentation.
51+
52+
## Services
53+
Services are used when the plugin has some initialization or running service. An example of a running service is a websocket connection to an external provider.
54+
55+
Each service in the services directory should have the `start` and `stop` async functions. The `start` function is called when Sentinela is starting and the `stop` function is called when Sentinela is finishing.
56+
57+
The `start` function will receive, as parameters, the `controller_enabled` and `executor_enabled` booleans. These parameters are used to know if the controller and executor are running in the current process and can be used to control which functionalities might be enabled or provided in each scenario.
58+
59+
The `start` and `stop` functions must have the following signatures:
60+
61+
```python
62+
async def init(controller_enabled: bool, executor_enabled: bool): ...
63+
64+
async def stop(): ...
65+
```
66+
67+
## Built-in plugins
68+
Sentinela comes with some built-in plugins that can be used to extend the application's functionality.
69+
- [Slack](./plugin_slack.md)

docs/slack_commands.md

Lines changed: 0 additions & 38 deletions
This file was deleted.

sample_monitors/test_monitor/test_monitor.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,8 @@
22
import random
33
from typing import TypedDict
44

5-
from monitor_utils import (
6-
AlertOptions,
7-
CountRule,
8-
IssueOptions,
9-
MonitorOptions,
10-
PriorityLevels,
11-
SlackNotification,
12-
)
5+
from monitor_utils import AlertOptions, CountRule, IssueOptions, MonitorOptions, PriorityLevels
6+
from plugins.slack import SlackNotification
137

148
monitor_options = MonitorOptions(
159
update_cron="* * * * *",

src/components/controller/controller.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
import utils.app as app
1212
from configs import configs
1313
from models import Monitor
14-
from services.slack import websocket as slack_websocket
15-
from utils.async_tools import do_concurrently
1614
from utils.exception_handling import catch_exceptions
1715
from utils.time import format_datetime_iso, is_triggered, now, time_since, time_until_next_trigger
1816

@@ -58,13 +56,6 @@ async def diagnostics() -> tuple[dict[str, Any], list[str]]:
5856
return status, issues
5957

6058

61-
async def _init():
62-
"""Initialize the controller components"""
63-
# Start the Slack websocket only at the controller
64-
if configs.slack_websocket_enabled:
65-
await slack_websocket.init()
66-
67-
6859
async def _queue_task(monitor: Monitor, tasks: list[str]):
6960
"""Send a message to the queue with the monitor tasks that should be executed"""
7061
monitor.set_queued(True)
@@ -148,8 +139,6 @@ async def run():
148139

149140
_logger.info("Controller running")
150141

151-
await _init()
152-
153142
# Load configs
154143
controller_process_schedule = configs.controller_process_schedule
155144

@@ -182,4 +171,3 @@ async def run():
182171

183172
# Wait for Slack websocket and all tasks to finish
184173
_logger.info("Finishing")
185-
await do_concurrently(slack_websocket.close(), *tasks)

src/components/executor/request_handler.py

Lines changed: 28 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44
import traceback
55
from typing import Any
66

7+
import plugins
78
import registry as registry
89
from base_exception import BaseSentinelaException
910
from configs import configs
10-
from models import Alert, Issue, Notification, NotificationStatus
11-
from services.slack import clear_slack_notification
12-
from utils.async_tools import do_concurrently
11+
from models import Alert, Issue
1312

1413
_logger = logging.getLogger("request_handler")
1514

@@ -58,48 +57,45 @@ async def issue_drop(message_payload: dict[Any, Any]):
5857
await issue.drop()
5958

6059

61-
async def resend_slack_notifications(message_payload: dict[Any, Any]):
62-
"""Clear all the notifications for the channel and update all active alerts to queue events to
63-
send the notifications again"""
64-
# Get all active notifications for the channel
65-
notifications = await Notification.get_all(
66-
Notification.status == NotificationStatus.active,
67-
Notification.target == "slack",
68-
Notification.data["channel"].astext == message_payload["slack_channel"],
69-
)
70-
71-
if len(notifications) == 0:
72-
return
73-
74-
monitors_ids = {notification.monitor_id for notification in notifications}
75-
for monitor_id in monitors_ids:
76-
await registry.wait_monitor_loaded(monitor_id)
77-
78-
await do_concurrently(*[
79-
clear_slack_notification(notification)
80-
for notification in notifications
81-
])
82-
83-
alert_ids = list({notification.alert_id for notification in notifications})
84-
alerts = await Alert.get_all(Alert.id.in_(alert_ids))
85-
await do_concurrently(*[alert.update() for alert in alerts])
86-
87-
8860
actions = {
8961
"alert_acknowledge": alert_acknowledge,
9062
"alert_lock": alert_lock,
9163
"alert_solve": alert_solve,
9264
"issue_drop": issue_drop,
93-
"resend_slack_notifications": resend_slack_notifications,
9465
}
9566

9667

68+
def get_action(action_name: str):
69+
"""Get the action function by its name, checking if it is a plugin action"""
70+
if action_name.startswith("plugin."):
71+
plugin_name, action_name = action_name.split(".")[1:3]
72+
73+
plugin = plugins.loaded_plugins.get(plugin_name)
74+
if plugin is None:
75+
_logger.warning(f"Plugin '{plugin_name}' unknown")
76+
return None
77+
78+
plugin_actions = getattr(plugin, "actions", None)
79+
if plugin_actions is None:
80+
_logger.warning(f"Plugin '{plugin_name}' doesn't have actions")
81+
return None
82+
83+
action = getattr(plugin_actions, action_name, None)
84+
if action is None:
85+
_logger.warning(f"Action '{plugin_name}.{action_name}' unknown")
86+
return None
87+
88+
return action
89+
90+
return actions.get(action_name)
91+
92+
9793
async def run(message: dict[Any, Any]):
9894
"""Process a received request"""
9995
message_payload = message["payload"]
10096
action_name = message_payload["action"]
10197

102-
action = actions.get(action_name)
98+
action = get_action(action_name)
10399

104100
if action is None:
105101
_logger.warning(f"Got request with unknown action '{json.dumps(message_payload)}'")

src/components/monitors_loader/monitors_loader.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
_logger = logging.getLogger("monitor_loader")
2121

22+
RELATIVE_PATH = "src"
2223
MONITORS_PATH = "_monitors"
2324
MONITORS_LOAD_PATH = "_monitors_load"
2425
COOL_DOWN_TIME = 2
@@ -281,5 +282,5 @@ async def wait_stop():
281282
global _task
282283
await _task
283284
_logger.info("Removing temporary monitors paths")
284-
shutil.rmtree(MONITORS_LOAD_PATH, ignore_errors=True)
285-
shutil.rmtree(MONITORS_PATH, ignore_errors=True)
285+
shutil.rmtree(Path(RELATIVE_PATH) / MONITORS_LOAD_PATH, ignore_errors=True)
286+
shutil.rmtree(Path(RELATIVE_PATH) / MONITORS_PATH, ignore_errors=True)

0 commit comments

Comments
 (0)