diff --git a/engine/apps/alerts/models/alert_group.py b/engine/apps/alerts/models/alert_group.py index 6a9062bdd4..0a52d01d09 100644 --- a/engine/apps/alerts/models/alert_group.py +++ b/engine/apps/alerts/models/alert_group.py @@ -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__) @@ -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 diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index 32bdeadb93..5921f4bf58 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -85,6 +85,7 @@ class LiveSetting(models.Model): "MATTERMOST_HOST", "MATTERMOST_BOT_TOKEN", "MATTERMOST_LOGIN_RETURN_REDIRECT_HOST", + "MATTERMOST_SIGNING_SECRET", ) DESCRIPTIONS = { @@ -217,6 +218,11 @@ class LiveSetting(models.Model): "https://grafana.com/docs/oncall/latest/open-source/#mattermost-setup" "' target='_blank'>instruction for details how to set up Mattermost. " ), + "MATTERMOST_SIGNING_SECRET": ( + "Check instruction for details how to set up Mattermost. " + ), } SECRET_SETTING_NAMES = ( diff --git a/engine/apps/mattermost/alert_group_representative.py b/engine/apps/mattermost/alert_group_representative.py new file mode 100644 index 0000000000..d653619b3c --- /dev/null +++ b/engine/apps/mattermost/alert_group_representative.py @@ -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] diff --git a/engine/apps/mattermost/alert_rendering.py b/engine/apps/mattermost/alert_rendering.py new file mode 100644 index 0000000000..af0d81593a --- /dev/null +++ b/engine/apps/mattermost/alert_rendering.py @@ -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", "Acknowledge", token)) + + 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 diff --git a/engine/apps/mattermost/apps.py b/engine/apps/mattermost/apps.py index 7a6fc4ec38..841c7fc88f 100644 --- a/engine/apps/mattermost/apps.py +++ b/engine/apps/mattermost/apps.py @@ -3,3 +3,6 @@ class MattermostConfig(AppConfig): name = "apps.mattermost" + + def ready(self) -> None: + import apps.mattermost.signals # noqa: F401 diff --git a/engine/apps/mattermost/client.py b/engine/apps/mattermost/client.py index 5a3c001168..ac789f50a4 100644 --- a/engine/apps/mattermost/client.py +++ b/engine/apps/mattermost/client.py @@ -1,3 +1,4 @@ +import json from dataclasses import dataclass from typing import Optional @@ -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 @@ -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"]) diff --git a/engine/apps/mattermost/exceptions.py b/engine/apps/mattermost/exceptions.py index 5df5a56f38..a96b90c993 100644 --- a/engine/apps/mattermost/exceptions.py +++ b/engine/apps/mattermost/exceptions.py @@ -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}" diff --git a/engine/apps/mattermost/migrations/0001_initial.py b/engine/apps/mattermost/migrations/0001_initial.py index 2601961c6d..da5e82a0a6 100644 --- a/engine/apps/mattermost/migrations/0001_initial.py +++ b/engine/apps/mattermost/migrations/0001_initial.py @@ -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 @@ -12,6 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('user_management', '0022_alter_team_unique_together'), + ('alerts', '0060_relatedincident'), ] operations = [ @@ -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( diff --git a/engine/apps/mattermost/models/__init__.py b/engine/apps/mattermost/models/__init__.py index 0cf4dd7f73..cada7d9f23 100644 --- a/engine/apps/mattermost/models/__init__.py +++ b/engine/apps/mattermost/models/__init__.py @@ -1,2 +1,3 @@ from .channel import MattermostChannel # noqa: F401 +from .message import MattermostMessage # noqa F401 from .user import MattermostUser # noqa F401 diff --git a/engine/apps/mattermost/models/channel.py b/engine/apps/mattermost/models/channel.py index f901b814eb..a8ea351c94 100644 --- a/engine/apps/mattermost/models/channel.py +++ b/engine/apps/mattermost/models/channel.py @@ -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 @@ -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) diff --git a/engine/apps/mattermost/models/message.py b/engine/apps/mattermost/models/message.py new file mode 100644 index 0000000000..e41713fd57 --- /dev/null +++ b/engine/apps/mattermost/models/message.py @@ -0,0 +1,33 @@ +from django.db import models + +from apps.alerts.models import AlertGroup +from apps.mattermost.client import MattermostPost + + +class MattermostMessage(models.Model): + ( + ALERT_GROUP_MESSAGE, + LOG_MESSAGE, + ) = range(2) + + MATTERMOST_MESSAGE_CHOICES = ((ALERT_GROUP_MESSAGE, "Alert group message"), (LOG_MESSAGE, "Log message")) + + post_id = models.CharField(max_length=100) + + channel_id = models.CharField(max_length=100) + + message_type = models.IntegerField(choices=MATTERMOST_MESSAGE_CHOICES) + + alert_group = models.ForeignKey( + "alerts.AlertGroup", + on_delete=models.CASCADE, + related_name="mattermost_messages", + ) + + created_at = models.DateTimeField(auto_now_add=True) + + @staticmethod + def create_message(alert_group: AlertGroup, post: MattermostPost, message_type: int): + return MattermostMessage.objects.create( + alert_group=alert_group, post_id=post.post_id, channel_id=post.channel_id, message_type=message_type + ) diff --git a/engine/apps/mattermost/models/user.py b/engine/apps/mattermost/models/user.py index 86d3488962..6029e0a4f6 100644 --- a/engine/apps/mattermost/models/user.py +++ b/engine/apps/mattermost/models/user.py @@ -2,7 +2,7 @@ class MattermostUser(models.Model): - user = models.OneToOneField("user_management.User", on_delete=models.CASCADE, related_name="mattermost_connection") + user = models.OneToOneField("user_management.User", on_delete=models.CASCADE, related_name="mattermost_user_identity") mattermost_user_id = models.CharField(max_length=100) username = models.CharField(max_length=100) nickname = models.CharField(max_length=100, null=True, blank=True, default=None) diff --git a/engine/apps/mattermost/signals.py b/engine/apps/mattermost/signals.py new file mode 100644 index 0000000000..f18b28bdc0 --- /dev/null +++ b/engine/apps/mattermost/signals.py @@ -0,0 +1,5 @@ +from apps.alerts.signals import alert_create_signal, alert_group_action_triggered_signal +from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative + +alert_create_signal.connect(AlertGroupMattermostRepresentative.on_create_alert) +alert_group_action_triggered_signal.connect(AlertGroupMattermostRepresentative.on_alert_group_action_triggered) diff --git a/engine/apps/mattermost/tasks.py b/engine/apps/mattermost/tasks.py new file mode 100644 index 0000000000..274f0ed692 --- /dev/null +++ b/engine/apps/mattermost/tasks.py @@ -0,0 +1,75 @@ +import logging + +from celery.utils.log import get_task_logger +from django.conf import settings +from rest_framework import status + +from apps.alerts.models import Alert +from apps.mattermost.alert_rendering import MattermostMessageRenderer +from apps.mattermost.client import MattermostClient +from apps.mattermost.exceptions import MattermostAPIException, MattermostAPITokenInvalid +from apps.mattermost.models import MattermostChannel, MattermostMessage +from common.custom_celery_tasks import shared_dedicated_queue_retry_task +from common.utils import OkToRetry + +logger = get_task_logger(__name__) +logger.setLevel(logging.DEBUG) + + +@shared_dedicated_queue_retry_task( + bind=True, autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None +) +def on_create_alert_async(self, alert_pk): + """ + It's async in order to prevent Mattermost downtime or formatting issues causing delay with SMS and other destinations. + """ + try: + alert = Alert.objects.get(pk=alert_pk) + except Alert.DoesNotExist as e: + if on_create_alert_async.request.retries >= 10: + logger.error(f"Alert {alert_pk} was not found. Probably it was deleted. Stop retrying") + return + else: + raise e + + alert_group = alert.group + mattermost_channel = MattermostChannel.get_channel_for_alert_group(alert_group=alert_group) + payload = MattermostMessageRenderer(alert_group).render_alert_group_message() + + with OkToRetry(task=self, exc=(MattermostAPIException,), num_retries=3): + try: + client = MattermostClient() + mattermost_post = client.create_post(channel_id=mattermost_channel.channel_id, data=payload) + except MattermostAPITokenInvalid: + logger.error(f"Mattermost API token is invalid could not create post for alert {alert_pk}") + except MattermostAPIException as ex: + logger.error(f"Mattermost API error {ex}") + if ex.status not in [status.HTTP_401_UNAUTHORIZED]: + raise ex + else: + MattermostMessage.create_message( + alert_group=alert_group, post=mattermost_post, message_type=MattermostMessage.ALERT_GROUP_MESSAGE + ) + + +@shared_dedicated_queue_retry_task( + autoretry_for=(Exception,), retry_backoff=True, max_retries=1 if settings.DEBUG else None +) +def on_alert_group_action_triggered_async(log_record_id): + from apps.alerts.models import AlertGroupLogRecord + from apps.mattermost.alert_group_representative import AlertGroupMattermostRepresentative + + try: + log_record = AlertGroupLogRecord.objects.get(pk=log_record_id) + except AlertGroupLogRecord.DoesNotExist as e: + logger.warning(f"Mattermost representative: log record {log_record_id} never created or has been deleted") + raise e + + alert_group_id = log_record.alert_group_id + logger.info( + f"Start mattermost on_alert_group_action_triggered for alert_group {alert_group_id}, log record {log_record_id}" + ) + representative = AlertGroupMattermostRepresentative(log_record) + if representative.is_applicable(): + handler = representative.get_handler() + handler(log_record.alert_group) diff --git a/engine/apps/mattermost/urls.py b/engine/apps/mattermost/urls.py index 0fba7b76d2..6743c7b1fc 100644 --- a/engine/apps/mattermost/urls.py +++ b/engine/apps/mattermost/urls.py @@ -1,8 +1,8 @@ from django.urls import include, path -from common.api_helpers.optional_slash_router import OptionalSlashRouter +from common.api_helpers.optional_slash_router import OptionalSlashRouter, optional_slash_path -from .views import MattermostChannelViewSet +from .views import MattermostChannelViewSet, MattermostEventView app_name = "mattermost" router = OptionalSlashRouter() @@ -10,4 +10,5 @@ urlpatterns = [ path("", include(router.urls)), + optional_slash_path("event", MattermostEventView.as_view(), name="incoming_mattermost_event"), ] diff --git a/engine/apps/mattermost/utils.py b/engine/apps/mattermost/utils.py new file mode 100644 index 0000000000..44f3a97daf --- /dev/null +++ b/engine/apps/mattermost/utils.py @@ -0,0 +1,37 @@ +import typing +import datetime +import logging + +import jwt +from django.conf import settings +from django.utils import timezone + +from apps.mattermost.exceptions import MattermostEventTokenInvalid +if typing.TYPE_CHECKING: + from apps.user_management.models import Organization + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + + +class MattermostEventAuthenticator: + @staticmethod + def create_token(organization: typing.Optional["Organization"]): + secret = settings.MATTERMOST_SIGNING_SECRET + expiration = timezone.now() + datetime.timedelta(days=30) + payload = { + "organization_id": organization.public_primary_key, + "exp": expiration, + } + token = jwt.encode(payload, secret, algorithm="HS256") + return token + + @staticmethod + def verify(token: str): + secret = settings.MATTERMOST_SIGNING_SECRET + try: + payload = jwt.decode(token, secret, algorithms="HS256") + return payload + except jwt.InvalidTokenError as e: + logger.error(f"Error while verifying mattermost token {e}") + raise MattermostEventTokenInvalid(msg="Invalid token from mattermost server") diff --git a/engine/apps/mattermost/views.py b/engine/apps/mattermost/views.py index feac1106cb..84fa263a1e 100644 --- a/engine/apps/mattermost/views.py +++ b/engine/apps/mattermost/views.py @@ -2,6 +2,7 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.views import APIView from apps.api.permissions import RBACPermission from apps.auth_token.auth import PluginAuthentication @@ -61,3 +62,29 @@ def perform_destroy(self, instance): channel_id=instance.channel_id, ) instance.delete() + + +class MattermostEventView(APIView): + def get(self, request, format=None): + return Response("hello") + + # Sample Request Payload + # { + # "user_id":"k8y8fccx57ygpq18oxp8pp3ntr", + # "user_name":"hbx80530", + # "channel_id":"gug81e7stfy8md747sewpeeqga", + # "channel_name":"camelcase", + # "team_id":"kjywdxcbjiyyupdgqst8bj8zrw", + # "team_domain":"local", + # "post_id":"cfsogqc61fbj3yssz78b1tarbw", + # "trigger_id":"cXJhd2Zwc2V3aW5nanBjY2I2YzdxdTc5NmE6azh5OGZjY3g1N3lncHExOG94cDhwcDNudHI6MTcyODgyMzQxODU4NzpNRVFDSUgvbURORjQrWFB1R1QzWHdTWGhDZG9rdEpNb3cydFNJL3l5QktLMkZrVjdBaUFaMjdybFB3c21EWUlyMHFIeVpKVnIyR1gwa2N6RzY5YkpuSDdrOEpuVXhnPT0=", + # "type":"", + # "data_source":"", + # "context":{ + # "action":"acknowledge", + # "token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJvcmdhbml6YXRpb25faWQiOiJPMjlJWUQ3S0dRWURNIiwiZXhwIjoxNzMxNDE1Mzc0fQ.RbETrJS_lRDFDa9asGZbNlhMx13qkK0bc10-dj6x4-U" + # } + # } + def post(self, request): + # TODO: Implement the webhook + return Response(status=200) diff --git a/engine/common/api_helpers/mixins.py b/engine/common/api_helpers/mixins.py index 552aaade45..25bfb33398 100644 --- a/engine/common/api_helpers/mixins.py +++ b/engine/common/api_helpers/mixins.py @@ -23,6 +23,7 @@ ) from apps.alerts.models import Alert, AlertGroup from apps.base.messaging import get_messaging_backends +from apps.mattermost.alert_rendering import AlertMattermostTemplater from common.api_helpers.exceptions import BadRequest from common.jinja_templater import apply_jinja_template from common.jinja_templater.apply_jinja_template import JinjaTemplateError, JinjaTemplateWarning @@ -238,8 +239,9 @@ def filter_lookups(child): PHONE_CALL = "phone_call" SMS = "sms" TELEGRAM = "telegram" +MATTERMOST = "mattermost" # templates with its own field in db, this concept replaced by messaging_backend_templates field -NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM] +NOTIFICATION_CHANNEL_OPTIONS = [SLACK, WEB, PHONE_CALL, SMS, TELEGRAM, MATTERMOST] TITLE = "title" MESSAGE = "message" @@ -258,6 +260,7 @@ def filter_lookups(child): PHONE_CALL: AlertPhoneCallTemplater, SMS: AlertSmsTemplater, TELEGRAM: AlertTelegramTemplater, + MATTERMOST: AlertMattermostTemplater, } # add additionally supported messaging backends diff --git a/engine/settings/base.py b/engine/settings/base.py index aa7a928175..cc6389f49f 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -727,6 +727,7 @@ class BrokerTypes: MATTERMOST_HOST = os.environ.get("MATTERMOST_HOST") MATTERMOST_BOT_TOKEN = os.environ.get("MATTERMOST_BOT_TOKEN") MATTERMOST_LOGIN_RETURN_REDIRECT_HOST = os.environ.get("MATTERMOST_LOGIN_RETURN_REDIRECT_HOST", None) +MATTERMOST_SIGNING_SECRET = os.environ.get("MATTERMOST_SIGNING_SECRET", None) SOCIAL_AUTH_SLACK_LOGIN_KEY = SLACK_CLIENT_OAUTH_ID SOCIAL_AUTH_SLACK_LOGIN_SECRET = SLACK_CLIENT_OAUTH_SECRET diff --git a/engine/settings/celery_task_routes.py b/engine/settings/celery_task_routes.py index 29309a7196..bca554abab 100644 --- a/engine/settings/celery_task_routes.py +++ b/engine/settings/celery_task_routes.py @@ -189,4 +189,7 @@ "apps.webhooks.tasks.trigger_webhook.send_webhook_event": {"queue": "webhook"}, "apps.webhooks.tasks.alert_group_status.alert_group_created": {"queue": "webhook"}, "apps.webhooks.tasks.alert_group_status.alert_group_status_change": {"queue": "webhook"}, + # MATTERMOST + "apps.mattermost.tasks.on_create_alert_async": {"queue": "mattermost"}, + "apps.mattermost.tasks.on_alert_group_action_triggered_async": {"queue": "mattermost"}, } diff --git a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts index b8c5efb0c9..1b0d6c4b07 100644 --- a/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts +++ b/grafana-plugin/src/components/AlertTemplates/AlertTemplatesForm.config.ts @@ -1,15 +1,21 @@ +import { merge } from 'lodash-es' + import { AppFeature } from 'state/features'; import { TemplateForEdit, commonTemplateForEdit } from './CommonAlertTemplatesForm.config'; export const getTemplatesForEdit = (features: Record) => { + const templatesForEdit = {...commonTemplateForEdit} if (features?.[AppFeature.MsTeams]) { - return { ...commonTemplateForEdit, ...additionalTemplateForEdit }; + merge(templatesForEdit, msteamsTemplateForEdit) + } + if (features?.[AppFeature.Mattermost]) { + merge(templatesForEdit, mattermostTemplateForEdit) } - return commonTemplateForEdit; + return templatesForEdit; }; -const additionalTemplateForEdit: { [id: string]: TemplateForEdit } = { +const msteamsTemplateForEdit: { [id: string]: TemplateForEdit } = { msteams_title_template: { name: 'msteams_title_template', displayName: 'MS Teams title', @@ -42,4 +48,37 @@ const additionalTemplateForEdit: { [id: string]: TemplateForEdit } = { }, }; +const mattermostTemplateForEdit: { [id: string]: TemplateForEdit } = { + mattermost_title_template: { + name: 'mattermost_title_template', + displayName: 'Mattermost title', + description: '', + additionalData: { + chatOpsName: 'mattermost', + chatOpsDisplayName: 'Mattermost', + }, + type: 'plain', + }, + mattermost_message_template: { + name: 'mattermost_message_template', + displayName: 'Mattermost message', + description: '', + additionalData: { + chatOpsName: 'mattermost', + chatOpsDisplayName: 'Mattermost', + }, + type: 'plain', + }, + mattermost_image_url_template: { + name: 'mattermost_image_url_template', + displayName: 'Mattermost image url', + description: '', + additionalData: { + chatOpsName: 'mattermost', + chatOpsDisplayName: 'Mattermost', + }, + type: 'plain', + }, +}; + export const FORM_NAME = 'AlertTemplates'; diff --git a/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts b/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts index 59b79a5469..616d2ad4e0 100644 --- a/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts +++ b/grafana-plugin/src/containers/IntegrationContainers/IntegrationTemplatesList.config.ts @@ -1,3 +1,5 @@ +import { clone } from 'lodash-es' + import { MONACO_INPUT_HEIGHT_SMALL, MONACO_INPUT_HEIGHT_TALL } from 'pages/integration/IntegrationCommon.config'; import { AppFeature } from 'state/features'; @@ -24,11 +26,35 @@ const additionalTemplatesToRender: TemplateBlock[] = [ }, ], }, + { + name: 'Mattermost', + contents: [ + { + name: 'mattermost_title_template', + label: 'Title', + height: MONACO_INPUT_HEIGHT_SMALL, + }, + { + name: 'mattermost_message_template', + label: 'Message', + height: MONACO_INPUT_HEIGHT_TALL, + }, + { + name: 'mattermost_image_url_template', + label: 'Image', + height: MONACO_INPUT_HEIGHT_SMALL, + }, + ], + } ]; export const getTemplatesToRender = (features?: Record) => { + const templatesToRender = clone(commonTemplatesToRender) if (features?.[AppFeature.MsTeams]) { - return commonTemplatesToRender.concat(additionalTemplatesToRender); + templatesToRender.push(additionalTemplatesToRender[0]); + } + if (features?.[AppFeature.Mattermost]) { + templatesToRender.push(additionalTemplatesToRender[1]) } - return commonTemplatesToRender; + return templatesToRender; };