Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mattermost Alert Flow #5173

Open
wants to merge 2 commits into
base: matiasb/mattermost-chatops
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions engine/apps/alerts/models/alert_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
)
from apps.base.models import UserNotificationPolicyLogRecord
from apps.labels.models import AlertGroupAssociatedLabel
from apps.mattermost.models import MattermostMessage
from apps.slack.models import SlackMessage

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1989,6 +1990,10 @@ def slack_message(self) -> typing.Optional["SlackMessage"]:
except AttributeError:
return self.slack_messages.order_by("created_at").first()

@property
def mattermost_message(self) -> typing.Optional["MattermostMessage"]:
return self.mattermost_messages.order_by("created_at").first()

@cached_property
def last_stop_escalation_log(self):
from apps.alerts.models import AlertGroupLogRecord
Expand Down
6 changes: 6 additions & 0 deletions engine/apps/base/models/live_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class LiveSetting(models.Model):
"MATTERMOST_HOST",
"MATTERMOST_BOT_TOKEN",
"MATTERMOST_LOGIN_RETURN_REDIRECT_HOST",
"MATTERMOST_SIGNING_SECRET",
)

DESCRIPTIONS = {
Expand Down Expand Up @@ -217,6 +218,11 @@ class LiveSetting(models.Model):
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
),
"MATTERMOST_SIGNING_SECRET": (
"Check <a href='"
"https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup"
"' target='_blank'>instruction</a> for details how to set up Mattermost. "
),
}

SECRET_SETTING_NAMES = (
Expand Down
87 changes: 87 additions & 0 deletions engine/apps/mattermost/alert_group_representative.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import logging

from rest_framework import status

from apps.alerts.models import AlertGroup
from apps.alerts.representative import AlertGroupAbstractRepresentative
from apps.mattermost.alert_rendering import MattermostMessageRenderer
from apps.mattermost.client import MattermostClient
from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid
from apps.mattermost.tasks import on_alert_group_action_triggered_async, on_create_alert_async

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)


class AlertGroupMattermostRepresentative(AlertGroupAbstractRepresentative):
def __init__(self, log_record) -> None:
self.log_record = log_record

def is_applicable(self):
from apps.mattermost.models import MattermostChannel

organization = self.log_record.alert_group.channel.organization
handler_exists = self.log_record.type in self.get_handler_map().keys()

mattermost_channels = MattermostChannel.objects.filter(organization=organization)
return handler_exists and mattermost_channels.exists()

@staticmethod
def get_handler_map():
from apps.alerts.models import AlertGroupLogRecord

return {
AlertGroupLogRecord.TYPE_ACK: "alert_group_action",
AlertGroupLogRecord.TYPE_UN_ACK: "alert_group_action",
AlertGroupLogRecord.TYPE_AUTO_UN_ACK: "alert_group_action",
AlertGroupLogRecord.TYPE_RESOLVED: "alert_group_action",
AlertGroupLogRecord.TYPE_UN_RESOLVED: "alert_group_action",
AlertGroupLogRecord.TYPE_ACK_REMINDER_TRIGGERED: "alert_group_action",
AlertGroupLogRecord.TYPE_SILENCE: "alert_group_action",
AlertGroupLogRecord.TYPE_UN_SILENCE: "alert_group_action",
AlertGroupLogRecord.TYPE_ATTACHED: "alert_group_action",
AlertGroupLogRecord.TYPE_UNATTACHED: "alert_group_action",
}

def on_alert_group_action(self, alert_group: AlertGroup):
logger.info(f"Update mattermost message for alert_group {alert_group.pk}")
payload = MattermostMessageRenderer(alert_group).render_alert_group_message()
mattermost_message = alert_group.mattermost_message
try:
client = MattermostClient()
client.update_post(post_id=mattermost_message.post_id, data=payload)
except MattermostAPITokenInvalid:
logger.error(f"Mattermost API token is invalid could not create post for alert {alert_group.pk}")
except MattermostAPIException as ex:
logger.error(f"Mattermost API error {ex}")
if ex.status not in [status.HTTP_401_UNAUTHORIZED]:
raise ex

@staticmethod
def on_create_alert(**kwargs):
alert_pk = kwargs["alert"]
on_create_alert_async.apply_async((alert_pk,))

@staticmethod
def on_alert_group_action_triggered(**kwargs):
from apps.alerts.models import AlertGroupLogRecord

log_record = kwargs["log_record"]
if isinstance(log_record, AlertGroupLogRecord):
log_record_id = log_record.pk
else:
log_record_id = log_record
on_alert_group_action_triggered_async.apply_async((log_record_id,))

def get_handler(self):
handler_name = self.get_handler_name()
logger.info(f"Using '{handler_name}' handler to process alert action in mattermost")
if hasattr(self, handler_name):
handler = getattr(self, handler_name)
else:
handler = None

return handler

def get_handler_name(self):
return self.HANDLER_PREFIX + self.get_handler_map()[self.log_record.type]
128 changes: 128 additions & 0 deletions engine/apps/mattermost/alert_rendering.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from apps.alerts.incident_appearance.renderers.base_renderer import AlertBaseRenderer, AlertGroupBaseRenderer
from apps.alerts.incident_appearance.templaters.alert_templater import AlertTemplater
from apps.alerts.models import Alert, AlertGroup
from apps.mattermost.utils import MattermostEventAuthenticator
from common.api_helpers.utils import create_engine_url
from common.utils import is_string_with_visible_characters, str_or_backup


class MattermostMessageRenderer:
def __init__(self, alert_group: AlertGroup):
self.alert_group = alert_group

def render_alert_group_message(self):
attachments = AlertGroupMattermostRenderer(self.alert_group).render_alert_group_attachments()
return {"props": {"attachments": attachments}}


class AlertMattermostTemplater(AlertTemplater):
RENDER_FOR_MATTERMOST = "mattermost"

def _render_for(self) -> str:
return self.RENDER_FOR_MATTERMOST


class AlertMattermostRenderer(AlertBaseRenderer):
def __init__(self, alert: Alert):
super().__init__(alert)
self.channel = alert.group.channel

@property
def templater_class(self):
return AlertMattermostTemplater

def render_alert_attachments(self):
attachments = []
title = str_or_backup(self.templated_alert.title, "Alert")
message = ""
if is_string_with_visible_characters(self.templated_alert.message):
message = self.templated_alert.message
attachments.append(
{
"fallback": "{}: {}".format(self.channel.get_integration_display(), self.alert.title),
"title": title,
"title_link": self.templated_alert.source_link,
"text": message,
"image_url": self.templated_alert.image_url,
}
)
return attachments


class AlertGroupMattermostRenderer(AlertGroupBaseRenderer):
def __init__(self, alert_group: AlertGroup):
super().__init__(alert_group)

self.alert_renderer = self.alert_renderer_class(self.alert_group.alerts.last())

@property
def alert_renderer_class(self):
return AlertMattermostRenderer

def render_alert_group_attachments(self):
attachments = self.alert_renderer.render_alert_attachments()
alert_group = self.alert_group

if alert_group.resolved:
attachments.append(
{
"fallback": "Resolved...",
"text": alert_group.get_resolve_text(),
}
)
elif alert_group.acknowledged:
attachments.append(
{
"fallback": "Acknowledged...",
"text": alert_group.get_acknowledge_text(),
}
)

# append buttons to the initial attachment
attachments[0]["actions"] = self._get_buttons_attachments()

return self._set_attachments_color(attachments)

def _get_buttons_attachments(self):
actions = []

def _make_actions(id, name, token):
return {
"id": id,
"name": name,
"integration": {
"url": create_engine_url("api/internal/v1/mattermost/event/"),
"context": {
"action": id,
"token": token,
},
},
}

token = MattermostEventAuthenticator.create_token(organization=self.alert_group.channel.organization)
if not self.alert_group.resolved:
if self.alert_group.acknowledged:
actions.append(_make_actions("unacknowledge", "Unacknowledge", token))
else:
actions.append(_make_actions("acknowledge", "Acknonwledge", token))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: Acknonwledge -> Acknowledge

Also visible in your screenshot :)


if self.alert_group.resolved:
actions.append(_make_actions("unresolve", "Unresolve", token))
else:
actions.append(_make_actions("resolve", "Resolve", token))

return actions

def _set_attachments_color(self, attachments):
color = "#a30200" # danger
if self.alert_group.silenced:
color = "#dddddd" # slack-grey
if self.alert_group.acknowledged:
color = "#daa038" # warning
if self.alert_group.resolved:
color = "#2eb886" # good

for attachment in attachments:
attachment["color"] = color

return attachments
3 changes: 3 additions & 0 deletions engine/apps/mattermost/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@

class MattermostConfig(AppConfig):
name = "apps.mattermost"

def ready(self) -> None:
import apps.mattermost.signals # noqa: F401
24 changes: 24 additions & 0 deletions engine/apps/mattermost/client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from dataclasses import dataclass
from typing import Optional

Expand Down Expand Up @@ -33,6 +34,13 @@ class MattermostChannel:
display_name: str


@dataclass
class MattermostPost:
post_id: str
channel_id: str
user_id: str


class MattermostClient:
def __init__(self, token: Optional[str] = None) -> None:
self.token = token or settings.MATTERMOST_BOT_TOKEN
Expand Down Expand Up @@ -82,3 +90,19 @@ def get_user(self, user_id: str = "me"):
self._check_response(response)
data = response.json()
return MattermostUser(user_id=data["id"], username=data["username"], nickname=data["nickname"])

def create_post(self, channel_id: str, data: dict):
url = f"{self.base_url}/posts"
data.update({"channel_id": channel_id})
response = requests.post(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token))
self._check_response(response)
data = response.json()
return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"])

def update_post(self, post_id: str, data: dict):
url = f"{self.base_url}/posts/{post_id}"
data.update({"id": post_id})
response = requests.put(url=url, data=json.dumps(data), timeout=self.timeout, auth=TokenAuth(self.token))
self._check_response(response)
data = response.json()
return MattermostPost(post_id=data["id"], channel_id=data["channel_id"], user_id=data["user_id"])
8 changes: 8 additions & 0 deletions engine/apps/mattermost/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,11 @@ def __init__(self, status, url, msg="", method="GET"):

def __str__(self) -> str:
return f"MattermostAPIException: status={self.status} url={self.url} method={self.method} error={self.msg}"


class MattermostEventTokenInvalid(Exception):
def __init__(self, msg=""):
self.msg = msg

def __str__(self):
return f"MattermostEventTokenInvalid message={self.msg}"
16 changes: 14 additions & 2 deletions engine/apps/mattermost/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 4.2.15 on 2024-10-07 02:12
# Generated by Django 4.2.15 on 2024-10-15 05:17

import apps.mattermost.models.channel
import django.core.validators
Expand All @@ -12,6 +12,7 @@ class Migration(migrations.Migration):

dependencies = [
('user_management', '0022_alter_team_unique_together'),
('alerts', '0060_relatedincident'),
]

operations = [
Expand All @@ -23,7 +24,18 @@ class Migration(migrations.Migration):
('username', models.CharField(max_length=100)),
('nickname', models.CharField(blank=True, default=None, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_connection', to='user_management.user')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_user_identity', to='user_management.user')),
],
),
migrations.CreateModel(
name='MattermostMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('post_id', models.CharField(max_length=100)),
('channel_id', models.CharField(max_length=100)),
('message_type', models.IntegerField(choices=[(0, 'Alert group message'), (1, 'Log message')])),
('created_at', models.DateTimeField(auto_now_add=True)),
('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mattermost_messages', to='alerts.alertgroup')),
],
),
migrations.CreateModel(
Expand Down
1 change: 1 addition & 0 deletions engine/apps/mattermost/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .channel import MattermostChannel # noqa: F401
from .message import MattermostMessage # noqa F401
from .user import MattermostUser # noqa F401
11 changes: 11 additions & 0 deletions engine/apps/mattermost/models/channel.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import typing

from django.conf import settings
from django.core.validators import MinLengthValidator
from django.db import models, transaction

from apps.alerts.models import AlertGroup
from common.insight_log.chatops_insight_logs import ChatOpsEvent, ChatOpsTypePlug, write_chatops_insight_log
from common.public_primary_keys import generate_public_primary_key, increase_public_primary_key_length

Expand Down Expand Up @@ -44,6 +47,14 @@ class MattermostChannel(models.Model):
class Meta:
unique_together = ("organization", "channel_id")

@classmethod
def get_channel_for_alert_group(cls, alert_group: AlertGroup) -> typing.Optional["MattermostChannel"]:
default_channel = cls.objects.filter(
organization=alert_group.channel.organization, is_default_channel=True
).first()

return default_channel

def make_channel_default(self, author):
try:
old_default_channel = MattermostChannel.objects.get(organization=self.organization, is_default_channel=True)
Expand Down
Loading