Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/configuration/sinks/webex.rst
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,57 @@ Now we're ready to configure the webex sink.
room_id: <YOUR 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: <YOUR BOT ACCESS TOKEN>
room_id: <DEFAULT 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: <YOUR BOT ACCESS TOKEN>
room_id: <DEFAULT ROOM ID>
namespace_room_id_override: "${labels.webex-room}"
room_id_override: "$labels.team"
send_to_default_if_missing: false
70 changes: 70 additions & 0 deletions docs/configuration/sinks/webhook.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion playbooks/robusta_playbooks/popeye.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 59 additions & 1 deletion src/robusta/core/sinks/webex/webex_sink.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
12 changes: 12 additions & 0 deletions src/robusta/core/sinks/webex/webex_sink_params.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
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


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
Expand Down
43 changes: 41 additions & 2 deletions src/robusta/core/sinks/webhook/webhook_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
arikalon1 marked this conversation as resolved.
"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

Expand Down
4 changes: 4 additions & 0 deletions src/robusta/integrations/kubernetes/api_client_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 7 additions & 5 deletions src/robusta/integrations/webex/sender.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import tempfile
from enum import Enum
from typing import Optional

from webexteamssdk import WebexTeamsAPI

Expand Down Expand Up @@ -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)
Expand All @@ -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 = []
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading