diff --git a/README.md b/README.md index 568496a4..aacba08c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -682,6 +682,84 @@ customPlaybooks: Installation instructions: [k9s docs](https://k9scli.io/topics/plugins/) +
+Azure Blob Storage Export with Microsoft Teams Notifications + +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 + +

(back to top)

+ +
+ ## 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. @@ -768,3 +846,4 @@ If you have any questions, feel free to contact **support@robusta.dev** 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 diff --git a/images/krr_teams_example.png b/images/krr_teams_example.png new file mode 100644 index 00000000..ff536bf2 Binary files /dev/null and b/images/krr_teams_example.png differ diff --git a/robusta_krr/core/models/config.py b/robusta_krr/core/models/config.py index 277e08fe..d9295c14 100644 --- a/robusta_krr/core/models/config.py +++ b/robusta_krr/core/models/config.py @@ -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] diff --git a/robusta_krr/core/runner.py b/robusta_krr/core/runner.py index 8ec15dc4..3b4440f1 100644 --- a/robusta_krr/core/runner.py +++ b/robusta_krr/core/runner.py @@ -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 @@ -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}" @@ -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 @@ -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) @@ -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.") from e + + 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: diff --git a/robusta_krr/main.py b/robusta_krr/main.py index 2d8bfc36..dcfa12c3 100644 --- a/robusta_krr/main.py +++ b/robusta_krr/main.py @@ -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", @@ -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,