Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
81 changes: 80 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ _View Instructions for: [Prometheus](#prometheus-victoria-metrics-and-thanos-aut

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

_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)_
_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)_

### Features

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

<details id="azure-blob-teams-integration">
<summary>Azure Blob Storage Export with Microsoft Teams Notifications</summary>

Export KRR reports directly to Azure Blob Storage and get notified in Microsoft Teams when reports are generated.

![Teams Notification Screenshot][teams-screenshot]

### Prerequisites

- An Azure Storage Account with a container for storing reports
- A Microsoft Teams channel with an incoming webhook configured
- Azure SAS URL with write permissions to your storage container

### Setup

1. **Create Azure Storage Container**: Set up a container in your Azure Storage Account (e.g., `fileuploads`)

2. **Generate SAS URL**: Create a SAS URL for your container with write permissions:
```bash
# Example SAS URL format (replace with your actual values)
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=...
```

3. **Configure Teams Webhook**: Set up an incoming webhook in your Microsoft Teams channel (located in the Workflows tab)

4. **Run KRR with Azure Integration**:
```bash
krr simple -f html \
--azurebloboutput "https://yourstorageaccount.blob.core.windows.net/fileuploads?sv=..." \
--teams-webhook "https://your-teams-webhook-url" \
--azure-subscription-id "your-subscription-id" \
--azure-resource-group "your-resource-group"
```

### Features

- **Automatic File Upload**: Reports are automatically uploaded to Azure Blob Storage with timestamped filenames
- **Teams Notifications**: Rich adaptive cards are sent to Teams when reports are generated
- **Direct Links**: Teams notifications include direct links to view files in Azure Portal
- **Multiple Formats**: Supports all KRR output formats (JSON, CSV, HTML, YAML, etc.)
- **Secure**: Uses SAS URLs for secure, time-limited access to your storage

### Command Options

| Flag | Description |
|------|-------------|
| `--azurebloboutput` | Azure Blob Storage SAS URL base path (make sure you include the container name; filename will be auto-appended) |
| `--teams-webhook` | Microsoft Teams webhook URL for notifications |
| `--azure-subscription-id` | Azure Subscription ID (for Azure Portal links in Teams) |
| `--azure-resource-group` | Azure Resource Group name (for Azure Portal links in Teams) |

### Example Usage

```bash
# Basic Azure Blob export
krr simple -f json --azurebloboutput "https://mystorageaccount.blob.core.windows.net/reports?sv=..."

# With Teams notifications
krr simple -f html \
--azurebloboutput "https://mystorageaccount.blob.core.windows.net/reports?sv=..." \
--teams-webhook "https://outlook.office.com/webhook/..." \
--azure-subscription-id "12345678-1234-1234-1234-123456789012" \
--azure-resource-group "my-resource-group"
```

### Teams Notification Features

The Teams adaptive card includes:
- 📊 Report generation announcement
- Namespace and format details
- Generation timestamp
- Storage account and container information
- Direct "View in Azure Storage" button linking to Azure Portal

<p align="right">(<a href="#readme-top">back to top</a>)</p>

</details>

## Creating a Custom Strategy/Formatter

Look into the [examples](https://github.com/robusta-dev/krr/tree/main/examples) directory for examples on how to create a custom strategy/formatter.
Expand Down Expand Up @@ -768,3 +846,4 @@ If you have any questions, feel free to contact **[email protected]** or messa
[product-screenshot]: images/screenshot.jpeg
[slack-screenshot]: images/krr_slack_example.png
[ui-screenshot]: images/ui_video.gif
[teams-screenshot]: images/krr_teams_example.png
Binary file added images/krr_teams_example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions robusta_krr/core/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ class Config(pd.BaseSettings):
file_output: Optional[str] = pd.Field(None)
file_output_dynamic: bool = pd.Field(False)
slack_output: Optional[str] = pd.Field(None)
azureblob_output: Optional[str] = pd.Field(None)
teams_webhook: Optional[str] = pd.Field(None)
azure_subscription_id: Optional[str] = pd.Field(None)
azure_resource_group: Optional[str] = pd.Field(None)

other_args: dict[str, Any]

Expand Down
148 changes: 147 additions & 1 deletion robusta_krr/core/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from rich.console import Console
from slack_sdk import WebClient
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from urllib.parse import urlparse
import requests
import json
import traceback
Expand Down Expand Up @@ -115,7 +116,7 @@ def _process_result(self, result: Result) -> None:

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

if settings.file_output_dynamic or settings.file_output or settings.slack_output:
if settings.file_output_dynamic or settings.file_output or settings.slack_output or settings.azureblob_output:
if settings.file_output_dynamic:
current_datetime = datetime.now().strftime("%Y%m%d%H%M%S")
file_name = f"krr-{current_datetime}.{settings.format}"
Expand All @@ -124,6 +125,10 @@ def _process_result(self, result: Result) -> None:
file_name = settings.file_output
elif settings.slack_output:
file_name = settings.slack_output
elif settings.azureblob_output:
current_datetime = datetime.now().strftime("%Y%m%d%H%M%S")
file_name = f"krr-{current_datetime}.{settings.format}"
logger.info(f"Writing output to file: {file_name}")

with open(file_name, "w") as target_file:
# don't use rich when writing a csv or html to avoid line wrapping etc
Expand All @@ -132,6 +137,14 @@ def _process_result(self, result: Result) -> None:
else:
console = Console(file=target_file, width=settings.width)
console.print(formatted)

if settings.azureblob_output:
self._upload_to_azure_blob(file_name, settings.azureblob_output)
if settings.teams_webhook:
storage_account, container = self._extract_storage_info_from_sas(settings.azureblob_output)
self._notify_teams(settings.teams_webhook, storage_account, container)
os.remove(file_name)

if settings.slack_output:
client = WebClient(os.environ["SLACK_BOT_TOKEN"])
warnings.filterwarnings("ignore", category=UserWarning)
Expand All @@ -152,6 +165,139 @@ def _process_result(self, result: Result) -> None:

os.remove(file_name)

def _upload_to_azure_blob(self, file_name: str, base_sas_url: str):
try:
logger.info(f"Uploading {file_name} to Azure Blob Storage")

with open(file_name, "rb") as file:
file_data = file.read()

headers = {
"Content-Type": "application/octet-stream",
"x-ms-blob-type": "BlockBlob",
}

if file_name.endswith(".csv"):
headers["Content-Type"] = "text/csv"
elif file_name.endswith(".json"):
headers["Content-Type"] = "application/json"
elif file_name.endswith(".yaml"):
headers["Content-Type"] = "application/x-yaml"
elif file_name.endswith(".html"):
headers["Content-Type"] = "text/html"

base_url = base_sas_url.rstrip('/')
url_part, query_part = base_url.split('?', 1)
full_sas_url = f"{url_part}/{file_name}?{query_part}"

response = requests.put(full_sas_url, headers=headers, data=file_data)

if response.status_code == 201:
logger.info(f"Successfully uploaded {file_name} to Azure Blob Storage")
else:
logger.error(f"Failed to upload {file_name} to Azure Blob Storage. Status code: {response.status_code}")
logger.error(f"Response: {response.text}")
except Exception as e:
logger.error(f"An error occurred while uploading {file_name} to Azure Blob Storage: {e}", exc_info=True)

def _notify_teams(self, webhook_url: str, storage_account: str, container: str):
"""Send notification to Teams with configurable webhook URL."""
try:
azure_portal_url = self._build_azure_portal_url(storage_account, container)

adaptive_card = {
"type": "message",
"attachments": [
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.2",
"body": [
{
"type": "TextBlock",
"text": "📊 KRR Report Generated",
"weight": "Bolder",
"size": "Medium",
"color": "Good"
},
{
"type": "TextBlock",
"text": f"Kubernetes Resource Report for {(' '.join(settings.namespaces))} has been generated and uploaded to Azure Blob Storage.",
"wrap": True,
"spacing": "Medium"
},
{
"type": "FactSet",
"facts": [
{
"title": "Namespaces:",
"value": ' '.join(settings.namespaces)
},
{
"title": "Format:",
"value": settings.format
},
{
"title": "Storage Account:",
"value": storage_account
},
{
"title": "Container:",
"value": container
},
{
"title": "Generated:",
"value": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
]
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "View in Azure Storage",
"url": azure_portal_url
}
]
}
}
]
}

response = requests.post(webhook_url, json=adaptive_card)
if response.status_code == 202:
logger.info("Successfully notified Microsoft Teams about the report generation.")
else:
logger.error(f"Failed to notify Microsoft Teams. Status code: {response.status_code}")
logger.error(f"Response: {response.text}")
except Exception as e:
logger.error(f"Error sending Teams notification: {e}", exc_info=True)

def _extract_storage_info_from_sas(self, sas_url: str) -> tuple[str, str]:
"""
Extracts the storage account name and container name from the SAS URL.
"""
try:
parsed = urlparse(sas_url)
storage_account = parsed.hostname.split('.')[0] # Extract the storage account name from the hostname
container = parsed.path.strip('/').split('/')[0] # Extract the first part of the path as the container name

return storage_account, container
except Exception as e:
logger.error(f"Failed to extract storage info from SAS URL: {e}")
raise ValueError("Invalid SAS URL format. Please provide a valid Azure Blob Storage SAS URL.")

def _build_azure_portal_url(self, storage_account: str, container: str) -> str:
"""
Builds the Azure portal URL to view the specified storage account and container.
"""

if not settings.azure_subscription_id or not settings.azure_resource_group:
# Return a generic Azure portal link if specific info is missing
logger.warning("Azure subscription ID or resource group not provided. Azure portal link will not be specific.")
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}"

def __get_resource_minimal(self, resource: ResourceType) -> float:
if resource == ResourceType.CPU:
Expand Down
28 changes: 28 additions & 0 deletions robusta_krr/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,30 @@ def run_strategy(
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.",
rich_help_panel="Output Settings",
),
azureblob_output: Optional[str] = typer.Option(
None,
"--azurebloboutput",
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.",
rich_help_panel="Output Settings",
),
teams_webhook: Optional[str] = typer.Option(
None,
"--teams-webhook",
help="Microsoft Teams webhook URL to send notifications when files are uploaded to Azure Blob Storage",
rich_help_panel="Output Settings",
),
azure_subscription_id: Optional[str] = typer.Option(
None,
"--azure-subscription-id",
help="Azure Subscription ID for Teams notification Azure Portal links",
rich_help_panel="Output Settings",
),
azure_resource_group: Optional[str] = typer.Option(
None,
"--azure-resource-group",
help="Azure Resource Group for Teams notification Azure Portal links",
rich_help_panel="Output Settings",
),
publish_scan_url: Optional[str] = typer.Option(
None,
"--publish_scan_url",
Expand Down Expand Up @@ -325,6 +349,10 @@ def run_strategy(
file_output=file_output,
file_output_dynamic=file_output_dynamic,
slack_output=slack_output,
azureblob_output=azureblob_output,
teams_webhook=teams_webhook,
azure_subscription_id=azure_subscription_id,
azure_resource_group=azure_resource_group,
show_severity=show_severity,
strategy=_strategy_name,
other_args=strategy_args,
Expand Down