diff --git a/docs/configuration/sinks/slack.rst b/docs/configuration/sinks/slack.rst index 67ec37ce8..3cd7c7641 100644 --- a/docs/configuration/sinks/slack.rst +++ b/docs/configuration/sinks/slack.rst @@ -181,3 +181,118 @@ your own. This is not recommended for most companies due to the added complexity When using a custom Slack app, callback buttons are not supported due to complexities in how Slack handles incoming messages. :ref:`Contact us if you need assistance. ` + + +Message Templating +------------------------------------------------------------------- + +Slack messages can be customized using Jinja2 templates. Robusta includes default templates that match the standard format, but you can override them for custom formatting. + +To use custom templates change your `slack_sink` to `slack_sink_preview`, and add your templates to the ``slack_custom_templates`` parameter: + +.. code-block:: yaml + + sinksConfig: + - slack_sink_preview: + api_key: xoxb-198... + name: preview_slack_sink + slack_channel: demo-slack-preview + slack_custom_templates: + custom_template.j2: |- + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Custom Alert Format:\n {{ status_emoji }} [{{ status_text }}] {{ title }}", + "emoji": true + } + } + + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *[{{ status_text }}] {{ title }}*{% if mention %} {{ mention }}{% endif %}" + } + } + + { + "type": "divider" + } + + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Type:* {{ alert_type }}" + }, + { + "type": "mrkdwn", + "text": "*Severity:* {{ severity_emoji }} {{ severity }}" + }, + { + "type": "mrkdwn", + "text": "*Cluster:* {{ cluster_name }}" + } + {% if resource_text %} + , + { + "type": "mrkdwn", + "text": "*Resource:*\\n{{ resource_text }}" + } + {% endif %} + ] + } + + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{% if labels %}*Labels:*\\n\\n{% for key, value in labels.items() %}• *{{ key }}*: {{ value }}\\n\\n{% endfor %}{% else %}*Labels:* _None_{% endif %}" + } + } + +Templates use Slack's Block Kit format and must generate valid JSON. Each template block is separated by double newlines (``\n\n``). + +Available template variables: + ++-----------------------------+-------------------------------------------------------------+ +| Variable | Description | ++=============================+=============================================================+ +| ``title`` | The alert title | ++-----------------------------+-------------------------------------------------------------+ +| ``description`` | The alert description | ++-----------------------------+-------------------------------------------------------------+ +| ``status_text`` | "Firing" or "Resolved" | ++-----------------------------+-------------------------------------------------------------+ +| ``status_emoji`` | "⚠️" (for firing) or "✅" (for resolved) | ++-----------------------------+-------------------------------------------------------------+ +| ``severity`` | Alert severity (e.g., "Warning", "Critical") | ++-----------------------------+-------------------------------------------------------------+ +| ``severity_emoji`` | Emoji for the severity level | ++-----------------------------+-------------------------------------------------------------+ +| ``alert_type`` | "Alert", "K8s Event", or "Notification" | ++-----------------------------+-------------------------------------------------------------+ +| ``cluster_name`` | The name of the cluster | ++-----------------------------+-------------------------------------------------------------+ +| ``investigate_uri`` | URI for investigation | ++-----------------------------+-------------------------------------------------------------+ +| ``resource_text`` | Resource identifier (e.g., "Pod/namespace/name") | ++-----------------------------+-------------------------------------------------------------+ +| ``subject_kind`` | The Kubernetes resource kind (e.g., "Pod", "Deployment") | ++-----------------------------+-------------------------------------------------------------+ +| ``subject_namespace`` | The Kubernetes namespace | ++-----------------------------+-------------------------------------------------------------+ +| ``subject_name`` | The name of the Kubernetes resource | ++-----------------------------+-------------------------------------------------------------+ +| ``resource_emoji`` | Emoji for the resource type | ++-----------------------------+-------------------------------------------------------------+ +| ``mention`` | Any @mentions extracted from the title | ++-----------------------------+-------------------------------------------------------------+ +| ``labels`` | Kubernetes labels on the subject resource (dict) | ++-----------------------------+-------------------------------------------------------------+ +| ``annotations`` | Kubernetes annotations on the subject resource (dict) | ++-----------------------------+-------------------------------------------------------------+ +| ``fingerprint`` | The unique identifier for the alert | ++-----------------------------+-------------------------------------------------------------+ diff --git a/src/robusta/core/model/runner_config.py b/src/robusta/core/model/runner_config.py index 138bb60b2..5edb08b8e 100644 --- a/src/robusta/core/model/runner_config.py +++ b/src/robusta/core/model/runner_config.py @@ -18,6 +18,7 @@ from robusta.core.sinks.rocketchat.rocketchat_sink_params import RocketchatSinkConfigWrapper from robusta.core.sinks.servicenow.servicenow_sink_params import ServiceNowSinkConfigWrapper from robusta.core.sinks.slack.slack_sink_params import SlackSinkConfigWrapper +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper from robusta.core.sinks.mail.mail_sink_params import MailSinkConfigWrapper from robusta.core.sinks.telegram.telegram_sink_params import TelegramSinkConfigWrapper from robusta.core.sinks.victorops.victorops_sink_params import VictoropsConfigWrapper @@ -55,6 +56,7 @@ class RunnerConfig(BaseModel): Union[ RobustaSinkConfigWrapper, SlackSinkConfigWrapper, + SlackSinkPreviewConfigWrapper, DataDogSinkConfigWrapper, KafkaSinkConfigWrapper, MsTeamsSinkConfigWrapper, diff --git a/src/robusta/core/sinks/sink_factory.py b/src/robusta/core/sinks/sink_factory.py index dbbc5829f..96ec8f3d3 100644 --- a/src/robusta/core/sinks/sink_factory.py +++ b/src/robusta/core/sinks/sink_factory.py @@ -22,6 +22,8 @@ from robusta.core.sinks.sink_base import SinkBase from robusta.core.sinks.sink_config import SinkConfigBase from robusta.core.sinks.slack import SlackSink, SlackSinkConfigWrapper +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper +from robusta.core.sinks.slack.preview.slack_sink_preview import SlackSinkPreview from robusta.core.sinks.telegram import TelegramSink, TelegramSinkConfigWrapper from robusta.core.sinks.victorops import VictoropsConfigWrapper, VictoropsSink from robusta.core.sinks.webex import WebexSink, WebexSinkConfigWrapper @@ -35,6 +37,7 @@ class SinkFactory: __sink_config_mapping: Dict[Type[SinkConfigBase], Type[SinkBase]] = { SlackSinkConfigWrapper: SlackSink, + SlackSinkPreviewConfigWrapper: SlackSinkPreview, RocketchatSinkConfigWrapper: RocketchatSink, RobustaSinkConfigWrapper: RobustaSink, MsTeamsSinkConfigWrapper: MsTeamsSink, diff --git a/src/robusta/core/sinks/slack/__init__.py b/src/robusta/core/sinks/slack/__init__.py index 16b32440d..d20bde126 100644 --- a/src/robusta/core/sinks/slack/__init__.py +++ b/src/robusta/core/sinks/slack/__init__.py @@ -1,2 +1,5 @@ +from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams, SlackSinkConfigWrapper from robusta.core.sinks.slack.slack_sink import SlackSink -from robusta.core.sinks.slack.slack_sink_params import SlackSinkConfigWrapper, SlackSinkParams + +# to prevent circular imports in SlackSender, SlackSinkParams and SlackSinkPreviewParams +__all__ = ["SlackSink", "SlackSinkParams", "SlackSinkConfigWrapper"] diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview.py new file mode 100644 index 000000000..f187a31a4 --- /dev/null +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview.py @@ -0,0 +1,10 @@ +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewConfigWrapper, SlackSinkPreviewParams +from robusta.core.sinks.slack.slack_sink import SlackSink + + +class SlackSinkPreview(SlackSink): + params: SlackSinkPreviewParams + + def __init__(self, sink_config: SlackSinkPreviewConfigWrapper, registry): + super().__init__(sink_config, registry, is_preview=True) + diff --git a/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py new file mode 100644 index 000000000..71b17f3ba --- /dev/null +++ b/src/robusta/core/sinks/slack/preview/slack_sink_preview_params.py @@ -0,0 +1,34 @@ +from robusta.core.sinks.sink_base_params import SinkBaseParams +from robusta.core.sinks.sink_config import SinkConfigBase +from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams +from typing import Optional, Dict +from pydantic import validator + + +class SlackSinkPreviewParams(SlackSinkParams): + #TODO: improve the SlackSinkPreviewParams so the slack_custom_templates can be defined once globally and + # only a template name needs to be passed to each channel in the config + slack_custom_templates: Optional[Dict[str, str]] = None # Template name -> custom template content + hide_buttons: bool = False + hide_enrichments: bool = False + + + @validator('slack_custom_templates') + def check_one_item(cls, v): + if v is not None and len(v) != 1: + raise ValueError("slack_custom_templates must contain exactly one key-value pair") + return v + + def get_custom_template(self) -> Optional[str]: + """Get the custom template if defined""" + if not self.slack_custom_templates: + return None + + return next(iter(self.slack_custom_templates.values())) + + +class SlackSinkPreviewConfigWrapper(SinkConfigBase): + slack_sink_preview: SlackSinkPreviewParams + + def get_params(self) -> SinkBaseParams: + return self.slack_sink_preview diff --git a/src/robusta/core/sinks/slack/slack_sink.py b/src/robusta/core/sinks/slack/slack_sink.py index 1f7aa7989..ceca00508 100644 --- a/src/robusta/core/sinks/slack/slack_sink.py +++ b/src/robusta/core/sinks/slack/slack_sink.py @@ -10,12 +10,13 @@ class SlackSink(SinkBase): params: SlackSinkParams - def __init__(self, sink_config: SlackSinkConfigWrapper, registry): - super().__init__(sink_config.slack_sink, registry) - self.slack_channel = sink_config.slack_sink.slack_channel - self.api_key = sink_config.slack_sink.api_key + def __init__(self, sink_config: SlackSinkConfigWrapper, registry, is_preview=False): + slack_sink_params = sink_config.get_params() + super().__init__(slack_sink_params, registry) + self.slack_channel = slack_sink_params.slack_channel + self.api_key = slack_sink_params.api_key self.slack_sender = slack_module.SlackSender( - self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel + self.api_key, self.account_id, self.cluster_name, self.signing_key, self.slack_channel, is_preview ) self.registry.subscribe("replace_callback_with_string", self) diff --git a/src/robusta/core/sinks/slack/templates/header.j2 b/src/robusta/core/sinks/slack/templates/header.j2 new file mode 100644 index 000000000..9b1201ea3 --- /dev/null +++ b/src/robusta/core/sinks/slack/templates/header.j2 @@ -0,0 +1,36 @@ +{# Default template for Slack message headers #} +{# This creates a JIRA-style header with title and context blocks #} + +{# First create the title block with status #} +{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *[{{ status_text }}] {% if platform_enabled and include_investigate_link %}<{{ investigate_uri }}|{{ title }}>{% else %}{{ title }}{% endif %}*{% if mention %} {{ mention }}{% endif %}" + } +} + +{# Then create the context block with metadata #} +{ + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": ":bell: Type: {{ alert_type }}" + }, + { + "type": "mrkdwn", + "text": "{{ severity_emoji }} Severity: {{ severity }}" + }, + { + "type": "mrkdwn", + "text": ":globe_with_meridians: Cluster: {{ cluster_name }}" + } + {% if resource_text %} + ,{ + "type": "mrkdwn", + "text": "{{ resource_emoji }} Resource: {{ resource_text }}" + } + {% endif %} + ] +} \ No newline at end of file diff --git a/src/robusta/core/sinks/slack/templates/template_loader.py b/src/robusta/core/sinks/slack/templates/template_loader.py new file mode 100644 index 000000000..f44af0256 --- /dev/null +++ b/src/robusta/core/sinks/slack/templates/template_loader.py @@ -0,0 +1,95 @@ +import json +import logging +import os +from typing import Any, Dict, List + +from jinja2 import Environment, FileSystemLoader, Template, select_autoescape + +# Get the directory where our templates are stored +TEMPLATE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__))) + + +class SlackTemplateLoader: + """ + Loads and renders Jinja2 templates for Slack messages. + """ + + def __init__(self): + """Initialize the template environment.""" + self.env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + autoescape=select_autoescape(["html", "xml"]), + trim_blocks=True, + lstrip_blocks=True, + ) + # Cache for templates + self._templates: Dict[str, Template] = {} + + def get_template(self, template_name: str) -> Template: + """ + Get a template by name, loading from file if not already cached. + + Args: + template_name: The name of the template file (e.g., "header.j2") + + Returns: + A Jinja2 Template object + """ + if template_name not in self._templates: + try: + self._templates[template_name] = self.env.get_template(template_name) + except Exception as e: + logging.error(f"Error loading template {template_name}: {e}") + # Return a simple default template as fallback + return Template("Template loading error") + + return self._templates[template_name] + + def render_to_blocks(self, template: Template, context: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Render a Jinja2 Template object using the provided context and parse the result as JSON to get Slack blocks. + Args: + template: A Jinja2 Template object + context: Dictionary of variables to pass to the template + Returns: + List of Slack block objects (dictionaries) + """ + try: + rendered = template.render(**context) + blocks = [] + for block_str in rendered.strip().split("\n\n"): + if not block_str.strip(): + continue + try: + # Try to parse as JSON, but if it fails, log and skip + block = json.loads(block_str) + blocks.append(block) + except json.JSONDecodeError as e: + logging.exception(f"Error parsing JSON from template output: {e}") + logging.warning(f"Problematic JSON (repr): {repr(block_str)}") + continue # Skip this block and continue + except Exception as e: + logging.error(f"Unexpected error parsing block: {e}") + logging.warning(f"Problematic JSON (repr): {repr(block_str)}") + continue + return blocks + except Exception as e: + logging.error(f"Error rendering template: {e}") + return [] + + def render_custom_template_to_blocks(self, custom_template: str, context: Dict[str, Any]) -> List[Dict[str, Any]]: + try: + template = Template(custom_template) + return self.render_to_blocks(template, context) + except Exception as e: + logging.error(f"Error rendering custom template: {e}") + return self.render_default_template_to_blocks(context) + + def render_default_template_to_blocks(self, context: Dict[str, Any]) -> List[Dict[str, Any]]: + DEFAULT_TEMPLATE_NAME="header.j2" + template = self.get_template(DEFAULT_TEMPLATE_NAME) + return self.render_to_blocks(template, context) + + +# Singleton instance +template_loader = SlackTemplateLoader() diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index f8a5d088e..a9854b4a9 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -5,14 +5,14 @@ import re from datetime import datetime, timedelta from itertools import chain -from typing import Any, Dict, List, Optional, Set - +from typing import Any, Dict, List, Optional, Set, Union import certifi import humanize from dateutil import tz from slack_sdk import WebClient from slack_sdk.errors import SlackApiError from slack_sdk.http_retry import all_builtin_retry_handlers +from robusta.core.sinks.slack.templates.template_loader import template_loader from robusta.core.model.base_params import AIInvestigateParams, ResourceInfo from robusta.core.model.env_vars import ( @@ -47,6 +47,8 @@ from robusta.core.sinks.common import ChannelTransformer from robusta.core.sinks.sink_base import KeyT from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams + from robusta.core.sinks.transformer import Transformer ACTION_TRIGGER_PLAYBOOK = "trigger_playbook" @@ -60,7 +62,7 @@ class SlackSender: verified_api_tokens: Set[str] = set() channel_name_to_id = {} - def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing_key: str, slack_channel: str): + def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing_key: str, slack_channel: str, is_preview: bool = False): """ Connect to Slack and verify that the Slack token is valid. Return True on success, False on failure @@ -81,6 +83,7 @@ def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing self.signing_key = signing_key self.account_id = account_id self.cluster_name = cluster_name + self.is_preview = is_preview if slack_token not in self.verified_api_tokens: try: @@ -90,6 +93,13 @@ def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing logging.error(f"Cannot connect to Slack API: {e}") raise e + def __slack_preview_sanitize_string(self, text: str) -> str: + """ + Properly sanitize a string for JSON by escaping newlines. + First unescapes any already escaped newlines, then escapes all newlines. + """ + return text.replace("\\n", "\n").replace("\n", "\\n") + def __get_action_block_for_choices(self, sink: str, choices: Dict[str, CallbackChoice] = None): if choices is None: return [] @@ -300,6 +310,7 @@ def __send_blocks_to_slack( status: FindingStatus, channel: str, thread_ts: str = None, + output_blocks: List[SlackBlock] = [], ) -> str: file_blocks = add_pngs_for_all_svgs([b for b in report_blocks if isinstance(b, FileBlock)]) if not sink_params.send_svg: @@ -317,7 +328,6 @@ def __send_blocks_to_slack( if error_msg: other_blocks.append(MarkdownBlock(error_msg)) - output_blocks = [] for block in other_blocks: output_blocks.extend(self.__to_slack(block, sink_params.name)) attachment_blocks = [] @@ -420,6 +430,92 @@ def extract_mentions(title) -> (str, str): return title, mention + def __create_finding_header_preview( + self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool, + sink_params: SlackSinkPreviewParams = None + ) -> List[SlackBlock]: + title = finding.title.removeprefix("[RESOLVED] ") if finding.title else "" + + title, mention = self.extract_mentions(title) + + sev = finding.severity + + # Prepare data for template + status_text = "Firing" if status == FindingStatus.FIRING else "Resolved" + status_emoji = "⚠️" if status == FindingStatus.FIRING else "✅" + investigate_uri = finding.get_investigate_uri(self.account_id, + self.cluster_name) if platform_enabled else "" + + # Get alert type information + if finding.source == FindingSource.PROMETHEUS: + alert_type = "Alert" + elif finding.source == FindingSource.KUBERNETES_API_SERVER: + alert_type = "K8s Event" + else: + alert_type = "Notification" + + resource_emoji = ":package:" + + subject_kind = "" + subject_namespace = "" + subject_name = "" + resource_id = "" + if finding.subject: + subject_kind = finding.subject.subject_type.value + subject_namespace = finding.subject.namespace + subject_name = finding.subject.name + + if subject_kind and subject_name: + # Choose emoji based on kind + if subject_kind.lower() == "pod": + resource_emoji = ":ship:" + elif subject_kind.lower() == "deployment": + resource_emoji = ":package:" + elif subject_kind.lower() == "node": + resource_emoji = ":computer:" + elif subject_kind.lower() == "service": + resource_emoji = ":link:" + elif subject_kind.lower() == "job": + resource_emoji = ":clock1:" + elif subject_kind.lower() == "statefulset": + resource_emoji = ":chains:" + + # Format as Kind/Namespace/Name + if subject_namespace: + resource_id = f"{subject_kind}/{subject_namespace}/{subject_name}" + else: + resource_id = f"{subject_kind}/{subject_name}" + description = finding.description or "" + # Prepare template context + template_context = { + "title": self.__slack_preview_sanitize_string(title), + "description": self.__slack_preview_sanitize_string(description), + "status_text": status_text, + "status_emoji": status_emoji, + "severity": sev.name.capitalize(), + "severity_emoji": sev.to_emoji(), + "alert_type": alert_type, + "cluster_name": self.cluster_name, + "platform_enabled": platform_enabled, + "include_investigate_link": include_investigate_link, + "investigate_uri": investigate_uri if investigate_uri else "", + "resource_text": resource_id, + "subject_kind": subject_kind, + "subject_namespace": subject_namespace, + "subject_name": subject_name, + "resource_emoji": resource_emoji, + "mention": mention, + "aggregation_key": finding.aggregation_key, + "labels": finding.subject.labels if finding.subject else {}, + "annotations": finding.subject.annotations if finding.subject else {}, + "fingerprint": finding.fingerprint, + } + + custom_template = sink_params.get_custom_template() if sink_params else None + if custom_template: + return template_loader.render_custom_template_to_blocks(custom_template, template_context) + else: + return template_loader.render_default_template_to_blocks(template_context) def __create_finding_header( self, finding: Finding, status: FindingStatus, platform_enabled: bool, include_investigate_link: bool @@ -570,6 +666,30 @@ def send_holmes_analysis( logging.exception(f"error sending message to slack. {title}") def send_finding_to_slack( + self, + finding: Finding, + sink_params: Union[SlackSinkParams, SlackSinkPreviewParams], + platform_enabled: bool, + thread_ts: str = None, + ) -> str: + if self.is_preview: + try: + return self.__send_finding_to_slack_preview( + finding=finding, + sink_params=sink_params, + platform_enabled=platform_enabled, + thread_ts=thread_ts + ) + except Exception: + logging.exception("Failed to render slack preview template, defaulting to legacy slack output") + return self.__send_finding_to_slack( + finding=finding, + sink_params=sink_params, + platform_enabled=platform_enabled, + thread_ts=thread_ts + ) + + def __send_finding_to_slack( self, finding: Finding, sink_params: SlackSinkParams, @@ -645,6 +765,88 @@ def send_finding_to_slack( thread_ts=thread_ts, ) + def __send_finding_to_slack_preview( + self, + finding: Finding, + sink_params: SlackSinkPreviewParams, + platform_enabled: bool, + thread_ts: str = None, + ) -> str: + blocks: List[BaseBlock] = [] + attachment_blocks: List[BaseBlock] = [] + + slack_channel = ChannelTransformer.template( + sink_params.channel_override, + sink_params.slack_channel, + self.cluster_name, + finding.subject.labels, + finding.subject.annotations, + ) + + if finding.finding_type == FindingType.AI_ANALYSIS: + # holmes analysis message needs special handling + self.send_holmes_analysis(finding, slack_channel, platform_enabled, thread_ts) + return "" # [arik] Looks like the return value here is not used, needs to be removed + + status: FindingStatus = ( + FindingStatus.RESOLVED if finding.title.startswith("[RESOLVED]") else FindingStatus.FIRING + ) + + slack_blocks: List[SlackBlock] = self.__create_finding_header_preview(finding, status, platform_enabled, + sink_params.investigate_link, sink_params) + + if not sink_params.hide_buttons: + links_block: LinksBlock = self.__create_links( + finding, platform_enabled, sink_params.investigate_link, sink_params.prefer_redirect_to_platform + ) + blocks.append(links_block) + + if HOLMES_ENABLED and HOLMES_ASK_SLACK_BUTTON_ENABLED: + blocks.append(self.__create_holmes_callback(finding)) + + if not sink_params.get_custom_template(): + blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) + + if not sink_params.get_custom_template() and finding.description: + if finding.source == FindingSource.PROMETHEUS: + blocks.append(MarkdownBlock(f"{Emojis.Alert.value} *Alert:* {finding.description}")) + elif finding.source == FindingSource.KUBERNETES_API_SERVER: + blocks.append( + MarkdownBlock(f"{Emojis.K8Notification.value} *K8s event detected:* {finding.description}") + ) + else: + blocks.append(MarkdownBlock(f"{Emojis.K8Notification.value} *Notification:* {finding.description}")) + unfurl = True + + if not sink_params.hide_enrichments: + for enrichment in finding.enrichments: + if enrichment.annotations.get(EnrichmentAnnotation.SCAN, False): + enrichment.blocks = [Transformer.scanReportBlock_to_fileblock(b) for b in enrichment.blocks] + + # if one of the enrichment specified unfurl=False, this slack message will contain unfurl=False + unfurl = bool(unfurl and enrichment.annotations.get(SlackAnnotations.UNFURL, True)) + if enrichment.annotations.get(SlackAnnotations.ATTACHMENT): + attachment_blocks.extend(enrichment.blocks) + else: + blocks.extend(enrichment.blocks) + + blocks.append(DividerBlock()) + + if len(attachment_blocks): + attachment_blocks.append(DividerBlock()) + + return self.__send_blocks_to_slack( + blocks, + attachment_blocks, + finding.title, + sink_params, + unfurl, + status, + slack_channel, + thread_ts=thread_ts, + output_blocks=slack_blocks, + ) + def send_or_update_summary_message( self, group_by_classification_header: List[str], @@ -683,7 +885,7 @@ def send_or_update_summary_message( MarkdownBlock(f"*Alerts Summary - {n_total_alerts} Notifications*"), ] - source_txt = f"*Source:* `{self.cluster_name}`" + cluster_txt = f"*Cluster:* `{self.cluster_name}`" if platform_enabled: blocks.extend( [ @@ -691,7 +893,7 @@ def send_or_update_summary_message( "type": "section", "text": { "type": "mrkdwn", - "text": source_txt, + "text": cluster_txt, }, } ] @@ -706,7 +908,7 @@ def send_or_update_summary_message( "url": investigate_uri, } else: - blocks.append(MarkdownBlock(text=source_txt)) + blocks.append(MarkdownBlock(text=cluster_txt)) blocks.extend( [ diff --git a/tests/manual_tests/test_slack_integration_manual.py b/tests/manual_tests/test_slack_integration_manual.py new file mode 100644 index 000000000..d9751338c --- /dev/null +++ b/tests/manual_tests/test_slack_integration_manual.py @@ -0,0 +1,663 @@ +import os +import sys +import logging +from datetime import datetime + +# Add the src directory to the Python path so we can import the modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src")) + +from robusta.integrations.slack.sender import SlackSender +from robusta.core.reporting.base import Finding, FindingSource, FindingSeverity, FindingStatus, FindingSubject, Link, \ + EnrichmentType +from robusta.core.reporting.consts import FindingSubjectType, FindingType +from robusta.core.reporting.base import LinkType +from robusta.core.reporting.blocks import ( + MarkdownBlock, + HeaderBlock, + DividerBlock, + TableBlock, + ListBlock, + LinkProp, + LinksBlock, + CallbackBlock, + CallbackChoice, + FileBlock +) +from robusta.core.model.base_params import ResourceInfo +from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams +from robusta.core.playbooks.internal.ai_integration import ask_holmes + +# Configure logging - set to DEBUG to see full block details +logging.basicConfig( + level=logging.DEBUG if os.environ.get("DEBUG", "").lower() == "true" else logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) + +# You'll need to provide a Slack API token to run this script +# You can get one from https://api.slack.com/apps +# For testing, you can hardcode a token here, but don't commit it to the repo +# By default, try to get token from env var +SLACK_TOKEN = os.environ.get("SLACK_TOKEN", "xoxb-your-actual-token-here") +# The Slack channel to send messages to +SLACK_CHANNEL = os.environ.get("SLACK_CHANNEL", "test-robusta") +# Optional channel override for testing label-based routing +SLACK_OVERRIDE_CHANNEL = os.environ.get("SLACK_OVERRIDE_CHANNEL", "") +# Optional Slack user ID to mention in test messages (e.g., "U1234567890") +SLACK_MENTION = os.environ.get("SLACK_MENTION", "") + + +# In a real scenario, we'd use @action decorator, but for testing +# we'll just remove the CallbackBlock as it requires the full Robusta action system +# We'll use a simple MarkdownBlock with button simulation instead + +# Create mock data for the Finding +def create_test_finding(title: str, severity: FindingSeverity = FindingSeverity.INFO) -> Finding: + """Create a test finding with the given title and severity""" + # Add mention to title if SLACK_MENTION is set + if SLACK_MENTION: + title = f"<@{SLACK_MENTION}> {title}" + + # Create a subject first with the correct type + subject = FindingSubject( + name="test-pod", + namespace="default", + subject_type=FindingSubjectType.TYPE_POD, + labels={ + "app": "test-app", + "environment": "test" + }, + annotations={ + "description": "Test annotation" + } + ) + + finding = Finding( + title=title, + source=FindingSource.PROMETHEUS, + severity=severity, + aggregation_key=f"test-{title}", + finding_type=FindingType.ISSUE, + description="This is a test finding for Slack message formatting", + subject=subject + ) + + # We need to mock the service since TopServiceResolver won't work in test environment + # Create a simple mock for the service object + class MockService: + def __init__(self, name, namespace, resource_type): + self.name = name + self.namespace = namespace + self.resource_type = resource_type + + def get_resource_key(self): + return f"{self.namespace}/{self.name}" + + # Set the service directly + finding.service = MockService( + name="test-deployment", + namespace="default", + resource_type="Deployment" + ) + + # Add links as proper Link objects + + finding.links = [ + Link(url="https://example.com/logs", name="View Logs"), + Link(url="https://example.com/grafana", name="Grafana", type=LinkType.PROMETHEUS_GENERATOR_URL) + ] + + return finding + + +def add_basic_blocks(finding: Finding) -> None: + """Add some basic blocks to the finding""" + finding.add_enrichment([ + HeaderBlock("Kubernetes Resource Information"), + MarkdownBlock("This alert was triggered by high resource usage"), + TableBlock( + rows=[ + ["Namespace", "default"], + ["Pod", "test-pod"], + ["Container", "test-container"], + ["CPU Usage", "250m"], + ["Memory Usage", "512Mi"], + ], + headers=["Property", "Value"], + table_name="Resource Usage", + ), + DividerBlock(), + MarkdownBlock("*Recent Events:*"), + ListBlock([ + "Pod started at 08:15:30", + "CPU usage spiked at 08:45:22", + "Memory usage increased at 08:47:15" + ]), + LinksBlock(links=[ + LinkProp(text="View Pod Details", url="https://example.com/pod"), + LinkProp(text="View Container Logs", url="https://example.com/logs") + ]), + # Instead of using CallbackBlock which requires the @action decorator + MarkdownBlock("*Available Actions:* (Simulated buttons)\n• Restart Pod\n• Scale Deployment") + ]) + + +def add_complex_table(finding: Finding) -> None: + """Add a more complex table to the finding""" + finding.add_enrichment([ + HeaderBlock("Detailed Resource Metrics"), + TableBlock( + rows=[ + ["pod-1", "Running", "0.25", "256Mi", "node-1", "app=web,tier=frontend"], + ["pod-2", "Running", "0.50", "512Mi", "node-1", "app=web,tier=frontend"], + ["pod-3", "CrashLoopBackOff", "0.10", "128Mi", "node-2", "app=db,tier=backend"], + ["pod-4", "Running", "0.75", "1024Mi", "node-2", "app=api,tier=backend"], + ["pod-5", "Pending", "0", "0", "None", "app=cache,tier=backend"], + ], + headers=["Pod", "Status", "CPU", "Memory", "Node", "Labels"], + table_name="Cluster Pod Overview", + ) + ]) + + +def create_holmes_callback(finding: Finding) -> None: + """Add a Holmes AI callback to the finding""" + # Instead of using a real callback, we'll simulate it with markdown + finding.add_enrichment([ + MarkdownBlock("*AI Investigation:*"), + MarkdownBlock("🤖 *Ask Holmes AI* to investigate this alert (simulated button)"), + MarkdownBlock("_Holmes would analyze this alert and provide insights on cause and remediation._") + ]) + + +def create_crash_loop_finding() -> Finding: + """Create a sample crash loop finding with exact requested data""" + subject = FindingSubject( + name="payment-processing-worker-747ccfb9db-r7z7m", + namespace="default", + subject_type=FindingSubjectType.TYPE_POD, + node="gke-1-4d3c8387-d24h", + labels={ + "app": "payment-processing-worker", + "pod-template-hash": "747ccfb9db" + }, + annotations={} + ) + + finding = Finding( + title="Crashing pod payment-processing-worker-747ccfb9db-r7z7m in namespace default", + source=FindingSource.KUBERNETES_API_SERVER, + severity=FindingSeverity.HIGH, + aggregation_key="CrashLoopBackoff", + finding_type=FindingType.ISSUE, + subject=subject, + failure=True, + fingerprint="d7c6e2ac798ee28c8a4ac6c8c33de52e60f0b019cd10d78ac04831d44cf86389", + starts_at=datetime(2025, 3, 23, 20, 8, 43, 822328) + ) + + # Add the crash info enrichment + finding.add_enrichment([ + TableBlock( + rows=[ + ["Container", "payment-processing-container"], + ["Restarts", 130], + ["Status", "WAITING"], + ["Reason", "CrashLoopBackOff"] + ], + headers=["label", "value"], + table_name="*Crash Info*", + metadata={"format": "vertical"} + ), + TableBlock( + rows=[ + ["Status", "TERMINATED"], + ["Reason", "Completed"], + ["Started at", "2025-03-23T18:08:32Z"], + ["Finished at", "2025-03-23T18:08:32Z"] + ], + headers=["label", "value"], + table_name="*Previous Container*", + metadata={"format": "vertical"} + ) + ], title="Container Crash information", enrichment_type=EnrichmentType.crash_info) + + # Add the logs enrichment + finding.add_enrichment([ + FileBlock( + filename="payment-processing-worker-747ccfb9db-r7z7m.log", + contents=b'Environment variable DEPLOY_ENV is undefined\n' + ) + ], title="Logs", enrichment_type=EnrichmentType.text_file) + + return finding + + +def create_node_saturation_finding() -> Finding: + """Create a node saturation finding with exact requested data""" + subject = FindingSubject( + name="gke-1-4d3c8387-c4bp", + subject_type=FindingSubjectType.TYPE_NODE, + namespace="default", + node="gke-1-4d3c8387-c4bp", + container="node-exporter", + labels={ + "beta.kubernetes.io/arch": "amd64", + "beta.kubernetes.io/instance-type": "e2-standard-2", + "beta.kubernetes.io/os": "linux", + "cloud.google.com/gke-boot-disk": "pd-balanced", + "cloud.google.com/gke-container-runtime": "containerd", + "cloud.google.com/gke-cpu-scaling-level": "2", + "cloud.google.com/gke-logging-variant": "DEFAULT", + "cloud.google.com/gke-max-pods-per-node": "110", + "cloud.google.com/gke-memory-gb-scaling-level": "8", + "cloud.google.com/gke-nodepool": "pool-1", + "cloud.google.com/gke-os-distribution": "cos", + "cloud.google.com/gke-provisioning": "spot", + "cloud.google.com/gke-spot": "true", + "cloud.google.com/gke-stack-type": "IPV4", + "cloud.google.com/machine-family": "e2", + "cloud.google.com/private-node": "false", + "failure-domain.beta.kubernetes.io/region": "us-central1", + "failure-domain.beta.kubernetes.io/zone": "us-central1-c", + "kubernetes.io/arch": "amd64", + "kubernetes.io/hostname": "gke-4d3c8387-c4bp" + }, + annotations={} + ) + + finding = Finding( + title="System saturated, load per core is very high.", + source=FindingSource.PROMETHEUS, + severity=FindingSeverity.LOW, + aggregation_key="NodeSystemSaturation", + finding_type=FindingType.ISSUE, + subject=subject, + description="System load per core at 10.128.0.77:9104 has been above 2 for the last 15 minutes, is currently at 5.22.\nThis might indicate this instance resources saturation and can cause it becoming unresponsive.\n", + starts_at=datetime(2025, 3, 23, 20, 9, 4, 838000) + ) + + # Could add enrichments here if needed + + return finding + + +def create_datadog_agent_crash_finding() -> Finding: + """Create a datadog agent crash finding""" + subject = FindingSubject( + name="datadog-agent-w49mv", + subject_type=FindingSubjectType.TYPE_POD, + namespace="default", + node="gke-1-4d3c8387-mm23", + labels={ + "agent.datadoghq.com/component": "agent", + "agent.datadoghq.com/name": "datadog", + "agent.datadoghq.com/provider": "", + "app.kubernetes.io/component": "agent", + "app.kubernetes.io/instance": "datadog-agent", + "app.kubernetes.io/managed-by": "datadog-operator", + "app.kubernetes.io/name": "datadog-agent-deployment", + "app.kubernetes.io/part-of": "default-datadog" + }, + annotations={} + ) + + finding = Finding( + title="Crashing pod datadog-agent-w49mv in namespace default", + source=FindingSource.KUBERNETES_API_SERVER, + severity=FindingSeverity.HIGH, + aggregation_key="CrashLoopBackoff", + finding_type=FindingType.ISSUE, + subject=subject, + failure=True, + starts_at=datetime(2025, 3, 23, 20, 9, 2, 211000) + ) + + # Could add crash info enrichment here if needed + + return finding + + +def create_cpu_throttling_finding() -> Finding: + """Create a CPU throttling finding with exact requested data""" + subject = FindingSubject( + name="vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + subject_type=FindingSubjectType.TYPE_POD, + namespace="default", + node="gke-4ksv", + container="vmagent", + labels={ + "app.kubernetes.io/component": "monitoring", + "app.kubernetes.io/instance": "vmks-victoria-metrics-k8s-stack", + "app.kubernetes.io/name": "vmagent", + "managed-by": "vm-operator", + "pod-template-hash": "6dcd499cb5", + "alertname": "CPUThrottlingHigh", + "container": "vmagent", + "namespace": "default", + "pod": "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + "prometheus": "default/robusta-kube-prometheus-st-prometheus", + "severity": "info" + }, + annotations={ + "description": "71.13% throttling of CPU in namespace default for container vmagent in pod vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm.", + "runbook_url": "https://runbooks.prometheus-operator.dev/runbooks/kubernetes/cputhrottlinghigh", + "summary": "Processes experience elevated CPU throttling." + } + ) + + # Create the finding + finding = Finding( + title="Processes experience elevated CPU throttling.", + source=FindingSource.PROMETHEUS, + severity=FindingSeverity.INFO, + aggregation_key="CPUThrottlingHigh", + finding_type=FindingType.ISSUE, + subject=subject, + description="71.13% throttling of CPU in namespace default for container vmagent in pod vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm.", + failure=True, + fingerprint="9674d957b35e0e9f", + starts_at=datetime(2025, 3, 22, 10, 22, 10, 219000), + add_silence_url=True, + silence_labels={ + "alertname": "CPUThrottlingHigh", + "container": "vmagent", + "namespace": "default", + "pod": "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm", + "prometheus": "default/robusta-kube-prometheus-st-prometheus", + "severity": "info" + } + ) + table_rows = [["Row1_Col1", "Row1_Col2"], ["Row2_Col1", "Row2_Col2"]] + table_block = TableBlock(table_rows, headers=["Header1", "Header2"]) + # Add explanation enrichment + finding.add_enrichment([ + MarkdownBlock( + '📘 *Alert Explanation:* This pod is throttled due to its CPU limit. This can occur even when CPU usage is far below the limit. '), + MarkdownBlock( + "🛠 *Robusta's Recommendation:* Remove this pod's CPU limit entirely. "),table_block + ], annotations={"unfurl": False}) + + # Instead of adding an SVG file which can cause JSON serialization issues, + # Let's add a simpler enrichment with just a table for CPU usage + finding.add_enrichment([ + TableBlock( + rows=[ + ["Current CPU Usage", "19.5m"], + ["CPU Request", "100m"], + ["CPU Limit", "300m"], + ["Throttling Percentage", "71.13%"], + ["Duration", "Last 5 minutes"] + ], + headers=["Metric", "Value"], + table_name="*CPU Usage*" + ) + ], title="Resources", enrichment_type=EnrichmentType.graph) + + # We're not including the SVG graph anymore to avoid JSON serialization issues + + # Add alert labels enrichment + finding.add_enrichment([ + TableBlock( + rows=[ + ["alertname", "CPUThrottlingHigh"], + ["container", "vmagent"], + ["namespace", "default"], + ["pod", "vmagent-vmks-victoria-metrics-k8s-stack-6dcd499cb5-tdlkm"], + ["prometheus", "default/robusta-kube-prometheus-st-prometheus"], + ["severity", "info"] + ], + headers=["label", "value"], + table_name="*Alert labels*", + metadata={"format": "vertical"} + ) + ], title="Alert labels", enrichment_type=EnrichmentType.alert_labels, annotations={"attachment": True}) + + # Add Prometheus link + finding.add_link( + Link( + url="http://robusta-kube-prometheus-st-prometheus.default:9090/graph?g0.expr=sum+by+%28cluster%2C+container%2C+pod%2C+namespace%29+%28increase%28container_cpu_cfs_throttled_periods_total%7Bcontainer%21%3D%22%22%7D%5B5m%5D%29%29+%2F+sum+by+%28cluster%2C+container%2C+pod%2C+namespace%29+%28increase%28container_cpu_cfs_periods_total%5B5m%5D%29%29+%3E+%2825+%2F+100%29&g0.tab=1", + name="View Graph", + type=LinkType.PROMETHEUS_GENERATOR_URL + ) + ) + + return finding + + +def main(): + if not SLACK_TOKEN: + logging.error("SLACK_TOKEN environment variable not set. Please set it to proceed.") + sys.exit(1) + + # Enable detailed block logging if DEBUG=true + if os.environ.get("DEBUG", "").lower() == "true": + logging.info("DEBUG mode enabled - will print detailed block information") + logging.info("Run script with DEBUG=false to disable detailed logging") + + # Initialize the standard SlackSender + standard_sender = SlackSender( + slack_token=SLACK_TOKEN, + account_id="test-account", + cluster_name="test-cluster", + signing_key="test-signing-key", + slack_channel=SLACK_CHANNEL, + is_preview=False + ) + + # Initialize the preview SlackSender + preview_sender = SlackSender( + slack_token=SLACK_TOKEN, + account_id="test-account", + cluster_name="test-cluster", + signing_key="test-signing-key", + slack_channel=SLACK_CHANNEL, + is_preview=True + ) + + # Create a test finding + finding = create_test_finding("Test Alert", FindingSeverity.HIGH) + + # Add markdown blocks with various content + finding.add_enrichment([ + MarkdownBlock("## Alert Details"), + MarkdownBlock("This is a test alert with *bold* and _italic_ text"), + MarkdownBlock("`Code snippet: kubectl get pods -n default`"), + MarkdownBlock("> Important note: This is a test alert"), + MarkdownBlock("• Bullet point 1\n• Bullet point 2\n• Bullet point 3"), + DividerBlock(), + MarkdownBlock("### Resource Information"), + MarkdownBlock("The following resources are affected by this alert:"), + ]) + + # Add a table with labels + finding.add_enrichment([ + TableBlock( + rows=[ + ["app", "test-app"], + ["environment", "test"], + ["severity", "high"], + ["namespace", "default"], + ["pod", "test-pod"], + ["container", "test-container"], + ["node", "test-node"], + ["cluster", "test-cluster"] + ], + headers=["Label", "Value"], + table_name="Resource Labels", + metadata={"format": "vertical"} + ) + ]) + + # Add file blocks with sample text + finding.add_enrichment([ + FileBlock( + filename="pod-logs.txt", + contents=b"""2024-03-23 10:15:23 INFO Starting application +2024-03-23 10:15:24 INFO Loading configuration +2024-03-23 10:15:25 ERROR Failed to connect to database +2024-03-23 10:15:26 WARN Retrying connection... +2024-03-23 10:15:27 ERROR Connection timeout""" + ), + FileBlock( + filename="config.yaml", + contents=b"""apiVersion: v1 +kind: Pod +metadata: + name: test-pod + namespace: default +spec: + containers: + - name: test-container + image: nginx:latest + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" +""" + ) + ]) + + # Test 1: Standard Slack Sink with default template + logging.info("Test 1: Standard Slack Sink with default template") + standard_params = SlackSinkParams( + name="standard-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000 + ) + standard_sender.send_finding_to_slack(finding, standard_params, platform_enabled=True) + + # Test 2: Slack Preview Sink with default template + logging.info("Test 2: Slack Preview Sink with default template") + preview_params = SlackSinkPreviewParams( + name="preview-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000 + ) + preview_sender.send_finding_to_slack(finding, preview_params, platform_enabled=True) + + # Test 3: Slack Preview Sink with custom template + logging.info("Test 3: Slack Preview Sink with custom template") + custom_template = """{ + "type": "header", + "text": { + "type": "plain_text", + "text": "Custom Alert Format", + "emoji": true + } + } + + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{ status_emoji }} *[{{ status_text }}] {{ title }}*{% if mention %} {{ mention }}{% endif %}" + } + } + + { + "type": "divider" + } + + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Type:* {{ alert_type }}" + }, + { + "type": "mrkdwn", + "text": "*Severity:* {{ severity_emoji }} {{ severity }}" + }, + { + "type": "mrkdwn", + "text": "*Cluster:* {{ cluster_name }}" + } + {% if resource_text %} + , + { + "type": "mrkdwn", + "text": "*Resource:*\\n{{ resource_text }}" + } + {% endif %} + ] + } + + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{% if labels %}*Custom Labels Format:*\\n\\n{% for key, value in labels.items() %}• *{{ key }}*: {{ value }}\\n\\n{% endfor %}{% else %}*Labels:* _None_{% endif %}" + } + }""" + custom_params = SlackSinkPreviewParams( + name="custom-sink", + slack_channel=SLACK_CHANNEL, + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + slack_custom_templates={"custom.j2": custom_template}, + hide_enrichments=True, + hide_buttons=True, + ) + preview_sender.send_finding_to_slack(finding, custom_params, platform_enabled=True) + + # Test 4: Channel Override Test (only if SLACK_OVERRIDE_CHANNEL is set) + if SLACK_OVERRIDE_CHANNEL: + logging.info(f"Test 4: Channel Override Test using label-based routing to {SLACK_OVERRIDE_CHANNEL}") + + # Create a new finding with a specific label for channel override + override_finding = create_test_finding("Channel Override Test Alert", FindingSeverity.HIGH) + + # Add the slack label to the subject's labels + override_finding.subject.labels["slack"] = SLACK_OVERRIDE_CHANNEL + + # Add some specific content for the override test + override_finding.add_enrichment([ + MarkdownBlock("## Channel Override Test"), + MarkdownBlock("This alert is being routed using label-based channel override"), + TableBlock( + rows=[ + ["Default Channel", SLACK_CHANNEL], + ["Override Channel", SLACK_OVERRIDE_CHANNEL], + ["Override Method", "Label-based (labels.slack)"], + ["Test Type", "Channel Override"], + ["Timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S")] + ], + headers=["Property", "Value"], + table_name="Override Information", + metadata={"format": "vertical"} + ) + ]) + + # Create override params with channel_override set to use the slack label + override_params = SlackSinkPreviewParams( + name="override-sink", + slack_channel=SLACK_CHANNEL, # This will be the fallback channel + api_key=SLACK_TOKEN, + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + channel_override="labels.slack" # This will read from the slack label + ) + + # Send using the preview sender (it will use the override channel from the label) + preview_sender.send_finding_to_slack(override_finding, override_params, platform_enabled=True) + logging.info(f"Channel override test message sent to {SLACK_OVERRIDE_CHANNEL}") + + logging.info(f"All test messages sent successfully to Slack channel: {SLACK_CHANNEL}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tests/test_slack_preview.py b/tests/test_slack_preview.py new file mode 100755 index 000000000..9a2ce3da1 --- /dev/null +++ b/tests/test_slack_preview.py @@ -0,0 +1,143 @@ +from robusta.core.reporting.base import Finding, FindingSeverity, FindingSource, FindingSubject, FindingSubjectType +from robusta.core.sinks.slack.preview.slack_sink_preview_params import SlackSinkPreviewParams +from robusta.core.reporting.blocks import MarkdownBlock +from robusta.integrations.slack.sender import SlackSender +from tests.config import CONFIG +from tests.utils.slack_utils import SlackChannel + +TEST_ACCOUNT = "test account" +TEST_CLUSTER = "test cluster" +TEST_KEY = "test key" + +def extract_text_from_blocks(message): + """Extract all text content from Slack message blocks and attachments""" + text_parts = [] + + # Extract text from main blocks + if 'blocks' in message: + for block in message['blocks']: + if block.get('type') == 'section' and 'text' in block: + text_parts.append(block['text'].get('text', '')) + + # Extract text from attachments + if 'attachments' in message: + for attachment in message['attachments']: + if 'blocks' in attachment: + for block in attachment['blocks']: + if block.get('type') == 'section' and 'text' in block: + text_parts.append(block['text'].get('text', '')) + + return ' '.join(text_parts) + +def test_slack_preview_default_template(slack_channel: SlackChannel): + slack_sender = SlackSender( + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, is_preview=True + ) + + # Create a subject for the finding + subject = FindingSubject( + name="test-pod", + namespace="default", + subject_type=FindingSubjectType.from_kind("pod"), + labels={ + "app": "test-app", + "environment": "test" + } + ) + + finding = Finding( + title="Test Preview Template", + aggregation_key="test-preview-template", + severity=FindingSeverity.INFO, + source=FindingSource.PROMETHEUS, + description="Testing preview template rendering", + subject=subject + ) + + # Add enrichments that will be included in the preview + finding.add_enrichment([ + MarkdownBlock("This is a test preview block."), + MarkdownBlock("*Additional Information:*"), + MarkdownBlock("• Test point 1\n• Test point 2") + ]) + + preview_params = SlackSinkPreviewParams( + name="test_preview", + slack_channel=slack_channel.channel_name, + api_key="", + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000 + ) + + slack_sender.send_finding_to_slack(finding, preview_params, platform_enabled=True) + latest_message = slack_channel.get_complete_latest_message() + message_text = extract_text_from_blocks(latest_message) + + # Check for key elements in the preview message + assert "Test Preview Template" in message_text + assert "This is a test preview block." in message_text + assert "Additional Information" in message_text + assert "Test point 1" in message_text + assert "Test point 2" in message_text + +def test_slack_preview_custom_template(slack_channel: SlackChannel): + slack_sender = SlackSender( + CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name, is_preview=True + ) + + subject = FindingSubject( + name="test-pod", + namespace="default", + subject_type=FindingSubjectType.from_kind("pod"), + labels={ + "app": "test-app", + "environment": "test" + } + ) + + finding = Finding( + title="Test Custom Preview Template", + aggregation_key="test-custom-preview-template", + severity=FindingSeverity.INFO, + source=FindingSource.PROMETHEUS, + description="Testing custom preview template rendering", + subject=subject + ) + + finding.add_enrichment([ + MarkdownBlock("This is a test custom preview block."), + MarkdownBlock("*Custom Content:*"), + MarkdownBlock("• Custom point 1\n• Custom point 2") + ]) + + # Custom template that includes both title and content + custom_template = """{ + "type": "section", + "text": { + "type": "mrkdwn", + "text": "CUSTOM PREVIEW: {{ title }}\\n\\n{{ description }}" + } + }""" + + preview_params = SlackSinkPreviewParams( + name="test_custom_preview", + slack_channel=slack_channel.channel_name, + api_key="", + investigate_link=True, + prefer_redirect_to_platform=False, + max_log_file_limit_kb=1000, + slack_custom_templates={"custom.j2": custom_template} + ) + + slack_sender.send_finding_to_slack(finding, preview_params, platform_enabled=True) + latest_message = slack_channel.get_complete_latest_message() + message_text = extract_text_from_blocks(latest_message) + + # Check for custom template elements + assert "CUSTOM PREVIEW: Test Custom Preview Template" in message_text + assert "Testing custom preview template rendering" in message_text + assert "This is a test custom preview block." in message_text + assert "Custom Content" in message_text + assert "Custom point 1" in message_text + assert "Custom point 2" in message_text diff --git a/tests/utils/slack_utils.py b/tests/utils/slack_utils.py index b28979a16..4890eeffb 100644 --- a/tests/utils/slack_utils.py +++ b/tests/utils/slack_utils.py @@ -22,6 +22,11 @@ def get_latest_message(self): messages = results["messages"] return messages[0]["text"] + def get_complete_latest_message(self): + results = self.client.conversations_history(channel=self.channel_id) + messages = results["messages"] + return messages[0] + @staticmethod def _create_or_join_channel(client: WebClient, channel_name: str) -> str: """Creates or joins the specified slack channel and returns its channel_id"""