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

MAIN-2798 - Opsgenie slack #1673

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
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
116 changes: 116 additions & 0 deletions playbooks/robusta_playbooks/sink_enrichments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import logging
from typing import Any, Optional
from urllib.parse import urlparse

from robusta.api import (
ActionParams,
CallbackBlock,
CallbackChoice,
ExecutionBaseEvent,
PrometheusKubernetesAlert,
action,
)
from robusta.core.reporting.base import Link, LinkType


class SlackCallbackParams(ActionParams):
"""
:var slack_username: The username that clicked the slack callback. - Auto-populated by slack
:var slack_message: The message from the slack callback. - Auto-populated by slack
"""
slack_username: Optional[str]
slack_message: Optional[Any]


class OpsGenieAckParams(SlackCallbackParams):
"""
:var alertmanager_url: Alternative Alert Manager url to send requests.
"""
alert_fingerprint: str


@action
def ack_opsgenie_alert_from_slack(event: ExecutionBaseEvent, params: OpsGenieAckParams):
"""
Sends an ack to opsgenie alert
"""
def ack_opsgenie_alert() -> None:
event.emit_event(
"opsgenie_ack",
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved
fingerprint=params.alert_fingerprint,
user=params.slack_username,
note=f"This alert was ack-ed from a Robusta Slack message by {params.slack_username}"
)

if not params.slack_message:
logging.warning("No action Slack found, unable to update slack message.")
return

# slack action block
actions = params.slack_message.get("actions", [])
if not actions:
logging.warning("No actions found in the Slack message.")
return

block_id = actions[0].get("block_id")
Copy link
Contributor

Choose a reason for hiding this comment

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

If in the future we will have mutlipe action what will happen? Why we aren't also checking the action name and making sure that this is indeed the action block and not only assume it is the first one?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Screen Shot 2025-01-08 at 14 19 46
screen shot of the slack action, in the future it will be broken.
This is not currently a functionality we can support or handle on our end so more dev will need to be done to support it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Why we can't find the right action using the text of something like that?

Copy link
Contributor Author

@Avi-Robusta Avi-Robusta Jan 8, 2025

Choose a reason for hiding this comment

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

the name is not always guaranteed, the text here has no action as you see
The only thing guarenteed here is the block_id that was clicked in the callback
Also simplicity is key, if we overcomplicate it by searching for specific text and the text changes in runner that is worse than relying on the ID

Copy link
Contributor

@moshemorad moshemorad Jan 8, 2025

Choose a reason for hiding this comment

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

Honestly i don't think it is worst, If you think it is high effort open ticket to support multiple action in that case and we will do it on next sprint.
Thanks.

if not block_id:
logging.warning("Block ID is missing in the first action of the Slack message.")
return

event.emit_event(
"replace_callback_with_string",
slack_message=params.slack_message,
block_id=block_id,
message_string=f"✅ *OpsGenie Ack by @{params.slack_username}*"
)

ack_opsgenie_alert()
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved


@action
def ack_slack_opsgenie_enricher(alert: PrometheusKubernetesAlert):
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved
"""
Add a button to the alert - clicking it will ask chat gpt to help find a solution.
"""
alert.add_enrichment(
[
CallbackBlock(
{
f'Ack Opsgenie Alert': CallbackChoice(
action=ack_opsgenie_alert_from_slack,
action_params=OpsGenieAckParams(
alert_fingerprint=alert.alert.fingerprint,
),
)
},
)
]
)


class OpsGenieLinkParams(ActionParams):
"""
:var url_base: The base url for your opsgenie account for example: "robusta-test-url.app.eu.opsgenie.com"
"""
url_base: Optional[str] = None
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved


def normalize_url_base(url_base: str) -> str:
"""
Normalize the url_base to remove 'https://' or 'http://' and any trailing slashes.
"""
# Remove the scheme (http/https) if present
parsed_url = urlparse(url_base)
url_base = parsed_url.netloc if parsed_url.netloc else parsed_url.path

# Remove trailing slash if present
return url_base.rstrip('/')


@action
def opsgenie_link_enricher(alert: PrometheusKubernetesAlert, params: OpsGenieLinkParams):
"""
Adds a link to finding of for the opsgenie alert.
"""
normalized_url_base = normalize_url_base(params.url_base)
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved
alert.add_link(Link(url=f"https://{normalized_url_base}/alert/list?query=alias:{alert.alert.fingerprint}", name="OpsGenie Alert", type=LinkType.OPSGENIE))
1 change: 1 addition & 0 deletions src/robusta/core/reporting/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def to_emoji(self) -> str:
class LinkType(StrEnum):
VIDEO = "video"
PROMETHEUS_GENERATOR_URL = "prometheus_generator_url"
OPSGENIE = "opsgenie"
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved


class Link(BaseModel):
Expand Down
23 changes: 23 additions & 0 deletions src/robusta/core/sinks/opsgenie/opsgenie_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ def __init__(self, sink_config: OpsGenieSinkConfigWrapper, registry):
if sink_config.opsgenie_sink.host is not None:
self.conf.host = sink_config.opsgenie_sink.host

self.registry.subscribe("opsgenie_ack", self)

self.api_client = opsgenie_sdk.api_client.ApiClient(configuration=self.conf)
self.alert_api = opsgenie_sdk.AlertApi(api_client=self.api_client)

def handle_event(self, event_name: str, **kwargs):
if event_name == "opsgenie_ack":
self.__ack_alert(**kwargs)
else:
logging.warning("OpsGenieSink subscriber called with unknown event")
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved

def __close_alert(self, finding: Finding):
body = opsgenie_sdk.CloseAlertPayload(
user="Robusta",
Expand All @@ -51,6 +59,21 @@ def __close_alert(self, finding: Finding):
except opsgenie_sdk.ApiException as err:
logging.error(f"Error closing opsGenie alert {finding} {err}", exc_info=True)

def __ack_alert(self, fingerprint: str, user: str, note: str):
body = opsgenie_sdk.AcknowledgeAlertPayload(
user=user,
note=note,
source="Robusta OpsGenie Ack",
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved
)
try:
self.alert_api.acknowledge_alert(
identifier=fingerprint,
acknowledge_alert_payload=body,
identifier_type="alias",
)
except opsgenie_sdk.ApiException as err:
logging.error(f"Error acking opsGenie alert {fingerprint} {err}", exc_info=True)

def __open_alert(self, finding: Finding, platform_enabled: bool):
description = self.__to_description(finding, platform_enabled)
details = self.__to_details(finding)
Expand Down
61 changes: 60 additions & 1 deletion src/robusta/core/sinks/slack/slack_sink.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import logging

from robusta.core.model.env_vars import ROBUSTA_UI_DOMAIN
from robusta.core.reporting.base import Finding, FindingStatus
from robusta.core.sinks.sink_base import NotificationGroup, NotificationSummary, SinkBase
Expand All @@ -15,6 +17,13 @@ def __init__(self, sink_config: SlackSinkConfigWrapper, registry):
self.slack_sender = slack_module.SlackSender(
self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel
)
self.registry.subscribe("replace_callback_with_string", self)

def handle_event(self, event_name: str, **kwargs):
if event_name == "replace_callback_with_string":
self.__replace_callback_with_string(**kwargs)
else:
logging.warning("SlackSink subscriber called with unknown event")

def write_finding(self, finding: Finding, platform_enabled: bool) -> None:
if self.grouping_enabled:
Expand Down Expand Up @@ -75,6 +84,56 @@ def handle_notification_grouping(self, finding: Finding, platform_enabled: bool)
finding, self.params, platform_enabled, thread_ts=slack_thread_ts
)


def get_timeline_uri(self, account_id: str, cluster_name: str) -> str:
return f"{ROBUSTA_UI_DOMAIN}/graphs?account_id={account_id}&cluster={cluster_name}"

def __replace_callback_with_string(self, slack_message, block_id, message_string):
"""
Replace a specific block in a Slack message with a given string while preserving other blocks.

Args:
slack_message (dict): The payload received from Slack.
block_id (str): The ID of the block to replace.
message_string (str): The text to replace the block content with.
"""
try:
# Extract required fields
channel_id = slack_message.get("channel", {}).get("id")
message_ts = slack_message.get("container", {}).get("message_ts")
blocks = slack_message.get("message", {}).get("blocks", [])

# Validate required fields
if not channel_id or not message_ts or not blocks:
raise ValueError("Missing required fields: channel_id, message_ts, or blocks.")

# Update the specific block
updated_blocks = []
block_found = False

for block in blocks:
if block.get("block_id") == block_id:
updated_blocks.append({
"type": "section",
"block_id": block_id,
"text": {
"type": "mrkdwn",
"text": message_string
}
})
block_found = True
else:
updated_blocks.append(block)
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved

if not block_found:
raise ValueError(f"No block found with block_id: {block_id}")

# Call the shorter update function
return self.slack_sender.update_slack_message(
channel=channel_id,
ts=message_ts,
blocks=updated_blocks,
text=message_string
)

except Exception as e:
logging.exception(f"Error updating Slack message: {e}")
44 changes: 36 additions & 8 deletions src/robusta/integrations/receiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@
import logging
import os
import time
from threading import Thread
from typing import Dict, Optional, List, Union
from uuid import UUID

from concurrent.futures import ThreadPoolExecutor
from contextlib import nullcontext
from threading import Thread
from typing import Any, Dict, List, Optional, Union
from uuid import UUID

import websocket
import sentry_sdk
import websocket
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
Expand All @@ -22,9 +21,9 @@
from robusta.core.model.env_vars import (
INCOMING_REQUEST_TIME_WINDOW_SECONDS,
RUNNER_VERSION,
SENTRY_ENABLED,
WEBSOCKET_PING_INTERVAL,
WEBSOCKET_PING_TIMEOUT,
SENTRY_ENABLED,
)
from robusta.core.playbooks.playbook_utils import to_safe_str
from robusta.core.playbooks.playbooks_event_handler import PlaybooksEventHandler
Expand All @@ -50,17 +49,31 @@ class ValidationResponse(BaseModel):
error_msg: Optional[str] = None


class SlackExternalActionRequest(ExternalActionRequest):
# Optional Slack Params
slack_username: Optional[str] = None
slack_message: Optional[Any] = None


class SlackActionRequest(BaseModel):
value: ExternalActionRequest
value: SlackExternalActionRequest

@validator("value", pre=True, always=True)
def validate_value(cls, v: str) -> dict:
# Slack value is sent as a stringified json, so we need to parse it before validation
return json.loads(v)


class SlackUserID(BaseModel):
username: str
name: str
team_id: str


class SlackActionsMessage(BaseModel):
actions: List[SlackActionRequest]
user: Optional[SlackUserID]
message: Optional[Dict[str, Any]]


class ActionRequestReceiver:
Expand Down Expand Up @@ -144,6 +157,13 @@ def __exec_external_request(self, action_request: ExternalActionRequest, validat
)
return

# add global slack values to callback
if hasattr(action_request, 'slack_username'):
action_request.body.action_params["slack_username"] = action_request.slack_username

if hasattr(action_request, 'slack_message'):
action_request.body.action_params["slack_message"] = action_request.slack_message

response = self.event_handler.run_external_action(
action_request.body.action_name,
action_request.body.action_params,
Expand Down Expand Up @@ -182,10 +202,18 @@ def _parse_websocket_message(
message: Union[str, bytes, bytearray]
) -> Union[SlackActionsMessage, ExternalActionRequest]:
try:
return SlackActionsMessage.parse_raw(message) # this is slack callback format
return ActionRequestReceiver._parse_slack_message(message) # this is slack callback format
except ValidationError:
return ExternalActionRequest.parse_raw(message)

@staticmethod
def _parse_slack_message(message: Union[str, bytes, bytearray]) -> SlackActionsMessage:
slack_actions_message = SlackActionsMessage.parse_raw(message) # this is slack callback format
for action in slack_actions_message.actions:
action.value.slack_username = slack_actions_message.user.username
action.value.slack_message = json.loads(message)
Avi-Robusta marked this conversation as resolved.
Show resolved Hide resolved
return slack_actions_message

def on_message(self, ws: websocket.WebSocketApp, message: str) -> None:
"""Callback for incoming websocket message from relay.

Expand Down
32 changes: 31 additions & 1 deletion src/robusta/integrations/slack/sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
from robusta.core.model.base_params import AIInvestigateParams, ResourceInfo
from robusta.core.model.env_vars import (
ADDITIONAL_CERTIFICATE,
SLACK_REQUEST_TIMEOUT,
HOLMES_ENABLED,
SLACK_REQUEST_TIMEOUT,
SLACK_TABLE_COLUMNS_LIMIT,
)
from robusta.core.playbooks.internal.ai_integration import ask_holmes
Expand Down Expand Up @@ -695,3 +695,33 @@ def send_or_update_summary_message(
return resp["ts"]
except Exception as e:
logging.exception(f"error sending message to slack\n{e}\nchannel={channel}\n")

def update_slack_message(self, channel: str, ts: str, blocks: list, text: str = ""):
"""
Update a Slack message with new blocks and optional text.
Args:
channel (str): Slack channel ID.
ts (str): Timestamp of the message to update.
blocks (list): List of Slack Block Kit blocks for the updated message.
text (str, optional): Plain text summary for accessibility. Defaults to "".
"""
try:
# Ensure channel ID exists in the mapping
if channel not in self.channel_name_to_id.values():
logging.error(f"Channel ID for {channel} could not be determined. Update aborted.")
return

# Call Slack's chat_update method
resp = self.slack_client.chat_update(
channel=channel,
ts=ts,
text=text,
blocks=blocks
)
logging.debug(f"Message updated successfully: {resp['ts']}")
return resp["ts"]

except Exception as e:
logging.exception(f"Error updating Slack message: {e}")
return None
Loading