Skip to content

Commit 87ce07a

Browse files
authored
Feature/azureblob-output (#451)
Added option to send report to Azure Blob Storage, as well as optionally notify a Teams Channel provided a webhook URL. If using the teams webhook alert, then an `azure-subscription-id` and `azure-resource-group` is required. Users can use the following new flags when running the program: - `--azurebloboutput "Blob Service SAS URL Here" ` - `--teams-webhook "Teams Webhook URL Here" ` - `--azure-subscription-id "Azure Subscription ID Here" ` - `--azure-resource-group "Azure Resource Group Name Here" ` Documentation is added to README.
1 parent 5391a8d commit 87ce07a

File tree

5 files changed

+259
-2
lines changed

5 files changed

+259
-2
lines changed

README.md

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ _View Instructions for: [Prometheus](#prometheus-victoria-metrics-and-thanos-aut
8181

8282
[![Used to receive information from KRR](./images/krr-other-integrations.svg)](#integrations)
8383

84-
_View instructions for: [Seeing recommendations in a UI](#free-ui-for-krr-recommendations), [Sending recommendations to Slack](#slack-notification), [Setting up KRR as a k9s plugin](#k9s-plugin)_
84+
_View instructions for: [Seeing recommendations in a UI](#free-ui-for-krr-recommendations), [Sending recommendations to Slack](#slack-notification), [Setting up KRR as a k9s plugin](#k9s-plugin), [Azure Blob Storage Export with Teams Notification](#azure-blob-teams-integration)_
8585

8686
### Features
8787

@@ -682,6 +682,84 @@ customPlaybooks:
682682
Installation instructions: [k9s docs](https://k9scli.io/topics/plugins/)
683683
</details>
684684

685+
<details id="azure-blob-teams-integration">
686+
<summary>Azure Blob Storage Export with Microsoft Teams Notifications</summary>
687+
688+
Export KRR reports directly to Azure Blob Storage and get notified in Microsoft Teams when reports are generated.
689+
690+
![Teams Notification Screenshot][teams-screenshot]
691+
692+
### Prerequisites
693+
694+
- An Azure Storage Account with a container for storing reports
695+
- A Microsoft Teams channel with an incoming webhook configured
696+
- Azure SAS URL with write permissions to your storage container
697+
698+
### Setup
699+
700+
1. **Create Azure Storage Container**: Set up a container in your Azure Storage Account (e.g., `fileuploads`)
701+
702+
2. **Generate SAS URL**: Create a SAS URL for your container with write permissions:
703+
```bash
704+
# Example SAS URL format (replace with your actual values)
705+
https://yourstorageaccount.blob.core.windows.net/fileuploads?sv=2024-11-04&ss=bf&srt=o&sp=wactfx&se=2026-07-21T21:12:48Z&st=2025-07-21T12:57:48Z&spr=https&sig=...
706+
```
707+
708+
3. **Configure Teams Webhook**: Set up an incoming webhook in your Microsoft Teams channel (located in the Workflows tab)
709+
710+
4. **Run KRR with Azure Integration**:
711+
```bash
712+
krr simple -f html \
713+
--azurebloboutput "https://yourstorageaccount.blob.core.windows.net/fileuploads?sv=..." \
714+
--teams-webhook "https://your-teams-webhook-url" \
715+
--azure-subscription-id "your-subscription-id" \
716+
--azure-resource-group "your-resource-group"
717+
```
718+
719+
### Features
720+
721+
- **Automatic File Upload**: Reports are automatically uploaded to Azure Blob Storage with timestamped filenames
722+
- **Teams Notifications**: Rich adaptive cards are sent to Teams when reports are generated
723+
- **Direct Links**: Teams notifications include direct links to view files in Azure Portal
724+
- **Multiple Formats**: Supports all KRR output formats (JSON, CSV, HTML, YAML, etc.)
725+
- **Secure**: Uses SAS URLs for secure, time-limited access to your storage
726+
727+
### Command Options
728+
729+
| Flag | Description |
730+
|------|-------------|
731+
| `--azurebloboutput` | Azure Blob Storage SAS URL base path (make sure you include the container name; filename will be auto-appended) |
732+
| `--teams-webhook` | Microsoft Teams webhook URL for notifications |
733+
| `--azure-subscription-id` | Azure Subscription ID (for Azure Portal links in Teams) |
734+
| `--azure-resource-group` | Azure Resource Group name (for Azure Portal links in Teams) |
735+
736+
### Example Usage
737+
738+
```bash
739+
# Basic Azure Blob export
740+
krr simple -f json --azurebloboutput "https://mystorageaccount.blob.core.windows.net/reports?sv=..."
741+
742+
# With Teams notifications
743+
krr simple -f html \
744+
--azurebloboutput "https://mystorageaccount.blob.core.windows.net/reports?sv=..." \
745+
--teams-webhook "https://outlook.office.com/webhook/..." \
746+
--azure-subscription-id "12345678-1234-1234-1234-123456789012" \
747+
--azure-resource-group "my-resource-group"
748+
```
749+
750+
### Teams Notification Features
751+
752+
The Teams adaptive card includes:
753+
- 📊 Report generation announcement
754+
- Namespace and format details
755+
- Generation timestamp
756+
- Storage account and container information
757+
- Direct "View in Azure Storage" button linking to Azure Portal
758+
759+
<p align="right">(<a href="#readme-top">back to top</a>)</p>
760+
761+
</details>
762+
685763
## Creating a Custom Strategy/Formatter
686764

687765
Look into the [examples](https://github.com/robusta-dev/krr/tree/main/examples) directory for examples on how to create a custom strategy/formatter.
@@ -768,3 +846,4 @@ If you have any questions, feel free to contact **[email protected]** or messa
768846
[product-screenshot]: images/screenshot.jpeg
769847
[slack-screenshot]: images/krr_slack_example.png
770848
[ui-screenshot]: images/ui_video.gif
849+
[teams-screenshot]: images/krr_teams_example.png

images/krr_teams_example.png

46.2 KB
Loading

robusta_krr/core/models/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ class Config(pd.BaseSettings):
6969
file_output: Optional[str] = pd.Field(None)
7070
file_output_dynamic: bool = pd.Field(False)
7171
slack_output: Optional[str] = pd.Field(None)
72+
azureblob_output: Optional[str] = pd.Field(None)
73+
teams_webhook: Optional[str] = pd.Field(None)
74+
azure_subscription_id: Optional[str] = pd.Field(None)
75+
azure_resource_group: Optional[str] = pd.Field(None)
7276

7377
other_args: dict[str, Any]
7478

robusta_krr/core/runner.py

Lines changed: 147 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from rich.console import Console
1313
from slack_sdk import WebClient
1414
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
15+
from urllib.parse import urlparse
1516
import requests
1617
import json
1718
import traceback
@@ -115,7 +116,7 @@ def _process_result(self, result: Result) -> None:
115116

116117
custom_print(formatted, rich=rich, force=True)
117118

118-
if settings.file_output_dynamic or settings.file_output or settings.slack_output:
119+
if settings.file_output_dynamic or settings.file_output or settings.slack_output or settings.azureblob_output:
119120
if settings.file_output_dynamic:
120121
current_datetime = datetime.now().strftime("%Y%m%d%H%M%S")
121122
file_name = f"krr-{current_datetime}.{settings.format}"
@@ -124,6 +125,10 @@ def _process_result(self, result: Result) -> None:
124125
file_name = settings.file_output
125126
elif settings.slack_output:
126127
file_name = settings.slack_output
128+
elif settings.azureblob_output:
129+
current_datetime = datetime.now().strftime("%Y%m%d%H%M%S")
130+
file_name = f"krr-{current_datetime}.{settings.format}"
131+
logger.info(f"Writing output to file: {file_name}")
127132

128133
with open(file_name, "w") as target_file:
129134
# don't use rich when writing a csv or html to avoid line wrapping etc
@@ -132,6 +137,14 @@ def _process_result(self, result: Result) -> None:
132137
else:
133138
console = Console(file=target_file, width=settings.width)
134139
console.print(formatted)
140+
141+
if settings.azureblob_output:
142+
self._upload_to_azure_blob(file_name, settings.azureblob_output)
143+
if settings.teams_webhook:
144+
storage_account, container = self._extract_storage_info_from_sas(settings.azureblob_output)
145+
self._notify_teams(settings.teams_webhook, storage_account, container)
146+
os.remove(file_name)
147+
135148
if settings.slack_output:
136149
client = WebClient(os.environ["SLACK_BOT_TOKEN"])
137150
warnings.filterwarnings("ignore", category=UserWarning)
@@ -152,6 +165,139 @@ def _process_result(self, result: Result) -> None:
152165

153166
os.remove(file_name)
154167

168+
def _upload_to_azure_blob(self, file_name: str, base_sas_url: str):
169+
try:
170+
logger.info(f"Uploading {file_name} to Azure Blob Storage")
171+
172+
with open(file_name, "rb") as file:
173+
file_data = file.read()
174+
175+
headers = {
176+
"Content-Type": "application/octet-stream",
177+
"x-ms-blob-type": "BlockBlob",
178+
}
179+
180+
if file_name.endswith(".csv"):
181+
headers["Content-Type"] = "text/csv"
182+
elif file_name.endswith(".json"):
183+
headers["Content-Type"] = "application/json"
184+
elif file_name.endswith(".yaml"):
185+
headers["Content-Type"] = "application/x-yaml"
186+
elif file_name.endswith(".html"):
187+
headers["Content-Type"] = "text/html"
188+
189+
base_url = base_sas_url.rstrip('/')
190+
url_part, query_part = base_url.split('?', 1)
191+
full_sas_url = f"{url_part}/{file_name}?{query_part}"
192+
193+
response = requests.put(full_sas_url, headers=headers, data=file_data)
194+
195+
if response.status_code == 201:
196+
logger.info(f"Successfully uploaded {file_name} to Azure Blob Storage")
197+
else:
198+
logger.error(f"Failed to upload {file_name} to Azure Blob Storage. Status code: {response.status_code}")
199+
logger.error(f"Response: {response.text}")
200+
except Exception as e:
201+
logger.error(f"An error occurred while uploading {file_name} to Azure Blob Storage: {e}", exc_info=True)
202+
203+
def _notify_teams(self, webhook_url: str, storage_account: str, container: str):
204+
"""Send notification to Teams with configurable webhook URL."""
205+
try:
206+
azure_portal_url = self._build_azure_portal_url(storage_account, container)
207+
208+
adaptive_card = {
209+
"type": "message",
210+
"attachments": [
211+
{
212+
"contentType": "application/vnd.microsoft.card.adaptive",
213+
"content": {
214+
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
215+
"type": "AdaptiveCard",
216+
"version": "1.2",
217+
"body": [
218+
{
219+
"type": "TextBlock",
220+
"text": "📊 KRR Report Generated",
221+
"weight": "Bolder",
222+
"size": "Medium",
223+
"color": "Good"
224+
},
225+
{
226+
"type": "TextBlock",
227+
"text": f"Kubernetes Resource Report for {(' '.join(settings.namespaces))} has been generated and uploaded to Azure Blob Storage.",
228+
"wrap": True,
229+
"spacing": "Medium"
230+
},
231+
{
232+
"type": "FactSet",
233+
"facts": [
234+
{
235+
"title": "Namespaces:",
236+
"value": ' '.join(settings.namespaces)
237+
},
238+
{
239+
"title": "Format:",
240+
"value": settings.format
241+
},
242+
{
243+
"title": "Storage Account:",
244+
"value": storage_account
245+
},
246+
{
247+
"title": "Container:",
248+
"value": container
249+
},
250+
{
251+
"title": "Generated:",
252+
"value": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
253+
}
254+
]
255+
}
256+
],
257+
"actions": [
258+
{
259+
"type": "Action.OpenUrl",
260+
"title": "View in Azure Storage",
261+
"url": azure_portal_url
262+
}
263+
]
264+
}
265+
}
266+
]
267+
}
268+
269+
response = requests.post(webhook_url, json=adaptive_card)
270+
if response.status_code == 202:
271+
logger.info("Successfully notified Microsoft Teams about the report generation.")
272+
else:
273+
logger.error(f"Failed to notify Microsoft Teams. Status code: {response.status_code}")
274+
logger.error(f"Response: {response.text}")
275+
except Exception as e:
276+
logger.error(f"Error sending Teams notification: {e}", exc_info=True)
277+
278+
def _extract_storage_info_from_sas(self, sas_url: str) -> tuple[str, str]:
279+
"""
280+
Extracts the storage account name and container name from the SAS URL.
281+
"""
282+
try:
283+
parsed = urlparse(sas_url)
284+
storage_account = parsed.hostname.split('.')[0] # Extract the storage account name from the hostname
285+
container = parsed.path.strip('/').split('/')[0] # Extract the first part of the path as the container name
286+
287+
return storage_account, container
288+
except Exception as e:
289+
logger.error(f"Failed to extract storage info from SAS URL: {e}")
290+
raise ValueError("Invalid SAS URL format. Please provide a valid Azure Blob Storage SAS URL.") from e
291+
292+
def _build_azure_portal_url(self, storage_account: str, container: str) -> str:
293+
"""
294+
Builds the Azure portal URL to view the specified storage account and container.
295+
"""
296+
297+
if not settings.azure_subscription_id or not settings.azure_resource_group:
298+
# Return a generic Azure portal link if specific info is missing
299+
logger.warning("Azure subscription ID or resource group not provided. Azure portal link will not be specific.")
300+
return f"https://portal.azure.com/#view/Microsoft_Azure_Storage/ContainerMenuBlade/~/overview/storageAccountId/%2Fsubscriptions%2F{settings.azure_subscription_id}%2FresourceGroups%2F{settings.azure_resource_group}%2Fproviders%2FMicrosoft.Storage%2FstorageAccounts%2F{storage_account}/path/{container}"
155301

156302
def __get_resource_minimal(self, resource: ResourceType) -> float:
157303
if resource == ResourceType.CPU:

robusta_krr/main.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,30 @@ def run_strategy(
266266
help="Send to output to a slack channel, must have SLACK_BOT_TOKEN with permissions: chat:write, files:write, chat:write.public. Bot must be added to the channel.",
267267
rich_help_panel="Output Settings",
268268
),
269+
azureblob_output: Optional[str] = typer.Option(
270+
None,
271+
"--azurebloboutput",
272+
help="Provide Azure Blob Storage SAS URL (with the container) to upload the output file to (e.g., https://mystorageaccount.blob.core.windows.net/container?sv=...). The filename will be automatically appended.",
273+
rich_help_panel="Output Settings",
274+
),
275+
teams_webhook: Optional[str] = typer.Option(
276+
None,
277+
"--teams-webhook",
278+
help="Microsoft Teams webhook URL to send notifications when files are uploaded to Azure Blob Storage",
279+
rich_help_panel="Output Settings",
280+
),
281+
azure_subscription_id: Optional[str] = typer.Option(
282+
None,
283+
"--azure-subscription-id",
284+
help="Azure Subscription ID for Teams notification Azure Portal links",
285+
rich_help_panel="Output Settings",
286+
),
287+
azure_resource_group: Optional[str] = typer.Option(
288+
None,
289+
"--azure-resource-group",
290+
help="Azure Resource Group for Teams notification Azure Portal links",
291+
rich_help_panel="Output Settings",
292+
),
269293
publish_scan_url: Optional[str] = typer.Option(
270294
None,
271295
"--publish_scan_url",
@@ -325,6 +349,10 @@ def run_strategy(
325349
file_output=file_output,
326350
file_output_dynamic=file_output_dynamic,
327351
slack_output=slack_output,
352+
azureblob_output=azureblob_output,
353+
teams_webhook=teams_webhook,
354+
azure_subscription_id=azure_subscription_id,
355+
azure_resource_group=azure_resource_group,
328356
show_severity=show_severity,
329357
strategy=_strategy_name,
330358
other_args=strategy_args,

0 commit comments

Comments
 (0)