diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6c250bddd..640c50958 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -105,10 +105,6 @@ jobs: name: helm-chart path: helm/robusta/ - - name: Upload helm chart - run: | - cd helm && ./upload_chart.sh - - name: Release Docker to Dockerhub run: |- docker buildx build \ @@ -118,3 +114,7 @@ jobs: --tag robustadev/robusta-runner:${{env.RELEASE_VER}} \ --push \ . + + - name: Upload helm chart + run: | + cd helm && ./upload_chart.sh diff --git a/docs/conf.py b/docs/conf.py index 7cd8c1c99..e944c76e3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -113,7 +113,8 @@ "tutorials/playbook-failed-liveness.html": "/master/playbook-reference/kubernetes-examples/playbook-failed-liveness.html", "tutorials/playbook-track-secrets.html": "/master/playbook-reference/kubernetes-examples//playbook-track-secrets.html", "tutorials/alert-remediation.html": "/master/playbook-reference/prometheus-examples/alert-remediation.html", - "tutorials/alert-custom-enrichment.html": "/master/playbook-reference/prometheus-examples/alert-custom-enrichment.html" + "tutorials/alert-custom-enrichment.html": "/master/playbook-reference/prometheus-examples/alert-custom-enrichment.html", + "catalog/sinks/slack.html": "/master/configuration/sinks/slack.html" } diff --git a/docs/configuration/ai-analysis.rst b/docs/configuration/ai-analysis.rst index 7f0da992e..c2f37a0a7 100644 --- a/docs/configuration/ai-analysis.rst +++ b/docs/configuration/ai-analysis.rst @@ -249,7 +249,7 @@ Reading the Robusta UI Token from a secret in HolmesGPT .. code-block:: yaml holmes: - additional_env_vars: + additionalEnvVars: .... - name: ROBUSTA_UI_TOKEN valueFrom: diff --git a/docs/configuration/alertmanager-integration/embedded-prometheus.rst b/docs/configuration/alertmanager-integration/embedded-prometheus.rst index 70480034c..219c9f5be 100644 --- a/docs/configuration/alertmanager-integration/embedded-prometheus.rst +++ b/docs/configuration/alertmanager-integration/embedded-prometheus.rst @@ -50,3 +50,9 @@ To allow the Grafana dashboard to persist after the Grafana instance restarts, y enabled: true Apply the change by performing a :ref:`Helm Upgrade `. + +Troubleshooting +--------------------- + +Encountering issues with your Prometheus? Follow this guide to resolve some :ref:`common errors `. + diff --git a/docs/configuration/cluster-misconfigurations.rst b/docs/configuration/cluster-misconfigurations.rst deleted file mode 100644 index f3a0fae8e..000000000 --- a/docs/configuration/cluster-misconfigurations.rst +++ /dev/null @@ -1,126 +0,0 @@ -Kubernetes Misconfigurations (Popeye) -************************************************ - -`Popeye `_ is a utility that scans live Kubernetes clusters and reports potential issues with resources and configurations. - -By optionally integrating Popeye with Robusta you can: - -1. Get weekly Popeye scan reports in Slack via Robusta OSS (disabled by default, see below to configure) -2. View Popeye scans from all your clusters in the Robusta UI (enabled by default for UI users) - -Sending Weekly Popeye Scan Reports to Slack -=========================================== -With or without the UI, you can configure additional scans on a :ref:`schedule ` as shown below. The results can be sent as a PDF to Slack or to the Robusta UI. - - -.. code-block:: yaml - :name: cb-popeye-set-periodic-scan - - customPlaybooks: - - triggers: - - on_schedule: - fixed_delay_repeat: - repeat: 1 # number of times to run or -1 to run forever - seconds_delay: 604800 # 1 week - actions: - - popeye_scan: - spinach: | - popeye: - excludes: - v1/pods: - - name: rx:kube-system - sinks: - - "robusta_ui_sink" - - -.. grid:: 1 1 1 1 - - .. grid-item:: - - .. md-tab-set:: - - .. md-tab-item:: Slack - - .. image:: /images/popeye_slack_example.png - :width: 1000px - - .. md-tab-item:: Robusta UI - - .. image:: /images/popeye_example.png - :width: 1000px - - -.. Note:: - - Other sinks like MSTeams are not supported yet. - -Taints, Tolerations and NodeSelectors -============================================ - -To run Popeye on a GPU enabled cluster or on specific nodes you can set custom tolerations or a nodeSelector in your ``generated_values.yaml`` file as follows: - -.. code-block:: yaml - :name: cb-popeye-set-custom-taints - - globalConfig: - popeye_job_spec: - tolerations: - - key: "key1" - operator: "Exists" - effect: "NoSchedule" - nodeSelector: - kubernetes.io/arch: "amd64" - nodeName: "your-selector" - -.. Note:: - - Popeye does `not support `_ arm nodes yet. If your cluster has both Arm and x64 nodes add ``kubernetes.io/arch: "amd64"`` as a node selector to schedule Popeye jobs on the x64 nodes. - -Troubleshooting Popeye -======================= - -Popeye scans run as Jobs in your cluster. If there are issues with a scan, troubleshoot as follows: - -Events ---------------------- -* To find errors with the Popeye job run: - -.. code-block:: bash - :name: cb-popeye-get-events - - kubectl get events --all-namespaces --field-selector=type!=Normal | grep popeye-job - -Logs ---------------------- -* Additional errors can sometimes be found in the Robusta runner logs: - -.. code-block:: bash - :name: cb-popeye-get-logs - - robusta logs - - -Known issues ---------------------- - -``couldn't get resource list for external.metrics.k8s.io/v1beta1`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -This is a known issue, there is a working workaround, which involves deploying a dummy workload. -Read more about it `here `_. - -``exec /bin/sh: exec format error`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -At the moment, Popeye docker images are only compiled for linux/amd64 os/arch. -This error suggests you are running the Popeye image on a different os/arch node. - -Reference -====================================== -.. robusta-action:: playbooks.robusta_playbooks.popeye.popeye_scan on_schedule - - You can trigger a Popeye scan at any time, by running the following command: - - .. code-block:: bash - - robusta playbooks trigger popeye_scan \ No newline at end of file diff --git a/docs/configuration/exporting/exporting-data.rst b/docs/configuration/exporting/exporting-data.rst index 0633120c0..0cf02cd77 100644 --- a/docs/configuration/exporting/exporting-data.rst +++ b/docs/configuration/exporting/exporting-data.rst @@ -1,7 +1,7 @@ Alert History Import and Export API =================================== -GET https://api.robusta.dev/api/alerts +GET https://api.robusta.dev/api/query/alerts -------------------------------------- Use this endpoint to export alert history data. You can filter the results based on specific criteria using query parameters such as ``alert_name``, ``account_id``, and time range. @@ -149,6 +149,127 @@ Response Fields - The node where the resource is located. +GET `https://api.robusta.dev/api/query/report` +-------------------------------------- + +Use this endpoint to retrieve aggregated alert data, including the count of each type of alert during a specified time range. Filters can be applied using query parameters such as `account_id` and the time range. + + +Query Parameters +^^^^^^^^^^^^^^^ + +.. list-table:: + :widths: 20 10 70 10 + :header-rows: 1 + + * - Parameter + - Type + - Description + - Required + * - ``account_id`` + - string + - The unique account identifier (found in your ``generated_values.yaml`` file). + - Yes + * - ``start_ts`` + - string + - Start timestamp for the query (in ISO 8601 format, e.g., ``2024-10-27T04:02:05.032Z``). + - Yes + * - ``end_ts`` + - string + - End timestamp for the query (in ISO 8601 format, e.g., ``2024-11-27T05:02:05.032Z``). + - Yes + + +Example Request +^^^^^^^^^^^^^^^ + +The following `curl` command demonstrates how to query aggregated alert data for a specified time range: + +.. code-block:: bash + + curl --location 'https://api.robusta.dev/api/query/report?account_id=XXXXXX-XXXX_XXXX_XXXXX7&start_ts=2024-10-27T04:02:05.032Z&end_ts=2024-11-27T05:02:05.032Z' \ + --header 'Authorization: Bearer TOKEN_HERE' + + +In the command, make sure to replace the following placeholders: + +- **`account_id`**: Your account ID, which can be found in your `generated_values.yaml` file. +- **`TOKEN_HERE`**: Your API token for authentication. Generate this token in the platform by navigating to **Settings** -> **API Keys** -> **New API Key**, and creating a key with the "Read Alerts" permission. + + + +Request Headers +^^^^^^^^^^^^^^^ + +.. list-table:: + :widths: 30 70 + :header-rows: 1 + + * - Header + - Description + * - ``Authorization`` + - Bearer token for authentication (e.g., ``Bearer TOKEN_HERE``). The token must have "Read Alerts" permission. + +Response Format +^^^^^^^^^^^^^^^ + +The API will return a JSON array of aggregated alerts, with each object containing: + +- **`aggregation_key`**: The unique identifier of the alert type (e.g., `KubeJobFailed`). +- **`alert_count`**: The total count of occurrences of this alert type within the specified time range. + +Example Response +^^^^^^^^^^^^^^^ +.. code-block:: json + [ + {"aggregation_key": "KubeJobFailed", "alert_count": 17413}, + {"aggregation_key": "KubePodNotReady", "alert_count": 11893}, + {"aggregation_key": "KubeDeploymentReplicasMismatch", "alert_count": 2410}, + {"aggregation_key": "KubeDeploymentRolloutStuck", "alert_count": 923}, + {"aggregation_key": "KubePodCrashLooping", "alert_count": 921}, + {"aggregation_key": "KubeContainerWaiting", "alert_count": 752}, + {"aggregation_key": "PrometheusRuleFailures", "alert_count": 188}, + {"aggregation_key": "KubeMemoryOvercommit", "alert_count": 187}, + {"aggregation_key": "PrometheusOperatorRejectedResources", "alert_count": 102}, + {"aggregation_key": "KubeletTooManyPods", "alert_count": 94}, + {"aggregation_key": "NodeMemoryHighUtilization", "alert_count": 23}, + {"aggregation_key": "TargetDown", "alert_count": 19}, + {"aggregation_key": "test123", "alert_count": 7}, + {"aggregation_key": "KubeAggregatedAPIDown", "alert_count": 4}, + {"aggregation_key": "KubeAggregatedAPIErrors", "alert_count": 4}, + {"aggregation_key": "KubeMemoryOvercommitTEST2", "alert_count": 1}, + {"aggregation_key": "TestAlert", "alert_count": 1}, + {"aggregation_key": "TestAlert2", "alert_count": 1}, + {"aggregation_key": "dsafd", "alert_count": 1}, + {"aggregation_key": "KubeMemoryOvercommitTEST", "alert_count": 1}, + {"aggregation_key": "vfd", "alert_count": 1} + ] + + + +Response Fields +^^^^^^^^^^^^^^^ +.. list-table:: + :widths: 25 10 70 + :header-rows: 1 + + * - Field + - Type + - Description + * - ``aggregation_key`` + - string + - The unique key representing the type of alert (e.g., ``KubeJobFailed``). + * - ``alert_count`` + - integer + - The number of times this alert occurred within the specified time range. + +Notes +^^^^^^^^^^^^^^^ + +- Ensure that the `start_ts` and `end_ts` parameters are in ISO 8601 format and are correctly set to cover the desired time range. +- Use the correct `Authorization` token with sufficient permissions to access the alert data. + + POST https://api.robusta.dev/api/alerts -------------------------------------- Use this endpoint to send alert data to Robusta. You can send up to 1000 alerts in a single request. diff --git a/docs/configuration/sinks/ms-teams.rst b/docs/configuration/sinks/ms-teams.rst index 52b10623f..76e4dc1b8 100644 --- a/docs/configuration/sinks/ms-teams.rst +++ b/docs/configuration/sinks/ms-teams.rst @@ -27,10 +27,14 @@ Then do a :ref:`Helm Upgrade `. Obtaining a webhook URL ----------------------------------- -- Choose a channel and click "Manage Channel". -- Click "Connectors->Edit", and configure an "Incoming Webhook" -- Fill out the name ``robusta``. -- Optional: upload the :download:`robusta logo ` for the connector’s image. +- Click '...' on the channel you want to add the webhook to. +- Click 'Workflows'. +- In the search box type 'webhook'. +- Select ``webhook template``. +- Name the webhook as 'Robusta Webhook'. +- Click 'Next'. +- Make sure the right Team & Channel is selected +- Click 'Add workflow'. - Copy the ``webhook_url``. .. image:: /images/msteams_sink/msteam_get_webhook_url.gif diff --git a/docs/images/msteams_sink/msteam_get_webhook_url.gif b/docs/images/msteams_sink/msteam_get_webhook_url.gif index 38d999526..2ed496c1e 100644 Binary files a/docs/images/msteams_sink/msteam_get_webhook_url.gif and b/docs/images/msteams_sink/msteam_get_webhook_url.gif differ diff --git a/docs/setup-robusta/proxies.rst b/docs/setup-robusta/proxies.rst index 0a038242a..2af6463e5 100644 --- a/docs/setup-robusta/proxies.rst +++ b/docs/setup-robusta/proxies.rst @@ -6,18 +6,25 @@ Robusta requires internet access in the following cases: * Robusta SaaS is enabled * Robusta is configured to send notifications to services such as Slack (via :ref:`sinks `) -If your Kubernetes cluster is behind an HTTP proxy or firewall, follow the instructions below to ensure Robusta has the necessary access. +If your Kubernetes cluster is behind an HTTP proxy or firewall, follow the instructions below to ensure Robusta and HolmesGPT has the necessary access. Configuring Proxy Settings for Robusta ---------------------------------------- -All outbound traffic from Robusta is handled by the `robusta-runner` deployment. +Outbound traffic from Robusta is handled by the `robusta-runner` deployment. -To configure proxy settings for `robusta-runner`, set the `HTTP_PROXY` and `HTTPS_PROXY` environment variables. You can do so with one of the follopwing Helm values: +To configure proxy settings for `robusta-runner`, set the `HTTP_PROXY` and `HTTPS_PROXY` environment variables. You can do so with one of the following Helm values: * ``runner.additional_env_vars`` - to set one environment variable at a time * ``runner.additional_env_froms`` - to set many environment variables at once +Configuring Proxy Settings for HolmesGPT +---------------------------------------- + +Set the `HTTP_PROXY` and `HTTPS_PROXY` environment variables. You can do so with the following Helm values: + +* ``holmes.additionalEnvVars`` - to set one environment variable at a time + Either Helm value can be used, depending on your preference. See `this GitHub issue for details and an example configuration `_. Domains Used by Robusta Saas UI diff --git a/helm/robusta/templates/runner.yaml b/helm/robusta/templates/runner.yaml index 09882c603..ec5ceb1b3 100644 --- a/helm/robusta/templates/runner.yaml +++ b/helm/robusta/templates/runner.yaml @@ -218,6 +218,10 @@ metadata: labels: app: {{ include "robusta.fullname" . }}-runner target: {{ include "robusta.fullname" . }}-runner +{{- if .Values.runner.service.annotations }} + annotations: +{{ toYaml .Values.runner.service.annotations | indent 4 }} +{{- end }} spec: selector: app: {{ include "robusta.fullname" . }}-runner diff --git a/helm/robusta/values.yaml b/helm/robusta/values.yaml index 8e6acb385..8a28c6bca 100644 --- a/helm/robusta/values.yaml +++ b/helm/robusta/values.yaml @@ -548,17 +548,6 @@ platformPlaybooks: sinks: - "robusta_ui_sink" -- name: "WeeklyPopeyeScan" - triggers: - - on_schedule: - fixed_delay_repeat: - repeat: 1 - seconds_delay: 120 - actions: - - popeye_scan: {} - sinks: - - "robusta_ui_sink" - - name: "WeeklyKRRScan" triggers: - on_schedule: @@ -691,6 +680,10 @@ runner: imagePullSecrets: [] extraVolumes: [] extraVolumeMounts: [] + # k8s service config + service: + # custom service annotations + annotations: {} serviceMonitor: path: /metrics securityContext: diff --git a/playbooks/robusta_playbooks/alerts_integration.py b/playbooks/robusta_playbooks/alerts_integration.py index 7a5cead75..11f8e9007 100644 --- a/playbooks/robusta_playbooks/alerts_integration.py +++ b/playbooks/robusta_playbooks/alerts_integration.py @@ -45,6 +45,7 @@ ) from robusta.core.playbooks.oom_killer_utils import logs_enricher, start_log_enrichment from robusta.core.reporting import FindingSubject +from robusta.core.reporting.base import Link, LinkType from robusta.core.reporting.blocks import TableBlockFormat from robusta.utils.parsing import format_event_templated_string @@ -214,6 +215,9 @@ def default_enricher(alert: PrometheusKubernetesAlert, params: DefaultEnricherPa By default, this enricher is last in the processing order, so it will be added to all alerts, that aren't silenced. """ + if alert.alert.generatorURL: + alert.add_link(Link(url=alert.alert.generatorURL, name="View Graph", type=LinkType.PROMETHEUS_GENERATOR_URL)) + labels = alert.alert.labels alert.add_enrichment( [ @@ -441,7 +445,7 @@ def format_pod_templated_string(pod: RobustaPod, template: Optional[str]) -> Opt subject_type=FindingSubjectType.from_kind("pod"), namespace=pod.metadata.namespace, labels=pod.metadata.labels, - annotations=pod.metadata.annotations + annotations=pod.metadata.annotations, ) return format_event_templated_string(subject, template) @@ -469,6 +473,7 @@ def alert_foreign_logs_enricher(event: PrometheusKubernetesAlert, params: Foreig params.label_selectors = [format_event_templated_string(subject, selector) for selector in params.label_selectors] return foreign_logs_enricher(event, params) + @action def foreign_logs_enricher(event: ExecutionBaseEvent, params: ForeignLogParams): """ diff --git a/playbooks/robusta_playbooks/event_enrichments.py b/playbooks/robusta_playbooks/event_enrichments.py index 546ee0dc0..f751bfba9 100644 --- a/playbooks/robusta_playbooks/event_enrichments.py +++ b/playbooks/robusta_playbooks/event_enrichments.py @@ -31,7 +31,7 @@ SlackAnnotations, StatefulSet, VideoEnricherParams, - VideoLink, + Link, action, get_event_timestamp, get_job_all_pods, @@ -367,7 +367,7 @@ def external_video_enricher(event: ExecutionBaseEvent, params: VideoEnricherPara """ Attaches a video links to the finding """ - event.add_video_link(VideoLink(url=params.url, name=params.name)) + event.add_video_link(Link(url=params.url, name=params.name)) @action diff --git a/playbooks/robusta_playbooks/k8s_resource_enrichments.py b/playbooks/robusta_playbooks/k8s_resource_enrichments.py index da6e8979a..37e3f9d1e 100644 --- a/playbooks/robusta_playbooks/k8s_resource_enrichments.py +++ b/playbooks/robusta_playbooks/k8s_resource_enrichments.py @@ -43,7 +43,9 @@ pod_requests, pod_restarts, ) +from robusta.core.discovery import utils from robusta.core.model.env_vars import RESOURCE_YAML_BLOCK_LIST +from robusta.core.model.pods import ResourceAttributes class RelatedPodParams(ActionParams): @@ -195,13 +197,19 @@ def to_pod_obj(pod: V1Pod, cluster: str, include_raw_data: bool = False) -> Rela ) +def get_attr_str(obj, attr) -> Optional[str]: + ret_attr = getattr(obj, attr, None) + # str(None) = "None" + return str(ret_attr) if ret_attr else None + + def get_pod_containers(pod: V1Pod) -> List[RelatedContainer]: containers: List[RelatedContainer] = [] spec: V1PodSpec = pod.spec for container in spec.containers: container: V1Container = container - requests = PodContainer.get_requests(container) - limits = PodContainer.get_limits(container) + requests = utils.container_resources(container, ResourceAttributes.requests) + limits = utils.container_resources(container, ResourceAttributes.limits) containerStatus: Optional[V1ContainerStatus] = PodContainer.get_status(pod, container.name) currentState: Optional[V1ContainerState] = getattr(containerStatus, "state", None) lastState: Optional[V1ContainerStateTerminated] = getattr(containerStatus, "last_state", None) @@ -222,16 +230,16 @@ def get_pod_containers(pod: V1Pod) -> List[RelatedContainer]: cpuRequest=requests.cpu, memoryLimit=limits.memory, memoryRequest=requests.memory, - restarts=getattr(containerStatus, "restartCount", 0), + restarts=getattr(containerStatus, "restart_count", 0), status=stateStr, statusMessage=getattr(state, "message", None) if state else None, statusReason=getattr(state, "reason", None) if state else None, - created=getattr(state, "startedAt", None), + created=get_attr_str(state, "started_at"), ports=[port.to_dict() for port in container.ports] if container.ports else [], terminatedReason=getattr(terminated_state, "reason", None), - terminatedExitCode=getattr(terminated_state, "exitCode", None), - terminatedStarted=getattr(terminated_state, "startedAt", None), - terminatedFinished=getattr(terminated_state, "finishedAt", None), + terminatedExitCode=getattr(terminated_state, "exit_code", None), + terminatedStarted=get_attr_str(terminated_state, "started_at"), + terminatedFinished=get_attr_str(terminated_state, "finished_at"), ) ) diff --git a/playbooks/robusta_playbooks/prometheus_simulation.py b/playbooks/robusta_playbooks/prometheus_simulation.py index 2be1becf0..2391948f1 100644 --- a/playbooks/robusta_playbooks/prometheus_simulation.py +++ b/playbooks/robusta_playbooks/prometheus_simulation.py @@ -11,6 +11,8 @@ from robusta.utils.error_codes import ActionException, ErrorCodes from robusta.utils.silence_utils import AlertManagerParams, gen_alertmanager_headers, get_alertmanager_url +FALLBACK_PROMETHUES_GENERATOR_URL = "http://localhost:9090/graph?g0.expr=up%7Bjob%3D%22apiserver%22%7D&g0.tab=0&g0.stacked=0&g0.show_exemplars=0&g0.range_input=1h" + def parse_by_operator(pairs: str, operator: str) -> Dict[str, str]: if not pairs: @@ -61,7 +63,7 @@ class PrometheusAlertParams(ActionParams): severity: str = "error" description: str = "simulated prometheus alert" summary: Optional[str] - generator_url = "" + generator_url = FALLBACK_PROMETHUES_GENERATOR_URL runbook_url: Optional[str] = "" fingerprint: Optional[str] = "" labels: Optional[str] = None diff --git a/src/robusta/api/__init__.py b/src/robusta/api/__init__.py index 4cb7da867..99bfe2b75 100644 --- a/src/robusta/api/__init__.py +++ b/src/robusta/api/__init__.py @@ -170,7 +170,7 @@ ScanReportBlock, ScanReportRow, TableBlock, - VideoLink, + Link, ) from robusta.core.reporting.action_requests import ( ActionRequestBody, diff --git a/src/robusta/core/model/base_params.py b/src/robusta/core/model/base_params.py index ab76c82a2..f0ebd1688 100644 --- a/src/robusta/core/model/base_params.py +++ b/src/robusta/core/model/base_params.py @@ -152,11 +152,13 @@ class HolmesIssueChatParamsContext(BaseModel): :var investigation_result: HolmesInvestigationResult object that contains investigation saved to Evidence table by frontend for the issue. :var issue_type: aggregation key of the issue :var robusta_issue_id: id of the issue + :var labels: labels from the issue """ investigation_result: HolmesInvestigationResult issue_type: str robusta_issue_id: Optional[str] = None + labels: Optional[Dict[str, str]] = None # will be deprecated later alongside with holmes_conversation action diff --git a/src/robusta/core/model/events.py b/src/robusta/core/model/events.py index e78739493..9ec060e80 100644 --- a/src/robusta/core/model/events.py +++ b/src/robusta/core/model/events.py @@ -17,7 +17,8 @@ FindingSource, FindingSubject, FindingSubjectType, - VideoLink, + Link, + LinkType, ) from robusta.core.sinks import SinkBase from robusta.integrations.scheduled.playbook_scheduler import PlaybooksScheduler @@ -95,10 +96,15 @@ def __prepare_sinks_findings(self): sink_finding.id = finding_id # share the same finding id between different sinks self.sink_findings[sink].append(sink_finding) - def add_video_link(self, video_link: VideoLink): + def add_link(self, link: Link, suppress_warning: bool = False) -> None: self.__prepare_sinks_findings() for sink in self.named_sinks: - self.sink_findings[sink][0].add_video_link(video_link, True) + self.sink_findings[sink][0].add_link(link, suppress_warning) + + def add_video_link(self, video_link: Link) -> None: + # For backward compatability + video_link.type = LinkType.VIDEO + self.add_link(video_link, True) def emit_event(self, event_name: str, **kwargs): """Publish an event to the pubsub. It will be processed by the sinks during the execution of the playbook.""" diff --git a/src/robusta/core/model/services.py b/src/robusta/core/model/services.py index 7c579fa4e..14a439c93 100644 --- a/src/robusta/core/model/services.py +++ b/src/robusta/core/model/services.py @@ -22,7 +22,7 @@ def __eq__(self, other): class ContainerInfo(BaseModel): name: str - image: str + image: Optional[str] env: List[EnvVar] resources: Resources ports: List[int] = [] diff --git a/src/robusta/core/playbooks/internal/ai_integration.py b/src/robusta/core/playbooks/internal/ai_integration.py index 1b05504a6..4c580b266 100644 --- a/src/robusta/core/playbooks/internal/ai_integration.py +++ b/src/robusta/core/playbooks/internal/ai_integration.py @@ -50,6 +50,7 @@ def ask_holmes(event: ExecutionBaseEvent, params: AIInvestigateParams): subject = params.resource.dict() if params.resource else {} try: + params.ask = add_labels_to_ask(params) holmes_req = HolmesRequest( source=params.context.get("source", "unknown source") if params.context else "unknown source", title=investigation__title, @@ -162,7 +163,14 @@ def holmes_workload_health(event: ExecutionBaseEvent, params: HolmesWorkloadHeal def build_conversation_title(params: HolmesConversationParams) -> str: - return f"{params.resource}, {params.ask} for issue {params.context.robusta_issue_id}" + return f"{params.resource}, {params.ask} for issue '{params.context.robusta_issue_id}'" + + +def add_labels_to_ask(params: HolmesConversationParams) -> str: + label_string = f"the alert has the following labels: {params.context.get('labels')}" if params.context.get("labels") else "" + ask = f"{params.ask}, {label_string}" if label_string else params.ask + logging.debug(f"holmes ask query: {ask}") + return ask # old version of holmes conversation API diff --git a/src/robusta/core/playbooks/playbooks_event_handler_impl.py b/src/robusta/core/playbooks/playbooks_event_handler_impl.py index 2996fae4b..412186d7e 100644 --- a/src/robusta/core/playbooks/playbooks_event_handler_impl.py +++ b/src/robusta/core/playbooks/playbooks_event_handler_impl.py @@ -249,7 +249,7 @@ def __run_playbook_actions( else f"Action Exception {e.type} while processing {action.action_name} {to_safe_str(action_params)}" ) logging.error(msg) - execution_event.response = self.__error_resp(e.type, e.code, log=False) + execution_event.response = self.__error_resp(msg, e.code, log=False) playbooks_errors_count.labels(source).inc() except PrometheusNotFound as e: logging.error(str(e)) @@ -311,14 +311,19 @@ def __handle_findings(self, execution_event: ExecutionBaseEvent): # only write the finding if is matching against the sink matchers if sink.accepts(finding): - # create deep copy, so that iterating on one sink enrichments won't affect the others - # Each sink has a different findings, but enrichments are shared - finding_copy = copy.deepcopy(finding) - sink.write_finding(finding_copy, self.registry.get_sinks().platform_enabled) - - sink_info = sinks_info[sink_name] - sink_info.type = sink.__class__.__name__ - sink_info.findings_count += 1 + try: + # create deep copy, so that iterating on one sink enrichments won't affect the others + # Each sink has a different findings, but enrichments are shared + finding_copy = copy.deepcopy(finding) + sink.write_finding(finding_copy, self.registry.get_sinks().platform_enabled) + + sink_info = sinks_info[sink_name] + sink_info.type = sink.__class__.__name__ + sink_info.findings_count += 1 + except Exception: # if we have an error, we should still respect stop + logging.exception( + f"Failed to send finding {finding.aggregation_key} to sink {sink.sink_name}" + ) if sink.params.stop: return diff --git a/src/robusta/core/reporting/__init__.py b/src/robusta/core/reporting/__init__.py index 97116de99..8afaedb71 100644 --- a/src/robusta/core/reporting/__init__.py +++ b/src/robusta/core/reporting/__init__.py @@ -9,7 +9,7 @@ FindingStatus, FindingSubject, FindingSubjectType, - VideoLink, + Link, ) from robusta.core.reporting.blocks import ( CallbackBlock, @@ -39,7 +39,7 @@ "Emojis", "FindingSeverity", "FindingStatus", - "VideoLink", + "Link", "FindingSource", "Enrichment", "Filterable", diff --git a/src/robusta/core/reporting/base.py b/src/robusta/core/reporting/base.py index a406921da..e371a77ad 100644 --- a/src/robusta/core/reporting/base.py +++ b/src/robusta/core/reporting/base.py @@ -6,6 +6,7 @@ from abc import ABC, abstractmethod from datetime import datetime from enum import Enum +from strenum import StrEnum from typing import Any, Dict, List, Optional, Union from urllib.parse import urlencode @@ -23,11 +24,13 @@ class BaseBlock(BaseModel): html_class: str = None -class Emojis(Enum): +class Emojis(StrEnum): Explain = "πŸ“˜" Recommend = "πŸ› " Alert = "🚨" K8Notification = "πŸ‘€" + Video = "🎬" + Graph = "πŸ“ˆ" class FindingSeverity(Enum): @@ -88,9 +91,25 @@ def to_emoji(self) -> str: return "πŸ”₯" -class VideoLink(BaseModel): +class LinkType(StrEnum): + VIDEO = "video" + PROMETHEUS_GENERATOR_URL = "prometheus_generator_url" + + +class Link(BaseModel): url: str name: str = "See more" + type: Optional[LinkType] = None + + @property + def link_text(self): + if self.type == LinkType.PROMETHEUS_GENERATOR_URL: + return f"{Emojis.Graph} {self.name}" + + if self.type == LinkType.VIDEO: + return f"{Emojis.Video} {self.name}" + + return self.name class EnrichmentType(Enum): @@ -260,7 +279,7 @@ def __init__( self.category = None # TODO fill real category self.subject = subject self.enrichments: List[Enrichment] = [] - self.video_links: List[VideoLink] = [] + self.links: List[Link] = [] self.service = TopServiceResolver.guess_cached_resource(name=subject.name, namespace=subject.namespace) self.service_key = self.service.get_resource_key() if self.service else "" uri_path = f"services/{self.service_key}?tab=grouped" if self.service_key else "graphs" @@ -341,11 +360,15 @@ def add_enrichment( Enrichment(blocks=enrichment_blocks, annotations=annotations, enrichment_type=enrichment_type, title=title) ) - def add_video_link(self, video_link: VideoLink, suppress_warning: bool = False): + def add_link(self, link: Link, suppress_warning: bool = False) -> None: if self.dirty and not suppress_warning: logging.warning("Updating a finding after it was added to the event is not allowed!") + self.links.append(link) - self.video_links.append(video_link) + def add_video_link(self, video_link: Link, suppress_warning: bool = False) -> None: + # For backward compatability + video_link.type = LinkType.VIDEO + self.add_link(video_link, suppress_warning) def __str__(self): return f"title: {self.title} desc: {self.description} severity: {self.severity} sub-name: {self.subject.name} sub-type:{self.subject.subject_type.value} enrich: {self.enrichments}" diff --git a/src/robusta/core/reporting/blocks.py b/src/robusta/core/reporting/blocks.py index dbca6ea4a..9e6c7cf6a 100644 --- a/src/robusta/core/reporting/blocks.py +++ b/src/robusta/core/reporting/blocks.py @@ -528,7 +528,6 @@ class PrometheusBlock(BaseBlock): class Config: arbitrary_types_allowed = True - def __init__( self, data: PrometheusQueryResult, diff --git a/src/robusta/core/reporting/url_helpers.py b/src/robusta/core/reporting/url_helpers.py new file mode 100644 index 000000000..a46dae75a --- /dev/null +++ b/src/robusta/core/reporting/url_helpers.py @@ -0,0 +1,31 @@ +import logging +from typing import Optional +from urllib.parse import ParseResult, parse_qs, urlencode, urlparse + +from robusta.core.model.env_vars import ROBUSTA_UI_DOMAIN + + +PROM_GRAPH_PATH_URL: str = "/graph" +PROM_GRAPH_URL_EXPR_PARAM: str = "g0.expr" + + +def convert_prom_graph_url_to_robusta_metrics_explorer(prom_url: str, cluster_name: str, account_id: str) -> str: + try: + parsed_url: ParseResult = urlparse(prom_url) + if parsed_url.path != PROM_GRAPH_PATH_URL: + logging.warning("Failed to convert to robusta metric explorer url, url: %s not seems to be graph url", prom_url) + return prom_url + + query_string = parse_qs(parsed_url.query) + expr_params: Optional[list[str]] = query_string.get(PROM_GRAPH_URL_EXPR_PARAM) + if not expr_params: + logging.warning("Failed to get expr params, url: %s not seems to be graph url", prom_url) + return prom_url + + expr = expr_params[0] + params: dict[str, str] = {"query": expr, "cluster": cluster_name, "account": account_id} + robusta_metrics_url: str = f"{ROBUSTA_UI_DOMAIN}/metrics-explorer?{urlencode(params)}" + return robusta_metrics_url + except Exception: + logging.warning('Failed to convert prom url: %s to robusta url', prom_url, exc_info=True) + return prom_url diff --git a/src/robusta/core/sinks/common/html_tools.py b/src/robusta/core/sinks/common/html_tools.py index a4cd98540..c4bbe18f8 100644 --- a/src/robusta/core/sinks/common/html_tools.py +++ b/src/robusta/core/sinks/common/html_tools.py @@ -2,9 +2,10 @@ Some base code for handling HTML-outputting sinks. Currently used by the mail and servicenow sinks. """ -from typing import List -from robusta.core.reporting.base import BaseBlock, Finding +from typing import List, Optional + +from robusta.core.reporting.base import BaseBlock, Emojis, Finding, LinkType from robusta.core.reporting.blocks import LinksBlock, LinkProp from robusta.core.reporting.blocks import FileBlock from robusta.core.sinks.transformer import Transformer @@ -88,21 +89,28 @@ def get_css(self): } """ - def create_links(self, finding: Finding, html_class: str): - links: List[LinkProp] = [LinkProp( - text="Investigate πŸ”Ž", - url=finding.get_investigate_uri(self.account_id, self.cluster_name), - )] - - if finding.add_silence_url: + def create_links(self, finding: Finding, html_class: str, platform_enabled: bool) -> Optional[LinksBlock]: + links: List[LinkProp] = [] + if platform_enabled: links.append( LinkProp( - text="Configure Silences πŸ”•", - url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + text="Investigate πŸ”Ž", + url=finding.get_investigate_uri(self.account_id, self.cluster_name), ) ) - for video_link in finding.video_links: - links.append(LinkProp(text=f"{video_link.name} 🎬", url=video_link.url)) + if finding.add_silence_url: + links.append( + LinkProp( + text="Configure Silences πŸ”•", + url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + ) + ) + + for link in finding.links: + links.append(LinkProp(text=link.link_text, url=link.url)) - return with_attr(LinksBlock(links=links), "html_class", html_class) + if links: + return with_attr(LinksBlock(links=links), "html_class", html_class) + + return None diff --git a/src/robusta/core/sinks/incidentio/__init__.py b/src/robusta/core/sinks/incidentio/__init__.py new file mode 100644 index 000000000..50c37e8fd --- /dev/null +++ b/src/robusta/core/sinks/incidentio/__init__.py @@ -0,0 +1,2 @@ +from robusta.core.sinks.incidentio.incidentio_sink import IncidentioSink +from robusta.core.sinks.incidentio.incidentio_sink_params import IncidentioSinkConfigWrapper, IncidentioSinkParams \ No newline at end of file diff --git a/src/robusta/core/sinks/incidentio/incidentio_api.py b/src/robusta/core/sinks/incidentio/incidentio_api.py new file mode 100644 index 000000000..4445ba258 --- /dev/null +++ b/src/robusta/core/sinks/incidentio/incidentio_api.py @@ -0,0 +1,21 @@ +from urllib.parse import urljoin + +class AlertEventsApi: + """ + Class to interact with the incident.io alert events API. + https://api-docs.incident.io/tag/Alert-Events-V2 + """ + + # API Endpoint + _endpoint = 'alert_events/http' + + def __init__ (self, base_url: str, source_config_id: str): + self.base_url = base_url + self.source_config_id = source_config_id + + def build_url(self) -> str: + """ + Build the full URL for the change_events API. + """ + return urljoin(self.base_url, f'{self._endpoint}/{self.source_config_id}') + \ No newline at end of file diff --git a/src/robusta/core/sinks/incidentio/incidentio_client.py b/src/robusta/core/sinks/incidentio/incidentio_client.py new file mode 100644 index 000000000..b57b6328c --- /dev/null +++ b/src/robusta/core/sinks/incidentio/incidentio_client.py @@ -0,0 +1,18 @@ +import requests +import json + +class IncidentIoClient: + def __init__(self, base_url: str, token: str): + self.base_url = base_url + self.headers = { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + def request(self, method: str, url: str, payload: dict) -> requests.Response: + """ + Perform an HTTP request to the Incident.io API. + """ + response = requests.request(method, url, headers=self.headers, data=json.dumps(payload)) + + return response diff --git a/src/robusta/core/sinks/incidentio/incidentio_sink.py b/src/robusta/core/sinks/incidentio/incidentio_sink.py new file mode 100644 index 000000000..ce5c6620b --- /dev/null +++ b/src/robusta/core/sinks/incidentio/incidentio_sink.py @@ -0,0 +1,106 @@ +import logging +from typing import Optional, Dict, List, Any +from robusta.core.sinks.incidentio.incidentio_client import IncidentIoClient +from robusta.core.sinks.incidentio.incidentio_sink_params import IncidentioSinkParams, IncidentioSinkConfigWrapper +from robusta.core.sinks.incidentio.incidentio_api import AlertEventsApi +from robusta.core.sinks.sink_base import SinkBase + +from robusta.core.reporting.base import BaseBlock, Finding, FindingSeverity, Enrichment, Link, LinkType +from robusta.core.reporting.blocks import ( + HeaderBlock, + JsonBlock, + LinksBlock, + ListBlock, + MarkdownBlock, + TableBlock, + KubernetesDiffBlock, +) + + +class IncidentioSink(SinkBase): + params: IncidentioSinkParams + + def __init__(self, sink_config: IncidentioSinkConfigWrapper, registry): + super().__init__(sink_config.incidentio_sink, registry) + self.source_config_id = sink_config.incidentio_sink.source_config_id + self.client = IncidentIoClient( + base_url=sink_config.incidentio_sink.base_url, + token=sink_config.incidentio_sink.token + ) + + @staticmethod + def __to_incidentio_status_type(title: str) -> str: + # Map finding title to incident.io status + if title.startswith("[RESOLVED]"): + return "resolved" + return "firing" + + + def __send_event_to_incidentio(self, finding: Finding, platform_enabled: bool) -> dict: + metadata: Dict[str, Any] = {} + links: List[Dict[str, str]] = [] + + # Add Robusta links if platform is enabled + if platform_enabled: + links.append( + { + "text": "πŸ”Ž Investigate in Robusta", + "href": finding.get_investigate_uri(self.account_id, self.cluster_name), + } + ) + + # Collect metadata + metadata["resource"] = finding.subject.name + metadata["namespace"] = finding.subject.namespace + metadata["cluster"] = self.cluster_name + metadata["severity"] = finding.severity.name + metadata["description"] = finding.description or "" + metadata["source"] = finding.source.name + metadata["fingerprint_id"] = finding.fingerprint + + # Convert blocks to metadata + for enrichment in finding.enrichments: + for block in enrichment.blocks: + text = self.__to_unformatted_text(block) + if text: + metadata["additional_info"] = metadata.get("additional_info", "") + text + "\n" + + return { + "deduplication_key": finding.fingerprint, + "title": finding.title, + "description": finding.description or "No description provided.", + "status": self.__to_incidentio_status_type(finding.title), + "metadata": metadata, + "source_url": finding.get_investigate_uri(self.account_id, self.cluster_name), + "links": links, + } + + def write_finding(self, finding: Finding, platform_enabled: bool) -> None: + payload = self.__send_event_to_incidentio(finding, platform_enabled) + + response = self.client.request( + "POST", + AlertEventsApi(self.client.base_url, self.source_config_id).build_url(), + payload + ) + + if not response.ok: + logging.error( + f"Error sending alert to Incident.io: {response.status_code}, {response.text}" + ) + + @staticmethod + def __to_unformatted_text(block: BaseBlock) -> Optional[str]: + if isinstance(block, HeaderBlock): + return block.text + elif isinstance(block, TableBlock): + return block.to_table_string() + elif isinstance(block, ListBlock): + return "\n".join(block.items) + elif isinstance(block, MarkdownBlock): + return block.text + elif isinstance(block, JsonBlock): + return block.json_str + elif isinstance(block, KubernetesDiffBlock): + return "\n".join(diff.formatted_path for diff in block.diffs) + return None diff --git a/src/robusta/core/sinks/incidentio/incidentio_sink_params.py b/src/robusta/core/sinks/incidentio/incidentio_sink_params.py new file mode 100644 index 000000000..89e4cd36f --- /dev/null +++ b/src/robusta/core/sinks/incidentio/incidentio_sink_params.py @@ -0,0 +1,44 @@ +import re +from typing import Optional +from urllib.parse import urlparse +from robusta.core.playbooks.playbook_utils import get_env_replacement + +from pydantic import validator + +from robusta.core.sinks.sink_base_params import SinkBaseParams +from robusta.core.sinks.sink_config import SinkConfigBase + +class IncidentioSinkParams(SinkBaseParams): + base_url: Optional[str] = "https://api.incident.io/v2/" + token: str + source_config_id: str + + @classmethod + def _get_sink_type(cls): + return "incidentio" + + @validator("base_url") + def validate_base_url(cls, base_url): + parsed_url = urlparse(base_url) + if not parsed_url.scheme or not parsed_url.netloc: + raise ValueError(f"Invalid base_url: {base_url}. It must include a scheme and netloc (e.g., https://api.incident.io).") + return base_url + + @validator("source_config_id") + def validate_source_config_id(cls, source_config_id): + """ + Ensures source_config_id matches the expected format. + """ + pattern = r"^[A-Z0-9]{26}$" + source_config_id = get_env_replacement(source_config_id) + if not re.match(pattern, source_config_id): + raise ValueError( + f"Invalid source_config_id: {source_config_id}. It must be a 26-character string of uppercase letters and digits." + ) + return source_config_id + +class IncidentioSinkConfigWrapper(SinkConfigBase): + incidentio_sink: IncidentioSinkParams + + def get_params(self) -> SinkBaseParams: + return self.incidentio_sink \ No newline at end of file diff --git a/src/robusta/core/sinks/msteams/msteams_sink.py b/src/robusta/core/sinks/msteams/msteams_sink.py index 423691ad4..f96b7f6b8 100644 --- a/src/robusta/core/sinks/msteams/msteams_sink.py +++ b/src/robusta/core/sinks/msteams/msteams_sink.py @@ -9,8 +9,15 @@ def __init__(self, sink_config: MsTeamsSinkConfigWrapper, registry): super().__init__(sink_config.ms_teams_sink, registry) self.webhook_url = sink_config.ms_teams_sink.webhook_url self.webhook_override = sink_config.ms_teams_sink.webhook_override + self.sink_config = sink_config.ms_teams_sink def write_finding(self, finding: Finding, platform_enabled: bool): MsTeamsSender.send_finding_to_ms_teams( - self.webhook_url, finding, platform_enabled, self.cluster_name, self.account_id, self.webhook_override + self.webhook_url, + finding, + platform_enabled, + self.cluster_name, + self.account_id, + self.webhook_override, + self.sink_config.prefer_redirect_to_platform, ) diff --git a/src/robusta/core/sinks/opsgenie/opsgenie_sink.py b/src/robusta/core/sinks/opsgenie/opsgenie_sink.py index 20d1cb460..b809cb192 100644 --- a/src/robusta/core/sinks/opsgenie/opsgenie_sink.py +++ b/src/robusta/core/sinks/opsgenie/opsgenie_sink.py @@ -78,19 +78,25 @@ def write_finding(self, finding: Finding, platform_enabled: bool): self.__open_alert(finding, platform_enabled) def __to_description(self, finding: Finding, platform_enabled: bool) -> str: - description = "" + actions_block: list[str] = [] if platform_enabled: - description = ( + actions_block.append( f'πŸ”Ž Investigate' ) if finding.add_silence_url: - description = f'{description} πŸ”• Silence' + actions_block.append( + f'πŸ”• Silence' + ) - for video_link in finding.video_links: - description = f'{description} 🎬 {video_link.name}' - description = f"{description}\n" + for link in finding.links: + actions_block.append(f'{link.link_text}') - return f"{description}{self.__enrichments_as_text(finding.enrichments)}" + if actions_block: + actions = f"{' '.join(actions_block)}\n" + else: + actions = "" + + return f"{actions}{self.__enrichments_as_text(finding.enrichments)}" def __to_details(self, finding: Finding) -> dict: details = { diff --git a/src/robusta/core/sinks/pagerduty/pagerduty_sink.py b/src/robusta/core/sinks/pagerduty/pagerduty_sink.py index 953ff5c49..e93f0f354 100644 --- a/src/robusta/core/sinks/pagerduty/pagerduty_sink.py +++ b/src/robusta/core/sinks/pagerduty/pagerduty_sink.py @@ -4,17 +4,19 @@ import requests from robusta.core.model.k8s_operation_type import K8sOperationType -from robusta.core.reporting.base import BaseBlock, Finding, FindingSeverity, Enrichment +from robusta.core.reporting.base import BaseBlock, Finding, FindingSeverity, Enrichment, Link, LinkType from robusta.core.reporting.blocks import ( HeaderBlock, JsonBlock, KubernetesDiffBlock, + LinksBlock, ListBlock, MarkdownBlock, TableBlock, ) from robusta.core.reporting.consts import FindingAggregationKey -from robusta.core.sinks.pagerduty.pagerduty_sink_params import PagerdutyConfigWrapper +from robusta.core.reporting.url_helpers import convert_prom_graph_url_to_robusta_metrics_explorer +from robusta.core.sinks.pagerduty.pagerduty_sink_params import PagerdutyConfigWrapper, PagerdutySinkParams from robusta.core.sinks.sink_base import SinkBase @@ -24,6 +26,7 @@ def __init__(self, sink_config: PagerdutyConfigWrapper, registry): self.events_url = "https://events.pagerduty.com/v2/enqueue/" self.change_url = "https://events.pagerduty.com/v2/change/enqueue" self.api_key = sink_config.pagerduty_sink.api_key + self.sink_config: PagerdutySinkParams = sink_config.pagerduty_sink @staticmethod def __to_pagerduty_severity_type(severity: FindingSeverity): @@ -57,15 +60,16 @@ def __send_changes_to_pagerduty(self, finding: Finding, platform_enabled: bool): custom_details: dict = {} links = [] if platform_enabled: - links.append({ - "text": "πŸ”‚ See change history in Robusta", - "href": finding.get_investigate_uri(self.account_id, self.cluster_name) - }) + links.append( + { + "text": "πŸ”‚ See change history in Robusta", + "href": finding.get_investigate_uri(self.account_id, self.cluster_name), + } + ) else: - links.append({ - "text": "πŸ”‚ Enable Robusta UI to see change history", - "href": "https://bit.ly/robusta-ui-pager-duty" - }) + links.append( + {"text": "πŸ”‚ Enable Robusta UI to see change history", "href": "https://bit.ly/robusta-ui-pager-duty"} + ) source = self.cluster_name @@ -114,9 +118,9 @@ def __send_changes_to_pagerduty(self, finding: Finding, platform_enabled: bool): "summary": summary, "timestamp": timestamp, "source": source, - "custom_details": custom_details + "custom_details": custom_details, }, - "links": links + "links": links, } headers = {"Content-Type": "application/json"} @@ -126,33 +130,52 @@ def __send_changes_to_pagerduty(self, finding: Finding, platform_enabled: bool): f"Error sending message to PagerDuty: {response.status_code}, {response.reason}, {response.text}" ) - @staticmethod def __send_events_to_pagerduty(self, finding: Finding, platform_enabled: bool): custom_details: dict = {} - links = [] + links: list[dict[str, str]] = [] + if platform_enabled: - links.append({ - "text": "πŸ”Ž Investigate in Robusta", - "href": finding.get_investigate_uri(self.account_id, self.cluster_name) - }) + links.append( + { + "text": "πŸ”Ž Investigate in Robusta", + "href": finding.get_investigate_uri(self.account_id, self.cluster_name), + } + ) if finding.add_silence_url: - links.append({ - "text": "πŸ”• Create Prometheus Silence", - "href": finding.get_prometheus_silence_url(self.account_id, self.cluster_name) - }) + links.append( + { + "text": "πŸ”• Create Prometheus Silence", + "href": finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + } + ) else: - links.append({ - "text": "πŸ”Ž Enable Robusta UI to investigate", - "href": "https://bit.ly/robusta-ui-pager-duty" - }) + links.append( + {"text": "πŸ”Ž Enable Robusta UI to investigate", "href": "https://bit.ly/robusta-ui-pager-duty"} + ) if finding.add_silence_url: - links.append({ - "text": "πŸ”• Enable Robusta UI to silence alerts", - "href": "https://bit.ly/robusta-ui-pager-duty" - }) + links.append( + {"text": "πŸ”• Enable Robusta UI to silence alerts", "href": "https://bit.ly/robusta-ui-pager-duty"} + ) + + prom_generator_link: Optional[Link] = next( + filter(lambda link: link.type == LinkType.PROMETHEUS_GENERATOR_URL, finding.links), None + ) + if prom_generator_link: + link_url: str = prom_generator_link.url + if platform_enabled and self.sink_config.prefer_redirect_to_platform: + link_url = convert_prom_graph_url_to_robusta_metrics_explorer( + prom_generator_link.url, self.cluster_name, self.account_id + ) + + links.append( + { + "text": prom_generator_link.link_text, + "href": link_url, + } + ) # custom fields that don't have an inherent meaning in PagerDuty itself: custom_details["Resource"] = finding.subject.name @@ -163,9 +186,9 @@ def __send_events_to_pagerduty(self, finding: Finding, platform_enabled: bool): custom_details["Severity"] = PagerdutySink.__to_pagerduty_severity_type(finding.severity).upper() custom_details["Fingerprint ID"] = finding.fingerprint custom_details["Description"] = finding.description - custom_details[ - "Caption" - ] = f"{finding.severity.to_emoji()} {PagerdutySink.__to_pagerduty_severity_type(finding.severity)} - {finding.title}" + custom_details["Caption"] = ( + f"{finding.severity.to_emoji()} {PagerdutySink.__to_pagerduty_severity_type(finding.severity)} - {finding.title}" + ) message_lines = "" if finding.description: @@ -173,6 +196,16 @@ def __send_events_to_pagerduty(self, finding: Finding, platform_enabled: bool): for enrichment in finding.enrichments: for block in enrichment.blocks: + if isinstance(block, LinksBlock): + for link in block.links: + links.append( + { + "text": link.text, + "href": link.url, + } + ) + continue + text = self.__to_unformatted_text_for_alerts(block) if not text: continue @@ -208,7 +241,7 @@ def write_finding(self, finding: Finding, platform_enabled: bool): if finding.aggregation_key == FindingAggregationKey.CONFIGURATION_CHANGE_KUBERNETES_RESOURCE_CHANGE.value: return PagerdutySink.__send_changes_to_pagerduty(self, finding=finding, platform_enabled=platform_enabled) - return PagerdutySink.__send_events_to_pagerduty(self, finding=finding, platform_enabled=platform_enabled) + return self.__send_events_to_pagerduty(finding=finding, platform_enabled=platform_enabled) @staticmethod def __to_unformatted_text_for_alerts(block: BaseBlock) -> str: @@ -234,10 +267,12 @@ def __to_unformatted_text_for_alerts(block: BaseBlock) -> str: @staticmethod def __to_unformatted_text_for_changes(block: KubernetesDiffBlock) -> Optional[List[str]]: - return list(map( - lambda diff: diff.formatted_path, - block.diffs, - )) + return list( + map( + lambda diff: diff.formatted_path, + block.diffs, + ) + ) # fetch the changed values from the block @staticmethod diff --git a/src/robusta/core/sinks/pushover/pushover_sink.py b/src/robusta/core/sinks/pushover/pushover_sink.py index f7a67f2f1..92c962bf1 100644 --- a/src/robusta/core/sinks/pushover/pushover_sink.py +++ b/src/robusta/core/sinks/pushover/pushover_sink.py @@ -89,8 +89,10 @@ def __get_message_text(self, finding: Finding, platform_enabled: bool): if finding.add_silence_url: message_content += f"[{SILENCE_ICON} Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" - for video_link in finding.video_links: - message_content = f"[{VIDEO_ICON} {video_link.name}]({video_link.url})" + for link in finding.links: + message_content += f"[{link.link_text}]({link.url})" + + if message_content: message_content += "\n\n" blocks = [MarkdownBlock(text=f"Source: {self.cluster_name}\n\n")] diff --git a/src/robusta/core/sinks/robusta/dal/model_conversion.py b/src/robusta/core/sinks/robusta/dal/model_conversion.py index d30a8cd3b..e8ba5b91c 100644 --- a/src/robusta/core/sinks/robusta/dal/model_conversion.py +++ b/src/robusta/core/sinks/robusta/dal/model_conversion.py @@ -21,7 +21,7 @@ PrometheusBlock, TableBlock, ) -from robusta.core.reporting.blocks import EmptyFileBlock, GraphBlock +from robusta.core.reporting.blocks import EmptyFileBlock, GraphBlock, LinksBlock from robusta.core.reporting.callbacks import ExternalActionRequestBuilder from robusta.core.reporting.holmes import HolmesChatResultsBlock, HolmesResultsBlock, ToolCallResult from robusta.core.sinks.transformer import Transformer @@ -50,7 +50,7 @@ def to_finding_json(account_id: str, cluster_id: str, finding: Finding): "service_key": finding.service_key, "cluster": cluster_id, "account_id": account_id, - "video_links": [link.dict() for link in finding.video_links], + "video_links": [link.dict() for link in finding.links], # TD: Migrate column in table. "starts_at": datetime_to_db_str(finding.starts_at), "updated_at": datetime_to_db_str(datetime.now()), } @@ -137,7 +137,7 @@ def to_evidence_json( finding_id: uuid.UUID, enrichment: Enrichment, ) -> Dict[Any, Any]: - structured_data = [] + structured_data: list[Any] = [] for block in enrichment.blocks: if isinstance(block, MarkdownBlock): if not block.text: @@ -238,6 +238,9 @@ def to_evidence_json( structured_data.append({"type": "json", "data": block.json_str}) elif isinstance(block, EventsRef): structured_data.append({"type": "events_ref", "data": block.dict()}) + elif isinstance(block, LinksBlock): + links = [link.dict() for link in block.links] + structured_data.append({"type": "list", "data": links}) else: logging.warning(f"cannot convert block of type {type(block)} to robusta platform format block: {block}") continue # no reason to crash the entire report diff --git a/src/robusta/core/sinks/sink_base_params.py b/src/robusta/core/sinks/sink_base_params.py index 9347ef74f..f9c055ecc 100644 --- a/src/robusta/core/sinks/sink_base_params.py +++ b/src/robusta/core/sinks/sink_base_params.py @@ -103,6 +103,7 @@ def validate_notification_mode(cls, values: Dict): class SinkBaseParams(ABC, BaseModel): name: str send_svg: bool = False + prefer_redirect_to_platform = True default: bool = True match: dict = {} scope: Optional[ScopeParams] diff --git a/src/robusta/core/sinks/sink_factory.py b/src/robusta/core/sinks/sink_factory.py index 5d1b49e42..dbbc5829f 100644 --- a/src/robusta/core/sinks/sink_factory.py +++ b/src/robusta/core/sinks/sink_factory.py @@ -29,6 +29,8 @@ from robusta.core.sinks.yamessenger import YaMessengerSink, YaMessengerSinkConfigWrapper from robusta.core.sinks.pushover import PushoverSink, PushoverSinkConfigWrapper from robusta.core.sinks.zulip import ZulipSink, ZulipSinkConfigWrapper +from robusta.core.sinks.incidentio.incidentio_sink import IncidentioSink +from robusta.core.sinks.incidentio.incidentio_sink_params import IncidentioSinkConfigWrapper class SinkFactory: __sink_config_mapping: Dict[Type[SinkConfigBase], Type[SinkBase]] = { @@ -53,7 +55,8 @@ class SinkFactory: PushoverSinkConfigWrapper: PushoverSink, GoogleChatSinkConfigWrapper: GoogleChatSink, ServiceNowSinkConfigWrapper: ServiceNowSink, - ZulipSinkConfigWrapper: ZulipSink + ZulipSinkConfigWrapper: ZulipSink, + IncidentioSinkConfigWrapper: IncidentioSink } @classmethod diff --git a/src/robusta/core/sinks/telegram/telegram_sink.py b/src/robusta/core/sinks/telegram/telegram_sink.py index 7e26f36bf..0a4358fa6 100644 --- a/src/robusta/core/sinks/telegram/telegram_sink.py +++ b/src/robusta/core/sinks/telegram/telegram_sink.py @@ -24,8 +24,9 @@ class TelegramSink(SinkBase): def __init__(self, sink_config: TelegramSinkConfigWrapper, registry): super().__init__(sink_config.telegram_sink, registry) - self.client = TelegramClient(sink_config.telegram_sink.chat_id, sink_config.telegram_sink.thread_id, - sink_config.telegram_sink.bot_token) + self.client = TelegramClient( + sink_config.telegram_sink.chat_id, sink_config.telegram_sink.thread_id, sink_config.telegram_sink.bot_token + ) self.send_files = sink_config.telegram_sink.send_files def write_finding(self, finding: Finding, platform_enabled: bool): @@ -52,16 +53,9 @@ def __get_message_text(self, finding: Finding, platform_enabled: bool): message_content = self.__build_telegram_title(title, status, finding.severity, finding.add_silence_url) - if platform_enabled: - message_content += ( - f"[{INVESTIGATE_ICON} Investigate]({finding.get_investigate_uri(self.account_id, self.cluster_name)}) " - ) - if finding.add_silence_url: - message_content += f"[{SILENCE_ICON} Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" - - for video_link in finding.video_links: - message_content = f"[{VIDEO_ICON} {video_link.name}]({video_link.url})" - message_content += "\n\n" + actions_content: str = self._get_actions_block(finding, platform_enabled) + if actions_content: + message_content += actions_content blocks = [MarkdownBlock(text=f"*Source:* `{self.cluster_name}`\n\n")] @@ -80,14 +74,32 @@ def __get_message_text(self, finding: Finding, platform_enabled: bool): return message_content + def _get_actions_block(self, finding: Finding, platform_enabled: bool): + actions_content = "" + if platform_enabled: + actions_content += ( + f"[{INVESTIGATE_ICON} Investigate]({finding.get_investigate_uri(self.account_id, self.cluster_name)}) " + ) + if finding.add_silence_url: + actions_content += f"[{SILENCE_ICON} Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" + + for link in finding.links: + actions_content = f"[{link.link_text}]({link.url})" + + if actions_content: + actions_content += "\n\n" + + return actions_content + @classmethod def __is_telegram_text_block(cls, block: BaseBlock) -> bool: # enrichments text tables are too big for mobile device return not (isinstance(block, FileBlock) or isinstance(block, TableBlock)) @classmethod - def __build_telegram_title(cls, title: str, status: FindingStatus, severity: FindingSeverity, - add_silence_url: bool) -> str: + def __build_telegram_title( + cls, title: str, status: FindingStatus, severity: FindingSeverity, add_silence_url: bool + ) -> str: icon = SEVERITY_EMOJI_MAP.get(severity, "") status_str: str = f"{status.to_emoji()} {status.name.lower()} - " if add_silence_url else "" return f"{status_str}{icon} {severity.name} - *{title}*\n\n" diff --git a/src/robusta/core/sinks/victorops/victorops_sink.py b/src/robusta/core/sinks/victorops/victorops_sink.py index 35c9dc06c..c7c3881c3 100644 --- a/src/robusta/core/sinks/victorops/victorops_sink.py +++ b/src/robusta/core/sinks/victorops/victorops_sink.py @@ -29,8 +29,8 @@ def write_finding(self, finding: Finding, platform_enabled: bool): self.account_id, self.cluster_name ) - for video_link in finding.video_links: - json_dict[f"vo_annotate.u.🎬 {video_link.name}"] = video_link.url + for link in finding.links: + json_dict[f"vo_annotate.u.🎬 {link.name}"] = link.url # custom fields json_dict["Resource"] = finding.subject.name diff --git a/src/robusta/core/sinks/webhook/webhook_sink.py b/src/robusta/core/sinks/webhook/webhook_sink.py index fa7959962..c56127492 100644 --- a/src/robusta/core/sinks/webhook/webhook_sink.py +++ b/src/robusta/core/sinks/webhook/webhook_sink.py @@ -44,8 +44,8 @@ def __write_text(self, finding: Finding, platform_enabled: bool): f"Silence: {finding.get_prometheus_silence_url(self.account_id, self.cluster_name)}" ) - for video_link in finding.video_links: - message_lines.append(f"{video_link.name}: {video_link.url}") + for link in finding.links: + message_lines.append(f"{link.name}: {link.url}") message_lines.append(f"Source: {self.cluster_name}") message_lines.append(finding.description) diff --git a/src/robusta/core/sinks/yamessenger/yamessenger_sink.py b/src/robusta/core/sinks/yamessenger/yamessenger_sink.py index 72468a885..533fb1c5c 100644 --- a/src/robusta/core/sinks/yamessenger/yamessenger_sink.py +++ b/src/robusta/core/sinks/yamessenger/yamessenger_sink.py @@ -18,7 +18,6 @@ } INVESTIGATE_ICON = "\U0001F50E" SILENCE_ICON = "\U0001F515" -VIDEO_ICON = "\U0001F3AC" class YaMessengerSink(SinkBase): def __init__(self, sink_config: YaMessengerSinkConfigWrapper, registry): @@ -64,9 +63,9 @@ def __get_message_text(self, finding: Finding, platform_enabled: bool): if finding.add_silence_url: message_content += f"[{SILENCE_ICON} Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" - for video_link in finding.video_links: - message_content = f"[{VIDEO_ICON} {video_link.name}]({video_link.url})" - message_content += "\n\n" + for link in finding.links: + message_content += f"[{link.link_text}]({link.url})" + message_content += "\n\n" blocks = [MarkdownBlock(text=f"*Source:* `{self.cluster_name}`\n\n")] diff --git a/src/robusta/integrations/discord/sender.py b/src/robusta/integrations/discord/sender.py index 5f2b746ac..46dc31c2c 100644 --- a/src/robusta/integrations/discord/sender.py +++ b/src/robusta/integrations/discord/sender.py @@ -2,7 +2,7 @@ import re from enum import Enum from itertools import chain -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import requests @@ -112,8 +112,8 @@ def __extract_markdown_name(block: MarkdownBlock): regex = re.compile(r"\*.+\*") match = re.match(regex, block.text) if match: - title = text[match.span()[0]: match.span()[1]] - text = text[match.span()[1]:] + title = text[match.span()[0] : match.span()[1]] + text = text[match.span()[1] :] return title, DiscordSender.__transform_markdown_links(text) or BLANK_CHAR @staticmethod @@ -193,12 +193,12 @@ def __to_discord(self, block: BaseBlock, sink_name: str) -> List[Union[DiscordBl return [] # no reason to crash the entire report def __send_blocks_to_discord( - self, - report_blocks: List[BaseBlock], - title: str, - status: FindingStatus, - severity: FindingSeverity, - msg_color: str, + self, + report_blocks: List[BaseBlock], + title: str, + status: FindingStatus, + severity: FindingSeverity, + msg_color: str, ): # Process attachment blocks file_blocks = add_pngs_for_all_svgs([b for b in report_blocks if isinstance(b, FileBlock)]) @@ -250,20 +250,14 @@ def __send_blocks_to_discord( logging.debug("Message was delivered successfully") def send_finding_to_discord( - self, - finding: Finding, - platform_enabled: bool, + self, + finding: Finding, + platform_enabled: bool, ): blocks: List[BaseBlock] = [] - if platform_enabled: # add link to the robusta ui, if it's configured - actions = f"[:mag_right: Investigate]({finding.get_investigate_uri(self.account_id, self.cluster_name)})" - if finding.add_silence_url: - actions = f"{actions} [:no_bell: Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" - - for video_link in finding.video_links: - actions = f"{actions} [:clapper: {video_link.name}]({video_link.url})" - blocks.append(DiscordDescriptionBlock(description=actions)) - + actions_block = self._get_actions_block(finding, platform_enabled) + if actions_block: + blocks.append(actions_block) blocks.append(DiscordFieldBlock(name="Source", value=f"`{self.cluster_name}`")) # first add finding description block @@ -298,3 +292,22 @@ def send_finding_to_discord( severity=finding.severity, msg_color=msg_color, ) + + def _get_actions_block(self, finding: Finding, platform_enabled: bool) -> Optional[DiscordDescriptionBlock]: + actions: list[str] = [] + if platform_enabled: # add link to the robusta ui, if it's configured + actions.append( + "[:mag_right: Investigate]({finding.get_investigate_uri(self.account_id, self.cluster_name)})" + ) + if finding.add_silence_url: + actions.append( + f"[:no_bell: Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" + ) + + for link in finding.links: + actions.append(f"[:clapper: {link.name}]({link.url})") + + if actions: + return DiscordDescriptionBlock(description=" ".join(actions)) + + return None diff --git a/src/robusta/integrations/google_chat/sender.py b/src/robusta/integrations/google_chat/sender.py index 87f0a5125..1543b15ed 100644 --- a/src/robusta/integrations/google_chat/sender.py +++ b/src/robusta/integrations/google_chat/sender.py @@ -28,8 +28,9 @@ def send_finding(self, finding: Finding, platform_enabled: bool): ) blocks.append(self.__create_finding_header(finding, status)) - if platform_enabled: - blocks.append(self.__create_links(finding)) + links_block = self.__create_links(finding, platform_enabled) + if links_block: + blocks.append(links_block) blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`\n\n")) if finding.description: @@ -120,24 +121,25 @@ def __create_finding_header(self, finding: Finding, status: FindingStatus) -> Ma f"<{finding.get_investigate_uri(self.account_id, self.cluster_name)}|*{title}*>\n\n" ) - def __create_links(self, finding: Finding): + def __create_links(self, finding: Finding, platform_enabled: bool): links: List[LinkProp] = [] - links.append( - LinkProp( - text="Investigate πŸ”Ž", - url=finding.get_investigate_uri(self.account_id, self.cluster_name), - ) - ) - - if finding.add_silence_url: + if platform_enabled: links.append( LinkProp( - text="Configure Silences πŸ”•", - url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + text="Investigate πŸ”Ž", + url=finding.get_investigate_uri(self.account_id, self.cluster_name), ) ) - for video_link in finding.video_links: - links.append(LinkProp(text=f"{video_link.name} 🎬", url=video_link.url)) + if finding.add_silence_url: + links.append( + LinkProp( + text="Configure Silences πŸ”•", + url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + ) + ) + + for link in finding.links: + links.append(LinkProp(text=f"{link.link_text}", url=link.url)) return LinksBlock(links=links) diff --git a/src/robusta/integrations/jira/sender.py b/src/robusta/integrations/jira/sender.py index c5deeb16f..42d26a808 100644 --- a/src/robusta/integrations/jira/sender.py +++ b/src/robusta/integrations/jira/sender.py @@ -204,10 +204,10 @@ def send_finding_to_jira( ) ) - for video_link in finding.video_links: - actions.append( - to_paragraph(f"🎬 {video_link.name}", [{"type": "link", "attrs": {"href": video_link.url}}]) - ) + for link in finding.links: + actions.append( + to_paragraph(f"{link.link_text}", [{"type": "link", "attrs": {"href": link.url}}]) + ) # Add runbook_url to issue markdown if present if finding.subject.annotations.get("runbook_url", None): diff --git a/src/robusta/integrations/mail/sender.py b/src/robusta/integrations/mail/sender.py index 0f42a94d3..85cb0d37d 100644 --- a/src/robusta/integrations/mail/sender.py +++ b/src/robusta/integrations/mail/sender.py @@ -31,8 +31,9 @@ def send_finding(self, finding: Finding, platform_enabled: bool, include_headers if include_headers: blocks.append(self.__create_finding_header(finding, status)) - if platform_enabled: - blocks.append(self.create_links(finding, html_class="header_links")) + links_block = self.create_links(finding, "header_links", platform_enabled) + if links_block: + blocks.append(links_block) blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) diff --git a/src/robusta/integrations/mattermost/sender.py b/src/robusta/integrations/mattermost/sender.py index ce9e0abba..7d6d663c1 100644 --- a/src/robusta/integrations/mattermost/sender.py +++ b/src/robusta/integrations/mattermost/sender.py @@ -49,8 +49,9 @@ def __init__(self, cluster_name: str, account_id: str, client: MattermostClient, self.sink_params = sink_params @classmethod - def __add_mattermost_title(cls, title: str, status: FindingStatus, severity: FindingSeverity, - add_silence_url: bool) -> str: + def __add_mattermost_title( + cls, title: str, status: FindingStatus, severity: FindingSeverity, add_silence_url: bool + ) -> str: icon = SEVERITY_EMOJI_MAP.get(severity, "") status_str: str = f"{status.to_emoji()} {status.name.lower()} - " if add_silence_url else "" return f"{status_str}{icon} {severity.name} - **{title}**" @@ -89,13 +90,13 @@ def __to_mattermost_diff(self, block: KubernetesDiffBlock, sink_name: str) -> st return "\n".join(_blocks) def __send_blocks_to_mattermost( - self, - report_blocks: List[BaseBlock], - title: str, - status: FindingStatus, - severity: FindingSeverity, - msg_color: str, - add_silence_url: bool, + self, + report_blocks: List[BaseBlock], + title: str, + status: FindingStatus, + severity: FindingSeverity, + msg_color: str, + add_silence_url: bool, ): # Process attachment blocks @@ -112,8 +113,9 @@ def __send_blocks_to_mattermost( output_blocks = [] header_block = {} if title: - title = self.__add_mattermost_title(title=title, status=status, severity=severity, - add_silence_url=add_silence_url) + title = self.__add_mattermost_title( + title=title, status=status, severity=severity, add_silence_url=add_silence_url + ) header_block = self.__to_mattermost(HeaderBlock(title), self.sink_params.name) for block in other_blocks: output_blocks.append(self.__to_mattermost(block, self.sink_params.name)) @@ -130,16 +132,31 @@ def __send_blocks_to_mattermost( self.client.post_message(header_block, attachments, file_attachments) - def send_finding_to_mattermost(self, finding: Finding, platform_enabled: bool): - blocks: List[BaseBlock] = [] + def _get_actions_markdown(self, finding: Finding, platform_enabled: bool): + actions: list[str] = [] if platform_enabled: # add link to the robusta ui, if it's configured - actions = f"[:mag_right: Investigate]({finding.get_investigate_uri(self.account_id, self.cluster_name)})" + actions.append( + f"[:mag_right: Investigate]({finding.get_investigate_uri(self.account_id, self.cluster_name)})" + ) if finding.add_silence_url: - actions = f"{actions} [:no_bell: Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" - for video_link in finding.video_links: - actions = f"{actions} [:clapper: {video_link.name}]({video_link.url})" + actions.append( + f"[:no_bell: Silence]({finding.get_prometheus_silence_url(self.account_id, self.cluster_name)})" + ) + + for link in finding.links: + actions.append(f"[:clapper: {link.name}]({link.url})") + + if actions: + return MarkdownBlock(" ".join(actions)) + + return None + + def send_finding_to_mattermost(self, finding: Finding, platform_enabled: bool): + blocks: List[BaseBlock] = [] - blocks.append(MarkdownBlock(actions)) + actions_block = self._get_actions_markdown(finding, platform_enabled) + if actions_block: + blocks.append(actions_block) blocks.append(MarkdownBlock(f"*Source:* `{self.cluster_name}`\n")) diff --git a/src/robusta/integrations/msteams/msteams_msg.py b/src/robusta/integrations/msteams/msteams_msg.py index f169922c0..4095c122c 100644 --- a/src/robusta/integrations/msteams/msteams_msg.py +++ b/src/robusta/integrations/msteams/msteams_msg.py @@ -14,7 +14,8 @@ MarkdownBlock, TableBlock, ) -from robusta.core.reporting.base import FindingStatus +from robusta.core.reporting.base import FindingStatus, LinkType +from robusta.core.reporting.url_helpers import convert_prom_graph_url_to_robusta_metrics_explorer from robusta.integrations.msteams.msteams_adaptive_card_files import MsTeamsAdaptiveCardFiles from robusta.integrations.msteams.msteams_elements.msteams_base import MsTeamsBase from robusta.integrations.msteams.msteams_elements.msteams_card import MsTeamsCard @@ -31,11 +32,12 @@ class MsTeamsMsg: # a safe zone of less then 28K MAX_SIZE_IN_BYTES = 1024 * 20 - def __init__(self, webhook_url: str): + def __init__(self, webhook_url: str, prefer_redirect_to_platform: bool): self.entire_msg: List[MsTeamsBase] = [] self.current_section: List[MsTeamsBase] = [] self.text_file_containers = [] self.webhook_url = webhook_url + self.prefer_redirect_to_platform = prefer_redirect_to_platform def write_title_and_desc(self, platform_enabled: bool, finding: Finding, cluster_name: str, account_id: str): status: FindingStatus = ( @@ -46,14 +48,7 @@ def write_title_and_desc(self, platform_enabled: bool, finding: Finding, cluster block = MsTeamsTextBlock(text=f"{title}", font_size="extraLarge") self.__write_to_entire_msg([block]) - if platform_enabled: # add link to the Robusta ui, if it's configured - silence_url = finding.get_prometheus_silence_url(account_id, cluster_name) - actions = f"[πŸ”Ž Investigate]({finding.get_investigate_uri(account_id, cluster_name)})" - if finding.add_silence_url: - actions = f"{actions} [πŸ”• Silence]({silence_url})" - for video_link in finding.video_links: - actions = f"{actions} [🎬 {video_link.name}]({video_link.url})" - self.__write_to_entire_msg([MsTeamsTextBlock(text=actions)]) + self._add_actions(platform_enabled, finding, cluster_name, account_id) self.__write_to_entire_msg([MsTeamsTextBlock(text=f"**Source:** *{cluster_name}*")]) @@ -61,6 +56,25 @@ def write_title_and_desc(self, platform_enabled: bool, finding: Finding, cluster block = MsTeamsTextBlock(text=finding.description) self.__write_to_entire_msg([block]) + def _add_actions(self, platform_enabled: bool, finding: Finding, cluster_name: str, account_id: str): + actions: list[str] = [] + if platform_enabled: # add link to the Robusta ui, if it's configured + actions.append(f"[πŸ”Ž Investigate]({finding.get_investigate_uri(account_id, cluster_name)})") + + if finding.add_silence_url: + silence_url = finding.get_prometheus_silence_url(account_id, cluster_name) + actions.append(f"[πŸ”• Silence]({silence_url})") + + for link in finding.links: + link_url = link.url + if link.type == LinkType.PROMETHEUS_GENERATOR_URL and self.prefer_redirect_to_platform: + link_url = convert_prom_graph_url_to_robusta_metrics_explorer(link.url, cluster_name, account_id) + action: str = f"[{link.link_text}]({link_url})" + actions.append(action) + + if actions: + self.__write_to_entire_msg([MsTeamsTextBlock(text=" ".join(actions))]) + @classmethod def __build_msteams_title( cls, title: str, status: FindingStatus, severity: FindingSeverity, add_silence_url: bool diff --git a/src/robusta/integrations/msteams/sender.py b/src/robusta/integrations/msteams/sender.py index 1e2db511c..0b98a239f 100644 --- a/src/robusta/integrations/msteams/sender.py +++ b/src/robusta/integrations/msteams/sender.py @@ -58,11 +58,12 @@ def send_finding_to_ms_teams( cluster_name: str, account_id: str, webhook_override: str, + prefer_redirect_to_platform: bool, ): webhook_url = MsTeamsWebhookUrlTransformer.template( webhook_override=webhook_override, default_webhook_url=webhook_url, annotations=finding.subject.annotations ) - msg = MsTeamsMsg(webhook_url) + msg = MsTeamsMsg(webhook_url, prefer_redirect_to_platform) msg.write_title_and_desc(platform_enabled, finding, cluster_name, account_id) for enrichment in finding.enrichments: diff --git a/src/robusta/integrations/rocketchat/sender.py b/src/robusta/integrations/rocketchat/sender.py index 32d2b8a0d..0d53dca5d 100644 --- a/src/robusta/integrations/rocketchat/sender.py +++ b/src/robusta/integrations/rocketchat/sender.py @@ -296,25 +296,26 @@ def __create_finding_header(self, finding: Finding, status: FindingStatus, platf return MarkdownBlock(f"{status_str} {sev.to_emoji()} `{sev.name.lower()}` {title}") - def __create_links(self, finding: Finding): + def __create_links(self, finding: Finding, platform_enabled: bool) -> LinksBlock: links: List[LinkProp] = [] - links.append( - LinkProp( - text="Investigate πŸ”Ž", - url=finding.get_investigate_uri(self.account_id, self.cluster_name), - ) - ) - - if finding.add_silence_url: + if platform_enabled: links.append( LinkProp( - text="Configure Silences πŸ”•", - url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + text="Investigate πŸ”Ž", + url=finding.get_investigate_uri(self.account_id, self.cluster_name), ) ) - for video_link in finding.video_links: - links.append(LinkProp(text=f"{video_link.name} 🎬", url=video_link.url)) + if finding.add_silence_url: + links.append( + LinkProp( + text="Configure Silences πŸ”•", + url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + ) + ) + + for link in finding.links: + links.append(LinkProp(text=f"{link.link_text}", url=link.url)) return LinksBlock(links=links) @@ -335,8 +336,7 @@ def send_finding_to_rocketchat( if finding.title: blocks.append(self.__create_finding_header(finding, status, platform_enabled)) - if platform_enabled: - blocks.append(self.__create_links(finding)) + blocks.append(self.__create_links(finding, platform_enabled)) blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) if finding.description: diff --git a/src/robusta/integrations/servicenow/sender.py b/src/robusta/integrations/servicenow/sender.py index 8eb71ded1..4b5933b9d 100644 --- a/src/robusta/integrations/servicenow/sender.py +++ b/src/robusta/integrations/servicenow/sender.py @@ -92,8 +92,9 @@ def send_finding(self, finding: Finding, platform_enabled: bool): def format_message(self, finding: Finding, platform_enabled: bool) -> Tuple[HTMLTransformer, str]: blocks: List[BaseBlock] = [] - if platform_enabled: - blocks.append(self.create_links(finding, html_class="header_links")) + links_block = self.create_links(finding, "header_links", platform_enabled) + if links_block: + blocks.append(links_block) blocks.append(MarkdownBlock(text=f"*Source:* `{self.cluster_name}`")) if finding.description: diff --git a/src/robusta/integrations/slack/sender.py b/src/robusta/integrations/slack/sender.py index 007e0bea5..94cde1bf3 100644 --- a/src/robusta/integrations/slack/sender.py +++ b/src/robusta/integrations/slack/sender.py @@ -1,3 +1,4 @@ +import copy import logging import ssl import tempfile @@ -12,9 +13,14 @@ from slack_sdk.errors import SlackApiError from robusta.core.model.base_params import AIInvestigateParams, ResourceInfo -from robusta.core.model.env_vars import ADDITIONAL_CERTIFICATE, SLACK_REQUEST_TIMEOUT, HOLMES_ENABLED, SLACK_TABLE_COLUMNS_LIMIT +from robusta.core.model.env_vars import ( + ADDITIONAL_CERTIFICATE, + SLACK_REQUEST_TIMEOUT, + HOLMES_ENABLED, + SLACK_TABLE_COLUMNS_LIMIT, +) from robusta.core.playbooks.internal.ai_integration import ask_holmes -from robusta.core.reporting.base import Emojis, EnrichmentType, Finding, FindingStatus +from robusta.core.reporting.base import Emojis, EnrichmentType, Finding, FindingStatus, LinkType from robusta.core.reporting.blocks import ( BaseBlock, CallbackBlock, @@ -33,6 +39,7 @@ from robusta.core.reporting.callbacks import ExternalActionRequestBuilder from robusta.core.reporting.consts import EnrichmentAnnotation, FindingSource, FindingType, SlackAnnotations from robusta.core.reporting.holmes import HolmesResultsBlock, ToolCallResult +from robusta.core.reporting.url_helpers import convert_prom_graph_url_to_robusta_metrics_explorer from robusta.core.reporting.utils import add_pngs_for_all_svgs from robusta.core.sinks.common import ChannelTransformer from robusta.core.sinks.sink_base import KeyT @@ -302,6 +309,31 @@ def __send_blocks_to_slack( f"error sending message to slack\ne={e}\ntext={message}\nchannel={channel}\nblocks={*output_blocks,}\nattachment_blocks={*attachment_blocks,}" ) + + def __limit_labels_size(self, labels: dict, max_size: int = 1000) -> dict: + # slack can only send 2k tokens in a callback so the labels are limited in size + + low_priority_labels = ["job", "prometheus", "severity", "service"] + current_length = len(str(labels)) + if current_length <= max_size: + return labels + + limited_labels = copy.deepcopy(labels) + + # first remove the low priority labels if needed + for key in low_priority_labels: + if current_length <= max_size: + break + if key in limited_labels: + del limited_labels[key] + current_length = len(str(limited_labels)) + + while current_length > max_size and limited_labels: + limited_labels.pop(next(iter(limited_labels))) + current_length = len(str(limited_labels)) + + return limited_labels + def __create_holmes_callback(self, finding: Finding) -> CallbackBlock: resource = ResourceInfo( name=finding.subject.name if finding.subject.name else "", @@ -315,6 +347,7 @@ def __create_holmes_callback(self, finding: Finding) -> CallbackBlock: "robusta_issue_id": str(finding.id), "issue_type": finding.aggregation_key, "source": finding.source.name, + "labels": self.__limit_labels_size(labels=finding.subject.labels) } return CallbackBlock( @@ -350,26 +383,39 @@ def __create_finding_header( {title}""" ) - def __create_links(self, finding: Finding, include_investigate_link: bool): + def __create_links( + self, + finding: Finding, + platform_enabled: bool, + include_investigate_link: bool, + prefer_redirect_to_platform: bool, + ): links: List[LinkProp] = [] - if include_investigate_link: - links.append( - LinkProp( - text="Investigate πŸ”Ž", - url=finding.get_investigate_uri(self.account_id, self.cluster_name), + if platform_enabled: + if include_investigate_link: + links.append( + LinkProp( + text="Investigate πŸ”Ž", + url=finding.get_investigate_uri(self.account_id, self.cluster_name), + ) ) - ) - if finding.add_silence_url: - links.append( - LinkProp( - text="Configure Silences πŸ”•", - url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + if finding.add_silence_url: + links.append( + LinkProp( + text="Configure Silences πŸ”•", + url=finding.get_prometheus_silence_url(self.account_id, self.cluster_name), + ) ) - ) - for video_link in finding.video_links: - links.append(LinkProp(text=f"{video_link.name} 🎬", url=video_link.url)) + for link in finding.links: + link_url = link.url + if link.type == LinkType.PROMETHEUS_GENERATOR_URL and prefer_redirect_to_platform: + link_url = convert_prom_graph_url_to_robusta_metrics_explorer( + link.url, self.cluster_name, self.account_id + ) + + links.append(LinkProp(text=link.link_text, url=link_url)) return LinksBlock(links=links) @@ -489,8 +535,10 @@ def send_finding_to_slack( if finding.title: blocks.append(self.__create_finding_header(finding, status, platform_enabled, sink_params.investigate_link)) - if platform_enabled: - blocks.append(self.__create_links(finding, sink_params.investigate_link)) + 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: blocks.append(self.__create_holmes_callback(finding)) diff --git a/src/robusta/integrations/zulip/sender.py b/src/robusta/integrations/zulip/sender.py index f95d014a7..4d6431a20 100644 --- a/src/robusta/integrations/zulip/sender.py +++ b/src/robusta/integrations/zulip/sender.py @@ -12,7 +12,7 @@ MarkdownBlock, TableBlock, ) -from robusta.core.reporting.base import BaseBlock, Finding, FindingStatus +from robusta.core.reporting.base import BaseBlock, Finding, FindingStatus, LinkType from robusta.core.reporting.blocks import FileBlock, LinksBlock from robusta.core.reporting.consts import FindingSource from robusta.core.reporting.utils import convert_svg_to_png @@ -138,8 +138,9 @@ def send_finding_to_zulip(self, finding: Finding, sink_params: ZulipSinkParams, if finding.add_silence_url: silence_url = finding.get_prometheus_silence_url(self.account_id, self.cluster_name) message_lines.append(self.__to_zulip_link("πŸ”• Silence", silence_url)) - for video_link in finding.video_links: - message_lines.append(f"🎬 {self.__to_zulip_link(video_link.name, video_link.url)}") + + for link in finding.links: + message_lines.append(f"🎬 {self.__to_zulip_link(link.name, link.url)}") message_lines.append(f"{self.__to_zulip_bold('Source:')} `{self.cluster_name}`") message_lines.append(finding.description) diff --git a/src/robusta/utils/scope.py b/src/robusta/utils/scope.py index 8eac6e2bc..7fb3e2bcf 100644 --- a/src/robusta/utils/scope.py +++ b/src/robusta/utils/scope.py @@ -1,7 +1,7 @@ import logging import re -from abc import abstractmethod, ABC -from typing import Dict, Optional, Union, List +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Union from pydantic import BaseModel, root_validator @@ -51,22 +51,24 @@ def scope_matches(self, scope: ScopeIncludeExcludeParamsT) -> bool: return False return True + def match_attribute(self, attr_name: str, attr_value, attr_matcher: str) -> bool: + if attr_name == "attributes": + return self.scope_match_attributes(attr_matcher, attr_value) + elif attr_name == "namespace_labels": + return self.scope_match_namespace_labels(attr_matcher, attr_value) + elif attr_name in ["labels", "annotations"]: + return self.match_labels_annotations(attr_matcher, attr_value) + elif re.fullmatch(attr_matcher, attr_value): + return True + return False + def scope_attribute_matches(self, attr_name: str, attr_matchers: List[str]) -> bool: data = self.get_data() if attr_name not in data: logging.warning(f'Scope match on non-existent attribute "{attr_name}" ({data=})') return False attr_value = data[attr_name] - for attr_matcher in attr_matchers: - if attr_name == "attributes": - return self.scope_match_attributes(attr_matcher, attr_value) - elif attr_name == "namespace_labels": - return self.scope_match_namespace_labels(attr_matcher, attr_value) - elif attr_name in ["labels", "annotations"]: - return self.match_labels_annotations(attr_matcher, attr_value) - elif re.fullmatch(attr_matcher, attr_value): - return True - return False + return any([self.match_attribute(attr_name, attr_value, matcher) for matcher in attr_matchers]) def scope_match_attributes(self, attr_matcher: str, attr_value: Dict[str, Union[List, Dict]]) -> bool: raise NotImplementedError