Skip to content

Commit 2a4cabd

Browse files
authored
Merge branch 'master' into rob-1126_headers_for_centralized_prometheus
2 parents ecfe154 + 497c446 commit 2a4cabd

4 files changed

Lines changed: 161 additions & 26 deletions

File tree

.github/workflows/test_robusta.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
- name: Set up Python
2020
uses: actions/setup-python@v2
2121
with:
22-
python-version: 3.9
22+
python-version: 3.11
2323

2424
# setup a KIND cluster for tests which need a kubernetes image
2525
- name: Create k8s Kind Cluster

src/robusta/integrations/slack/sender.py

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,22 @@
44
import tempfile
55
from datetime import datetime, timedelta
66
from itertools import chain
7-
from typing import Any, Dict, List, Set
7+
from typing import Any, Dict, List, Optional, Set
88

99
import certifi
1010
import humanize
1111
from dateutil import tz
1212
from slack_sdk import WebClient
13-
from slack_sdk.http_retry import all_builtin_retry_handlers
1413
from slack_sdk.errors import SlackApiError
14+
from slack_sdk.http_retry import all_builtin_retry_handlers
1515

1616
from robusta.core.model.base_params import AIInvestigateParams, ResourceInfo
1717
from robusta.core.model.env_vars import (
1818
ADDITIONAL_CERTIFICATE,
19+
HOLMES_ASK_SLACK_BUTTON_ENABLED,
1920
HOLMES_ENABLED,
2021
SLACK_REQUEST_TIMEOUT,
21-
SLACK_TABLE_COLUMNS_LIMIT, HOLMES_ASK_SLACK_BUTTON_ENABLED,
22+
SLACK_TABLE_COLUMNS_LIMIT,
2223
)
2324
from robusta.core.playbooks.internal.ai_integration import ask_holmes
2425
from robusta.core.reporting.base import Emojis, EnrichmentType, Finding, FindingStatus, LinkType
@@ -69,7 +70,12 @@ def __init__(self, slack_token: str, account_id: str, cluster_name: str, signing
6970
except Exception as e:
7071
logging.exception(f"Failed to use custom certificate. {e}")
7172

72-
self.slack_client = WebClient(token=slack_token, ssl=ssl_context, timeout=SLACK_REQUEST_TIMEOUT, retry_handlers=all_builtin_retry_handlers())
73+
self.slack_client = WebClient(
74+
token=slack_token,
75+
ssl=ssl_context,
76+
timeout=SLACK_REQUEST_TIMEOUT,
77+
retry_handlers=all_builtin_retry_handlers(),
78+
)
7379
self.signing_key = signing_key
7480
self.account_id = account_id
7581
self.cluster_name = cluster_name
@@ -213,37 +219,74 @@ def __to_slack(self, block: BaseBlock, sink_name: str) -> List[SlackBlock]:
213219
logging.warning(f"cannot convert block of type {type(block)} to slack format block: {block}")
214220
return [] # no reason to crash the entire report
215221

216-
def __upload_file_to_slack(self, block: FileBlock, max_log_file_limit_kb: int) -> str:
222+
def _upload_temp_file(self, f, file_reference, truncated_content: bytes, filename: str) -> Optional[str]:
223+
"""Helper to upload a file-like or file path to Slack."""
224+
f.write(truncated_content)
225+
f.flush()
226+
f.seek(0)
227+
228+
result = self.slack_client.files_upload_v2(
229+
title=filename,
230+
file_uploads=[{"file": file_reference, "filename": filename, "title": filename}],
231+
)
232+
return result["file"]["permalink"]
233+
234+
def __upload_file_to_slack(self, block: FileBlock, max_log_file_limit_kb: int) -> Optional[str]:
235+
"""Upload a file to Slack and return a permalink to it."""
217236
truncated_content = block.truncate_content(max_file_size_bytes=max_log_file_limit_kb * 1000)
237+
filename = block.filename
218238

219-
"""Upload a file to slack and return a link to it"""
220-
with tempfile.NamedTemporaryFile() as f:
221-
f.write(truncated_content)
222-
f.flush()
223-
result = self.slack_client.files_upload_v2(title=block.filename, file=f.name, filename=block.filename)
224-
return result["file"]["permalink"]
239+
try:
240+
with tempfile.NamedTemporaryFile() as f:
241+
logging.debug("Trying NamedTemporaryFile for Slack upload")
242+
return self._upload_temp_file(f, f.name, truncated_content, filename)
243+
except Exception as e:
244+
logging.debug(f"NamedTemporaryFile failed: {e}")
245+
try:
246+
SPOOLED_FILE_SIZE = 10 * 1000 * 1000 # 10MB to protect against OOM
247+
with tempfile.SpooledTemporaryFile(max_size=SPOOLED_FILE_SIZE) as f:
248+
logging.debug("Trying SpooledTemporaryFile for Slack upload")
249+
return self._upload_temp_file(f, f, truncated_content, filename)
250+
except Exception as e2:
251+
logging.exception(f"SpooledTemporaryFile also failed: {e2}")
252+
return None
225253

226254
def prepare_slack_text(self, message: str, max_log_file_limit_kb: int, files: List[FileBlock] = []):
255+
error_files = []
256+
227257
if files:
228258
# it's a little annoying but it seems like files need to be referenced in `title` and not just `blocks`
229259
# in order to be actually shared. well, I'm actually not sure about that, but when I tried adding the files
230260
# to a separate block and not including them in `title` or the first block then the link was present but
231261
# the file wasn't actually shared and the link was broken
232262
uploaded_files = []
263+
233264
for file_block in files:
234265
# slack throws an error if you write empty files, so skip it
235266
if len(file_block.contents) == 0:
236267
continue
237268
permalink = self.__upload_file_to_slack(file_block, max_log_file_limit_kb=max_log_file_limit_kb)
238-
uploaded_files.append(f"* <{permalink} | {file_block.filename}>")
269+
if permalink:
270+
uploaded_files.append(f"* <{permalink} | {file_block.filename}>")
271+
else:
272+
error_files.append(file_block.filename)
239273

240274
file_references = "\n".join(uploaded_files)
241275
message = f"{message}\n{file_references}"
242276

243277
if len(message) == 0:
244278
return "empty-message" # blank messages aren't allowed
279+
message = Transformer.apply_length_limit(message, MAX_BLOCK_CHARS)
245280

246-
return Transformer.apply_length_limit(message, MAX_BLOCK_CHARS)
281+
if error_files:
282+
error_msg = (
283+
"_Failed to send file(s) "
284+
+ ", ".join(error_files)
285+
+ " to slack._\n _See robusta-runner logs for details._"
286+
)
287+
return message, error_msg
288+
289+
return message, None
247290

248291
def __send_blocks_to_slack(
249292
self,
@@ -266,9 +309,12 @@ def __send_blocks_to_slack(
266309
file_blocks.extend(Transformer.tableblock_to_fileblocks(other_blocks, SLACK_TABLE_COLUMNS_LIMIT))
267310
file_blocks.extend(Transformer.tableblock_to_fileblocks(report_attachment_blocks, SLACK_TABLE_COLUMNS_LIMIT))
268311

269-
message = self.prepare_slack_text(
312+
message, error_msg = self.prepare_slack_text(
270313
title, max_log_file_limit_kb=sink_params.max_log_file_limit_kb, files=file_blocks
271314
)
315+
if error_msg:
316+
other_blocks.append(MarkdownBlock(error_msg))
317+
272318
output_blocks = []
273319
for block in other_blocks:
274320
output_blocks.extend(self.__to_slack(block, sink_params.name))
@@ -310,15 +356,14 @@ def __send_blocks_to_slack(
310356
f"error sending message to slack\ne={e}\ntext={message}\nchannel={channel}\nblocks={*output_blocks,}\nattachment_blocks={*attachment_blocks,}"
311357
)
312358

313-
314359
def __limit_labels_size(self, labels: dict, max_size: int = 1000) -> dict:
315360
# slack can only send 2k tokens in a callback so the labels are limited in size
316361

317362
low_priority_labels = ["job", "prometheus", "severity", "service"]
318363
current_length = len(str(labels))
319364
if current_length <= max_size:
320365
return labels
321-
366+
322367
limited_labels = copy.deepcopy(labels)
323368

324369
# first remove the low priority labels if needed
@@ -348,7 +393,7 @@ def __create_holmes_callback(self, finding: Finding) -> CallbackBlock:
348393
"robusta_issue_id": str(finding.id),
349394
"issue_type": finding.aggregation_key,
350395
"source": finding.source.name,
351-
"labels": self.__limit_labels_size(labels=finding.subject.labels)
396+
"labels": self.__limit_labels_size(labels=finding.subject.labels),
352397
}
353398

354399
return CallbackBlock(
@@ -714,12 +759,7 @@ def update_slack_message(self, channel: str, ts: str, blocks: list, text: str =
714759
return
715760

716761
# Call Slack's chat_update method
717-
resp = self.slack_client.chat_update(
718-
channel=channel,
719-
ts=ts,
720-
text=text,
721-
blocks=blocks
722-
)
762+
resp = self.slack_client.chat_update(channel=channel, ts=ts, text=text, blocks=blocks)
723763
logging.debug(f"Message updated successfully: {resp['ts']}")
724764
return resp["ts"]
725765

tests/test_slack.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from robusta.api import Finding, MarkdownBlock, SlackSender, TableBlock
1+
import logging
2+
from unittest.mock import patch
3+
4+
from robusta.api import FileBlock, Finding, MarkdownBlock, SlackSender, TableBlock
25
from robusta.core.sinks.slack.slack_sink_params import SlackSinkParams
36
from tests.config import CONFIG
47
from tests.utils.slack_utils import SlackChannel
@@ -7,6 +10,10 @@
710
TEST_CLUSTER = "test cluster"
811
TEST_KEY = "test key"
912

13+
TEST_FILE_NAME = "test.txt"
14+
TEST_FILE_CONTENT = "test file content"
15+
TEST_FINDING_TITLE = "Test Text File Upload"
16+
1017

1118
def test_send_to_slack(slack_channel: SlackChannel):
1219
slack_sender = SlackSender(
@@ -48,3 +55,84 @@ def test_long_table_columns(slack_channel: SlackChannel):
4855
)
4956
slack_params = SlackSinkParams(name="test_slack", slack_channel=slack_channel.channel_name, api_key="")
5057
slack_sender.send_finding_to_slack(finding, slack_params, False)
58+
59+
60+
def test_send_file_spooled_tempfile_fails(slack_channel: SlackChannel):
61+
slack_sender = SlackSender(
62+
CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name
63+
)
64+
65+
# Test with a text file
66+
finding = Finding(title=TEST_FINDING_TITLE, aggregation_key="TestTextFileUpload")
67+
finding.add_enrichment([FileBlock(TEST_FILE_NAME, TEST_FILE_CONTENT)])
68+
69+
slack_params = SlackSinkParams(name="test_slack", slack_channel=slack_channel.channel_name, api_key="")
70+
71+
# verify NamedTemporaryFile sending works
72+
# verify SpooledTemporaryFile sending works
73+
with patch("tempfile.NamedTemporaryFile", side_effect=FileNotFoundError("No usable temporary directory found")):
74+
slack_sender.send_finding_to_slack(finding, slack_params, False)
75+
76+
# Verify that the message contains the finding title but not the file content
77+
latest_message = slack_channel.get_latest_message()
78+
logging.warning(latest_message)
79+
assert TEST_FINDING_TITLE in latest_message
80+
assert TEST_FILE_NAME in latest_message
81+
82+
83+
def test_send_file_named_tempfile_fails(slack_channel: SlackChannel):
84+
slack_sender = SlackSender(
85+
CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name
86+
)
87+
88+
finding = Finding(title=TEST_FINDING_TITLE, aggregation_key="TestTextFileUpload")
89+
finding.add_enrichment([FileBlock(TEST_FILE_NAME, TEST_FILE_CONTENT)])
90+
91+
slack_params = SlackSinkParams(name="test_slack", slack_channel=slack_channel.channel_name, api_key="")
92+
93+
# Simulate tempfile failure
94+
with patch("tempfile.SpooledTemporaryFile", side_effect=FileNotFoundError("No usable temporary directory found")):
95+
slack_sender.send_finding_to_slack(finding, slack_params, False)
96+
97+
# Check the Slack message
98+
latest_message = slack_channel.get_latest_message()
99+
assert TEST_FINDING_TITLE in latest_message
100+
assert TEST_FILE_NAME in latest_message
101+
102+
103+
def test_temporary_file_creation_failure(slack_channel: SlackChannel):
104+
slack_sender = SlackSender(
105+
CONFIG.PYTEST_IN_CLUSTER_SLACK_TOKEN, TEST_ACCOUNT, TEST_CLUSTER, TEST_KEY, slack_channel.channel_name
106+
)
107+
108+
# Test with a text file
109+
finding = Finding(title=TEST_FINDING_TITLE, aggregation_key="TestTextFileUpload")
110+
finding.add_enrichment(
111+
[FileBlock(TEST_FILE_NAME, TEST_FILE_CONTENT.encode()), FileBlock("file2.txt", TEST_FILE_CONTENT.encode())]
112+
)
113+
114+
slack_params = SlackSinkParams(name="test_slack", slack_channel=slack_channel.channel_name, api_key="")
115+
116+
# Mock NamedTemporaryFile to raise an exception
117+
with patch("tempfile.NamedTemporaryFile", side_effect=FileNotFoundError("No usable temporary directory found")):
118+
with patch("tempfile.SpooledTemporaryFile", side_effect=FileNotFoundError("Cant create spooled file")):
119+
slack_sender.send_finding_to_slack(finding, slack_params, False)
120+
121+
# Verify that the message contains the finding title but not the file content
122+
latest_message = slack_channel.get_latest_message()
123+
assert TEST_FINDING_TITLE in latest_message
124+
assert TEST_FILE_NAME not in latest_message # File should not be included
125+
126+
# Test with a binary file (PNG)
127+
png_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" # 1x1 transparent PNG
128+
finding = Finding(title="Test PNG File Upload", aggregation_key="TestPNGFileUpload")
129+
finding.add_enrichment([FileBlock("test.png", png_content)])
130+
131+
with patch("tempfile.NamedTemporaryFile", side_effect=FileNotFoundError("No usable temporary directory found")):
132+
with patch("tempfile.SpooledTemporaryFile", side_effect=FileNotFoundError("Cant create spooled file")):
133+
slack_sender.send_finding_to_slack(finding, slack_params, False)
134+
135+
# Verify that the message contains the finding title but not the file content
136+
latest_message = slack_channel.get_latest_message()
137+
assert "Test PNG File Upload" in latest_message
138+
assert "test.png" not in latest_message # File should not be included

tests/utils/slack_utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
13
from slack_sdk import WebClient
24

35

@@ -25,7 +27,12 @@ def _create_or_join_channel(client: WebClient, channel_name: str) -> str:
2527
"""Creates or joins the specified slack channel and returns its channel_id"""
2628
for channel in client.conversations_list()["channels"]:
2729
if channel["name"] == channel_name:
28-
# TODO: join the channel if necessary
30+
try:
31+
# Attempt to join the channel if not already joined
32+
client.conversations_join(channel=channel["id"])
33+
except Exception as e:
34+
# It's ok if already in channel or can't join (e.g., private and no permission)
35+
logging.warning(f"Could not join channel {channel_name}: {e}")
2936
return channel["id"]
3037

3138
# TODO: make this a private channel not a public channel. it shouldn't be visible to anyone who joins the public

0 commit comments

Comments
 (0)