Skip to content

Commit 369dbdb

Browse files
authored
fix webhook sink (#2078)
add dynamic room for webex sink make popeye container name fixed
1 parent 3b3531e commit 369dbdb

8 files changed

Lines changed: 248 additions & 9 deletions

File tree

docs/configuration/sinks/webex.rst

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,57 @@ Now we're ready to configure the webex sink.
6464
room_id: <YOUR ROOM ID>
6565
6666
You should now get playbooks results in Webex!
67+
68+
Dynamic Room Routing
69+
------------------------------------------------
70+
71+
You can route alerts to different Webex rooms based on Kubernetes labels or
72+
annotations. The sink supports two override fields, evaluated in order:
73+
74+
1. ``namespace_room_id_override`` — resolved against the **Namespace** object's labels
75+
and annotations (looked up by the finding's namespace, with TTL caching to avoid
76+
hammering the K8s API).
77+
2. ``room_id_override`` — resolved against the finding's **subject** labels and
78+
annotations (same behavior as Slack's ``channel_override``).
79+
80+
If neither override produces a room id, ``send_to_default_if_missing`` decides what
81+
happens:
82+
83+
- ``true`` *(default)* — send to the configured ``room_id``.
84+
- ``false`` — drop the finding silently.
85+
86+
Both override fields use the same template syntax as Slack:
87+
88+
- ``cluster_name`` — the Robusta cluster name.
89+
- ``labels.foo`` / ``$labels.foo`` — value of a label.
90+
- ``annotations.bar`` / ``$annotations.bar`` — value of an annotation.
91+
- ``${labels.foo-bar}`` / ``${annotations.kubernetes.io/owner}`` — bracket form
92+
required when the key contains characters other than letters, digits, or underscores
93+
(e.g. ``-``, ``/``, ``.``).
94+
- Composite patterns are allowed: ``"$cluster_name-$labels.team"``.
95+
96+
Example — route by a label on the namespace, fall back to the default room:
97+
98+
.. code-block:: yaml
99+
100+
sinksConfig:
101+
- webex_sink:
102+
name: webex_sink
103+
bot_access_token: <YOUR BOT ACCESS TOKEN>
104+
room_id: <DEFAULT ROOM ID>
105+
namespace_room_id_override: "${labels.webex-room}"
106+
send_to_default_if_missing: true
107+
108+
Example — route by namespace label first, fall back to a subject label, drop if neither
109+
is present:
110+
111+
.. code-block:: yaml
112+
113+
sinksConfig:
114+
- webex_sink:
115+
name: webex_sink
116+
bot_access_token: <YOUR BOT ACCESS TOKEN>
117+
room_id: <DEFAULT ROOM ID>
118+
namespace_room_id_override: "${labels.webex-room}"
119+
room_id_override: "$labels.team"
120+
send_to_default_if_missing: false

docs/configuration/sinks/webhook.rst

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,73 @@ Save the file and run
2626
.. image:: /images/deployment-babysitter-webhook.png
2727
:width: 600
2828
:align: center
29+
30+
Configuration parameters
31+
-------------------------
32+
33+
.. list-table::
34+
:header-rows: 1
35+
:widths: 20 15 65
36+
37+
* - Field
38+
- Default
39+
- Description
40+
* - ``url``
41+
- *(required)*
42+
- The webhook endpoint to POST to.
43+
* - ``format``
44+
- ``text``
45+
- Payload format. ``text`` for a human-readable body, ``json`` for a structured body.
46+
* - ``size_limit``
47+
- ``4096``
48+
- Maximum payload size in bytes. Content beyond the limit is truncated.
49+
* - ``authorization``
50+
- *(none)*
51+
- Optional value sent in the ``Authorization`` request header.
52+
* - ``slack_webhook``
53+
- ``false``
54+
- When ``true`` and ``format: json``, posts a Slack-compatible body for use with Slack incoming webhooks.
55+
56+
JSON payload
57+
-------------
58+
59+
When ``format: json`` is set, the POST body is a JSON object with the following top-level fields:
60+
61+
.. code-block:: json
62+
63+
{
64+
"title": "CrashLoopBackOff",
65+
"description": "Container is crashing repeatedly",
66+
"cluster_name": "prod-eu-west",
67+
"account_id": "abcd-1234",
68+
"severity": "HIGH",
69+
"source": "KUBERNETES_API_SERVER",
70+
"finding_type": "ISSUE",
71+
"aggregation_key": "CrashLoopBackOff",
72+
"failure": true,
73+
"fingerprint": "2c1d...",
74+
"starts_at": "2026-04-30T10:15:00+00:00",
75+
"ends_at": null,
76+
"subject": {
77+
"name": "my-pod",
78+
"kind": "pod",
79+
"namespace": "default",
80+
"node": "node-1",
81+
"container": "main",
82+
"labels": {"app": "demo"},
83+
"annotations": {"team": "platform"}
84+
},
85+
"links": [
86+
{"name": "Runbook", "url": "https://...", "type": null},
87+
{"name": "Graph", "url": "https://...", "type": "prometheus_generator_url"}
88+
],
89+
"investigate": "https://platform.robusta.dev/...",
90+
"silence": "https://platform.robusta.dev/silences/create?...",
91+
"enrichments": [ ... ]
92+
}
93+
94+
``investigate`` and ``silence`` are present only when the Robusta platform is enabled
95+
(``silence`` additionally requires ``add_silence_url`` on the finding).
96+
97+
If the serialized payload exceeds ``size_limit``, the largest field (``enrichments``)
98+
is dropped first so that core metadata and ``links`` survive truncation.

playbooks/robusta_playbooks/popeye.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def popeye_scan(event: ExecutionBaseEvent, params: PopeyeParams):
159159
serviceAccountName=params.service_account_name,
160160
containers=[
161161
Container(
162-
name=to_kubernetes_name(IMAGE),
162+
name="popeye-scanner",
163163
image=IMAGE,
164164
command=[
165165
"/bin/sh",

src/robusta/core/sinks/webex/webex_sink.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,22 @@
1+
import logging
2+
from typing import Dict, Optional, Tuple
3+
14
from robusta.core.reporting.base import Finding
5+
from robusta.core.sinks.common.channel_transformer import ChannelTransformer
26
from robusta.core.sinks.sink_base import SinkBase
37
from robusta.core.sinks.webex.webex_sink_params import WebexSinkConfigWrapper
8+
from robusta.integrations.kubernetes.api_client_utils import (
9+
get_namespace_annotations,
10+
get_namespace_labels,
11+
)
412
from robusta.integrations.webex.sender import WebexSender
513

14+
# Sentinel passed as default_channel to ChannelTransformer.template() so we can detect
15+
# the "any token missing" case from the outside. ChannelTransformer returns the default
16+
# we pass in only when the override is empty (we filter that case ourselves) or when a
17+
# referenced label/annotation key is missing — both of which we treat as unresolved here.
18+
_UNRESOLVED = "__robusta_webex_unresolved__"
19+
620

721
class WebexSink(SinkBase):
822
def __init__(self, sink_config: WebexSinkConfigWrapper, registry):
@@ -17,4 +31,48 @@ def __init__(self, sink_config: WebexSinkConfigWrapper, registry):
1731
)
1832

1933
def write_finding(self, finding: Finding, platform_enabled: bool):
20-
self.sender.send_finding_to_webex(finding, platform_enabled)
34+
room_id = self._resolve_room_id(finding)
35+
if room_id is None:
36+
return
37+
self.sender.send_finding_to_webex(finding, platform_enabled, room_id=room_id)
38+
39+
def _resolve_room_id(self, finding: Finding) -> Optional[str]:
40+
params = self.params
41+
42+
if params.namespace_room_id_override and finding.subject.namespace:
43+
ns_labels, ns_annotations = self._get_namespace_metadata(finding.subject.namespace)
44+
resolved = ChannelTransformer.template(
45+
params.namespace_room_id_override,
46+
_UNRESOLVED,
47+
self.cluster_name,
48+
ns_labels,
49+
ns_annotations,
50+
)
51+
if resolved != _UNRESOLVED:
52+
return resolved
53+
54+
if params.room_id_override:
55+
resolved = ChannelTransformer.template(
56+
params.room_id_override,
57+
_UNRESOLVED,
58+
self.cluster_name,
59+
finding.subject.labels or {},
60+
finding.subject.annotations or {},
61+
)
62+
if resolved != _UNRESOLVED:
63+
return resolved
64+
65+
if not params.room_id_override and not params.namespace_room_id_override:
66+
return params.room_id
67+
68+
return params.room_id if params.send_to_default_if_missing else None
69+
70+
@staticmethod
71+
def _get_namespace_metadata(namespace: str) -> Tuple[Dict[str, str], Dict[str, str]]:
72+
try:
73+
labels = get_namespace_labels(namespace) or {}
74+
annotations = get_namespace_annotations(namespace) or {}
75+
except KeyError:
76+
logging.debug("namespace %s not found in cache", namespace)
77+
return {}, {}
78+
return labels, annotations

src/robusta/core/sinks/webex/webex_sink_params.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
1+
from typing import Optional
2+
3+
from pydantic import validator
4+
5+
from robusta.core.sinks.common.channel_transformer import ChannelTransformer
16
from robusta.core.sinks.sink_base_params import SinkBaseParams
27
from robusta.core.sinks.sink_config import SinkConfigBase
38

49

510
class WebexSinkParams(SinkBaseParams):
611
bot_access_token: str
712
room_id: str
13+
room_id_override: Optional[str] = None
14+
namespace_room_id_override: Optional[str] = None
15+
send_to_default_if_missing: bool = True
816

917
@classmethod
1018
def _get_sink_type(cls):
1119
return "webex"
1220

21+
@validator("room_id_override", "namespace_room_id_override")
22+
def validate_overrides(cls, v):
23+
return ChannelTransformer.validate_channel_override(v)
24+
1325

1426
class WebexSinkConfigWrapper(SinkConfigBase):
1527
webex_sink: WebexSinkParams

src/robusta/core/sinks/webhook/webhook_sink.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,53 @@ def __write_text(self, finding: Finding, platform_enabled: bool):
7373
logging.exception(f"Webhook request error\n headers: \n{self.headers}")
7474

7575
def __write_json(self, finding: Finding, platform_enabled: bool):
76-
finding_dict = json.loads(json.dumps(finding, default=lambda o: getattr(o, '__dict__', str(o))))
76+
finding_dict = {
77+
"title": finding.title,
78+
"description": finding.description,
79+
"cluster_name": self.cluster_name,
80+
"account_id": self.account_id,
81+
"severity": finding.severity.name,
82+
"source": finding.source.name,
83+
"finding_type": finding.finding_type.name,
84+
"aggregation_key": finding.aggregation_key,
85+
"failure": finding.failure,
86+
"fingerprint": finding.fingerprint,
87+
"starts_at": finding.starts_at.isoformat() if finding.starts_at else None,
88+
"ends_at": finding.ends_at.isoformat() if finding.ends_at else None,
89+
"id": str(finding.id),
90+
"category": finding.category,
91+
"service": json.loads(
92+
json.dumps(finding.service, default=lambda o: getattr(o, '__dict__', str(o)))
93+
) if finding.service else None,
94+
"service_key": finding.service_key,
95+
"creation_date": finding.creation_date,
96+
"investigate_uri": finding.investigate_uri,
97+
"add_silence_url": finding.add_silence_url,
98+
"subject": {
99+
"name": finding.subject.name,
100+
"kind": finding.subject.subject_type.value,
101+
"namespace": finding.subject.namespace,
102+
"node": finding.subject.node,
103+
"container": finding.subject.container,
104+
"labels": finding.subject.labels,
105+
"annotations": finding.subject.annotations,
106+
},
107+
"links": [
108+
{"name": link.name, "url": link.url, "type": link.type.value if link.type else None}
109+
for link in finding.links
110+
],
111+
}
77112

78113
if platform_enabled:
79114
finding_dict["investigate"] = finding.get_investigate_uri(self.account_id, self.cluster_name)
80-
81115
if finding.add_silence_url:
82116
finding_dict["silence"] = finding.get_prometheus_silence_url(self.account_id, self.cluster_name)
83117

118+
# Enrichments last so they're the first thing dropped if size_limit is exceeded.
119+
finding_dict["enrichments"] = json.loads(
120+
json.dumps(finding.enrichments, default=lambda o: getattr(o, '__dict__', str(o)))
121+
)
122+
84123
message = {}
85124
message_length = 0
86125

src/robusta/integrations/kubernetes/api_client_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,7 @@ def get_all_namespace_data():
286286

287287
def get_namespace_labels(namespace_name: str) -> Dict[str, str]:
288288
return get_all_namespace_data()[namespace_name].labels
289+
290+
291+
def get_namespace_annotations(namespace_name: str) -> Dict[str, str]:
292+
return get_all_namespace_data()[namespace_name].annotations

src/robusta/integrations/webex/sender.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import tempfile
22
from enum import Enum
3+
from typing import Optional
34

45
from webexteamssdk import WebexTeamsAPI
56

@@ -38,7 +39,8 @@ def __init__(
3839
self.account_id = account_id
3940
self.client = WebexTeamsAPI(access_token=bot_access_token) # Create a client using webexteamssdk
4041

41-
def send_finding_to_webex(self, finding: Finding, platform_enabled: bool):
42+
def send_finding_to_webex(self, finding: Finding, platform_enabled: bool, room_id: Optional[str] = None):
43+
target_room = room_id or self.room_id
4244
message, table_blocks, file_blocks, description = self._separate_blocks(finding, platform_enabled)
4345
adaptive_card_body = self._createAdaptiveCardBody(message, table_blocks, description)
4446
adaptive_card = self._createAdaptiveCard(adaptive_card_body)
@@ -51,9 +53,9 @@ def send_finding_to_webex(self, finding: Finding, platform_enabled: bool):
5153
]
5254

5355
# Here text="." is added because Webex API throws error to add text/file/markdown
54-
self.client.messages.create(roomId=self.room_id, text=".", attachments=attachment)
56+
self.client.messages.create(roomId=target_room, text=".", attachments=attachment)
5557
if file_blocks:
56-
self._send_files(file_blocks)
58+
self._send_files(file_blocks, target_room)
5759

5860
def _createAdaptiveCardBody(self, message_content, table_blocks: List[TableBlock], description):
5961
body = []
@@ -154,7 +156,7 @@ def _separate_blocks(self, finding: Finding, platform_enabled: bool):
154156

155157
return message_content, table_blocks, file_blocks, description
156158

157-
def _send_files(self, files: List[FileBlock]):
159+
def _send_files(self, files: List[FileBlock], room_id: str):
158160
# Webex allows for only one file attachment per message
159161
# This function sends the files individually to webex
160162
for block in files:
@@ -164,7 +166,7 @@ def _send_files(self, files: List[FileBlock]):
164166
f.write(block.contents)
165167
f.flush()
166168
self.client.messages.create(
167-
roomId=self.room_id,
169+
roomId=room_id,
168170
files=[f.name],
169171
)
170172
f.close() # File is deleted when closed

0 commit comments

Comments
 (0)