From 83906bc58f04b3af572689ddd561efe9444fa005 Mon Sep 17 00:00:00 2001 From: Arik Alon Date: Tue, 12 May 2026 20:20:01 +0300 Subject: [PATCH] fix webhook sink add dynamic room for webex sink make popeye container name fixed --- docs/configuration/sinks/webex.rst | 54 ++++++++++++++ docs/configuration/sinks/webhook.rst | 70 +++++++++++++++++++ playbooks/robusta_playbooks/popeye.py | 2 +- src/robusta/core/sinks/webex/webex_sink.py | 60 +++++++++++++++- .../core/sinks/webex/webex_sink_params.py | 12 ++++ .../core/sinks/webhook/webhook_sink.py | 43 +++++++++++- .../kubernetes/api_client_utils.py | 4 ++ src/robusta/integrations/webex/sender.py | 12 ++-- 8 files changed, 248 insertions(+), 9 deletions(-) diff --git a/docs/configuration/sinks/webex.rst b/docs/configuration/sinks/webex.rst index fb9a0a21f..eb82c73df 100644 --- a/docs/configuration/sinks/webex.rst +++ b/docs/configuration/sinks/webex.rst @@ -64,3 +64,57 @@ Now we're ready to configure the webex sink. room_id: You should now get playbooks results in Webex! + +Dynamic Room Routing +------------------------------------------------ + +You can route alerts to different Webex rooms based on Kubernetes labels or +annotations. The sink supports two override fields, evaluated in order: + +1. ``namespace_room_id_override`` — resolved against the **Namespace** object's labels + and annotations (looked up by the finding's namespace, with TTL caching to avoid + hammering the K8s API). +2. ``room_id_override`` — resolved against the finding's **subject** labels and + annotations (same behavior as Slack's ``channel_override``). + +If neither override produces a room id, ``send_to_default_if_missing`` decides what +happens: + +- ``true`` *(default)* — send to the configured ``room_id``. +- ``false`` — drop the finding silently. + +Both override fields use the same template syntax as Slack: + +- ``cluster_name`` — the Robusta cluster name. +- ``labels.foo`` / ``$labels.foo`` — value of a label. +- ``annotations.bar`` / ``$annotations.bar`` — value of an annotation. +- ``${labels.foo-bar}`` / ``${annotations.kubernetes.io/owner}`` — bracket form + required when the key contains characters other than letters, digits, or underscores + (e.g. ``-``, ``/``, ``.``). +- Composite patterns are allowed: ``"$cluster_name-$labels.team"``. + +Example — route by a label on the namespace, fall back to the default room: + +.. code-block:: yaml + + sinksConfig: + - webex_sink: + name: webex_sink + bot_access_token: + room_id: + namespace_room_id_override: "${labels.webex-room}" + send_to_default_if_missing: true + +Example — route by namespace label first, fall back to a subject label, drop if neither +is present: + +.. code-block:: yaml + + sinksConfig: + - webex_sink: + name: webex_sink + bot_access_token: + room_id: + namespace_room_id_override: "${labels.webex-room}" + room_id_override: "$labels.team" + send_to_default_if_missing: false diff --git a/docs/configuration/sinks/webhook.rst b/docs/configuration/sinks/webhook.rst index 1c796d6f4..0bb9b42ce 100644 --- a/docs/configuration/sinks/webhook.rst +++ b/docs/configuration/sinks/webhook.rst @@ -26,3 +26,73 @@ Save the file and run .. image:: /images/deployment-babysitter-webhook.png :width: 600 :align: center + +Configuration parameters +------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Field + - Default + - Description + * - ``url`` + - *(required)* + - The webhook endpoint to POST to. + * - ``format`` + - ``text`` + - Payload format. ``text`` for a human-readable body, ``json`` for a structured body. + * - ``size_limit`` + - ``4096`` + - Maximum payload size in bytes. Content beyond the limit is truncated. + * - ``authorization`` + - *(none)* + - Optional value sent in the ``Authorization`` request header. + * - ``slack_webhook`` + - ``false`` + - When ``true`` and ``format: json``, posts a Slack-compatible body for use with Slack incoming webhooks. + +JSON payload +------------- + +When ``format: json`` is set, the POST body is a JSON object with the following top-level fields: + +.. code-block:: json + + { + "title": "CrashLoopBackOff", + "description": "Container is crashing repeatedly", + "cluster_name": "prod-eu-west", + "account_id": "abcd-1234", + "severity": "HIGH", + "source": "KUBERNETES_API_SERVER", + "finding_type": "ISSUE", + "aggregation_key": "CrashLoopBackOff", + "failure": true, + "fingerprint": "2c1d...", + "starts_at": "2026-04-30T10:15:00+00:00", + "ends_at": null, + "subject": { + "name": "my-pod", + "kind": "pod", + "namespace": "default", + "node": "node-1", + "container": "main", + "labels": {"app": "demo"}, + "annotations": {"team": "platform"} + }, + "links": [ + {"name": "Runbook", "url": "https://...", "type": null}, + {"name": "Graph", "url": "https://...", "type": "prometheus_generator_url"} + ], + "investigate": "https://platform.robusta.dev/...", + "silence": "https://platform.robusta.dev/silences/create?...", + "enrichments": [ ... ] + } + +``investigate`` and ``silence`` are present only when the Robusta platform is enabled +(``silence`` additionally requires ``add_silence_url`` on the finding). + +If the serialized payload exceeds ``size_limit``, the largest field (``enrichments``) +is dropped first so that core metadata and ``links`` survive truncation. diff --git a/playbooks/robusta_playbooks/popeye.py b/playbooks/robusta_playbooks/popeye.py index 53bcc737a..2aa243b46 100644 --- a/playbooks/robusta_playbooks/popeye.py +++ b/playbooks/robusta_playbooks/popeye.py @@ -159,7 +159,7 @@ def popeye_scan(event: ExecutionBaseEvent, params: PopeyeParams): serviceAccountName=params.service_account_name, containers=[ Container( - name=to_kubernetes_name(IMAGE), + name="popeye-scanner", image=IMAGE, command=[ "/bin/sh", diff --git a/src/robusta/core/sinks/webex/webex_sink.py b/src/robusta/core/sinks/webex/webex_sink.py index 871a4bac9..c6c150832 100644 --- a/src/robusta/core/sinks/webex/webex_sink.py +++ b/src/robusta/core/sinks/webex/webex_sink.py @@ -1,8 +1,22 @@ +import logging +from typing import Dict, Optional, Tuple + from robusta.core.reporting.base import Finding +from robusta.core.sinks.common.channel_transformer import ChannelTransformer from robusta.core.sinks.sink_base import SinkBase from robusta.core.sinks.webex.webex_sink_params import WebexSinkConfigWrapper +from robusta.integrations.kubernetes.api_client_utils import ( + get_namespace_annotations, + get_namespace_labels, +) from robusta.integrations.webex.sender import WebexSender +# Sentinel passed as default_channel to ChannelTransformer.template() so we can detect +# the "any token missing" case from the outside. ChannelTransformer returns the default +# we pass in only when the override is empty (we filter that case ourselves) or when a +# referenced label/annotation key is missing — both of which we treat as unresolved here. +_UNRESOLVED = "__robusta_webex_unresolved__" + class WebexSink(SinkBase): def __init__(self, sink_config: WebexSinkConfigWrapper, registry): @@ -17,4 +31,48 @@ def __init__(self, sink_config: WebexSinkConfigWrapper, registry): ) def write_finding(self, finding: Finding, platform_enabled: bool): - self.sender.send_finding_to_webex(finding, platform_enabled) + room_id = self._resolve_room_id(finding) + if room_id is None: + return + self.sender.send_finding_to_webex(finding, platform_enabled, room_id=room_id) + + def _resolve_room_id(self, finding: Finding) -> Optional[str]: + params = self.params + + if params.namespace_room_id_override and finding.subject.namespace: + ns_labels, ns_annotations = self._get_namespace_metadata(finding.subject.namespace) + resolved = ChannelTransformer.template( + params.namespace_room_id_override, + _UNRESOLVED, + self.cluster_name, + ns_labels, + ns_annotations, + ) + if resolved != _UNRESOLVED: + return resolved + + if params.room_id_override: + resolved = ChannelTransformer.template( + params.room_id_override, + _UNRESOLVED, + self.cluster_name, + finding.subject.labels or {}, + finding.subject.annotations or {}, + ) + if resolved != _UNRESOLVED: + return resolved + + if not params.room_id_override and not params.namespace_room_id_override: + return params.room_id + + return params.room_id if params.send_to_default_if_missing else None + + @staticmethod + def _get_namespace_metadata(namespace: str) -> Tuple[Dict[str, str], Dict[str, str]]: + try: + labels = get_namespace_labels(namespace) or {} + annotations = get_namespace_annotations(namespace) or {} + except KeyError: + logging.debug("namespace %s not found in cache", namespace) + return {}, {} + return labels, annotations diff --git a/src/robusta/core/sinks/webex/webex_sink_params.py b/src/robusta/core/sinks/webex/webex_sink_params.py index 39ea58144..91b7e7a85 100644 --- a/src/robusta/core/sinks/webex/webex_sink_params.py +++ b/src/robusta/core/sinks/webex/webex_sink_params.py @@ -1,3 +1,8 @@ +from typing import Optional + +from pydantic import validator + +from robusta.core.sinks.common.channel_transformer import ChannelTransformer from robusta.core.sinks.sink_base_params import SinkBaseParams from robusta.core.sinks.sink_config import SinkConfigBase @@ -5,11 +10,18 @@ class WebexSinkParams(SinkBaseParams): bot_access_token: str room_id: str + room_id_override: Optional[str] = None + namespace_room_id_override: Optional[str] = None + send_to_default_if_missing: bool = True @classmethod def _get_sink_type(cls): return "webex" + @validator("room_id_override", "namespace_room_id_override") + def validate_overrides(cls, v): + return ChannelTransformer.validate_channel_override(v) + class WebexSinkConfigWrapper(SinkConfigBase): webex_sink: WebexSinkParams diff --git a/src/robusta/core/sinks/webhook/webhook_sink.py b/src/robusta/core/sinks/webhook/webhook_sink.py index c56127492..3d2c2555d 100644 --- a/src/robusta/core/sinks/webhook/webhook_sink.py +++ b/src/robusta/core/sinks/webhook/webhook_sink.py @@ -73,14 +73,53 @@ def __write_text(self, finding: Finding, platform_enabled: bool): logging.exception(f"Webhook request error\n headers: \n{self.headers}") def __write_json(self, finding: Finding, platform_enabled: bool): - finding_dict = json.loads(json.dumps(finding, default=lambda o: getattr(o, '__dict__', str(o)))) + finding_dict = { + "title": finding.title, + "description": finding.description, + "cluster_name": self.cluster_name, + "account_id": self.account_id, + "severity": finding.severity.name, + "source": finding.source.name, + "finding_type": finding.finding_type.name, + "aggregation_key": finding.aggregation_key, + "failure": finding.failure, + "fingerprint": finding.fingerprint, + "starts_at": finding.starts_at.isoformat() if finding.starts_at else None, + "ends_at": finding.ends_at.isoformat() if finding.ends_at else None, + "id": str(finding.id), + "category": finding.category, + "service": json.loads( + json.dumps(finding.service, default=lambda o: getattr(o, '__dict__', str(o))) + ) if finding.service else None, + "service_key": finding.service_key, + "creation_date": finding.creation_date, + "investigate_uri": finding.investigate_uri, + "add_silence_url": finding.add_silence_url, + "subject": { + "name": finding.subject.name, + "kind": finding.subject.subject_type.value, + "namespace": finding.subject.namespace, + "node": finding.subject.node, + "container": finding.subject.container, + "labels": finding.subject.labels, + "annotations": finding.subject.annotations, + }, + "links": [ + {"name": link.name, "url": link.url, "type": link.type.value if link.type else None} + for link in finding.links + ], + } if platform_enabled: finding_dict["investigate"] = finding.get_investigate_uri(self.account_id, self.cluster_name) - if finding.add_silence_url: finding_dict["silence"] = finding.get_prometheus_silence_url(self.account_id, self.cluster_name) + # Enrichments last so they're the first thing dropped if size_limit is exceeded. + finding_dict["enrichments"] = json.loads( + json.dumps(finding.enrichments, default=lambda o: getattr(o, '__dict__', str(o))) + ) + message = {} message_length = 0 diff --git a/src/robusta/integrations/kubernetes/api_client_utils.py b/src/robusta/integrations/kubernetes/api_client_utils.py index 4b998041c..c7e5003df 100644 --- a/src/robusta/integrations/kubernetes/api_client_utils.py +++ b/src/robusta/integrations/kubernetes/api_client_utils.py @@ -286,3 +286,7 @@ def get_all_namespace_data(): def get_namespace_labels(namespace_name: str) -> Dict[str, str]: return get_all_namespace_data()[namespace_name].labels + + +def get_namespace_annotations(namespace_name: str) -> Dict[str, str]: + return get_all_namespace_data()[namespace_name].annotations diff --git a/src/robusta/integrations/webex/sender.py b/src/robusta/integrations/webex/sender.py index 44ee30dc3..6d1400159 100644 --- a/src/robusta/integrations/webex/sender.py +++ b/src/robusta/integrations/webex/sender.py @@ -1,5 +1,6 @@ import tempfile from enum import Enum +from typing import Optional from webexteamssdk import WebexTeamsAPI @@ -38,7 +39,8 @@ def __init__( self.account_id = account_id self.client = WebexTeamsAPI(access_token=bot_access_token) # Create a client using webexteamssdk - def send_finding_to_webex(self, finding: Finding, platform_enabled: bool): + def send_finding_to_webex(self, finding: Finding, platform_enabled: bool, room_id: Optional[str] = None): + target_room = room_id or self.room_id message, table_blocks, file_blocks, description = self._separate_blocks(finding, platform_enabled) adaptive_card_body = self._createAdaptiveCardBody(message, table_blocks, description) adaptive_card = self._createAdaptiveCard(adaptive_card_body) @@ -51,9 +53,9 @@ def send_finding_to_webex(self, finding: Finding, platform_enabled: bool): ] # Here text="." is added because Webex API throws error to add text/file/markdown - self.client.messages.create(roomId=self.room_id, text=".", attachments=attachment) + self.client.messages.create(roomId=target_room, text=".", attachments=attachment) if file_blocks: - self._send_files(file_blocks) + self._send_files(file_blocks, target_room) def _createAdaptiveCardBody(self, message_content, table_blocks: List[TableBlock], description): body = [] @@ -154,7 +156,7 @@ def _separate_blocks(self, finding: Finding, platform_enabled: bool): return message_content, table_blocks, file_blocks, description - def _send_files(self, files: List[FileBlock]): + def _send_files(self, files: List[FileBlock], room_id: str): # Webex allows for only one file attachment per message # This function sends the files individually to webex for block in files: @@ -164,7 +166,7 @@ def _send_files(self, files: List[FileBlock]): f.write(block.contents) f.flush() self.client.messages.create( - roomId=self.room_id, + roomId=room_id, files=[f.name], ) f.close() # File is deleted when closed