Skip to content

Commit 552ae3e

Browse files
committed
Add mute_intervals config for sinks to mute notifications by date range
Adds a new `mute_intervals` field parallel to `activity` in sink config. Users can specify date/time ranges (MM-DD HH:MM format, no year) during which all notifications to a sink are muted. Supports timezone config and year-boundary wrapping (e.g. Dec 24 to Jan 2). https://claude.ai/code/session_01FGiv1N3QcFMWAPT4e1pu93
1 parent eae833b commit 552ae3e

5 files changed

Lines changed: 176 additions & 3 deletions

File tree

helm/robusta/values.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ fullnameOverride: ""
1010
playbookRepos: {}
1111

1212
# sinks configurations
13+
# Each sink supports an optional mute_intervals field (parallel to activity) that mutes
14+
# all notifications during specified date/time ranges. Format: MM-DD HH:MM (no year).
15+
# Example:
16+
# sinksConfig:
17+
# - slack_sink:
18+
# name: my_slack_sink
19+
# slack_channel: my-channel
20+
# api_key: xoxb-your-key
21+
# mute_intervals:
22+
# timezone: UTC
23+
# intervals:
24+
# - start_date: "12-24 00:00"
25+
# end_date: "12-26 23:59"
26+
# - start_date: "01-01 00:00"
27+
# end_date: "01-01 23:59"
1328
sinksConfig: []
1429

1530
# global parameters

src/robusta/core/sinks/sink_base.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
from robusta.core.model.k8s_operation_type import K8sOperationType
1010
from robusta.core.reporting.base import Finding
11-
from robusta.core.sinks.sink_base_params import ActivityInterval, ActivityParams, SinkBaseParams
12-
from robusta.core.sinks.timing import TimeSlice, TimeSliceAlways
11+
from robusta.core.sinks.sink_base_params import ActivityInterval, ActivityParams, MuteParams, SinkBaseParams
12+
from robusta.core.sinks.timing import MuteDateInterval, TimeSlice, TimeSliceAlways
1313

1414

1515
KeyT = Tuple[str, ...]
@@ -71,6 +71,7 @@ def __init__(self, sink_params: SinkBaseParams, registry):
7171
self.signing_key = global_config.get("signing_key", "")
7272

7373
self.time_slices = self._build_time_slices_from_params(self.params.activity)
74+
self.mute_date_intervals = self._build_mute_intervals_from_params(self.params.mute_intervals)
7475

7576
self.grouping_summary_mode = False
7677
self.grouping_enabled = False
@@ -151,6 +152,15 @@ def _build_time_slices_from_params(self, params: ActivityParams):
151152
def _interval_to_time_slice(self, timezone: str, interval: ActivityInterval):
152153
return TimeSlice(interval.days, [(time.start, time.end) for time in interval.hours], timezone)
153154

155+
def _build_mute_intervals_from_params(self, params: MuteParams):
156+
if params is None:
157+
return []
158+
timezone = params.timezone
159+
return [
160+
MuteDateInterval(interval.start_date, interval.end_date, timezone)
161+
for interval in params.intervals
162+
]
163+
154164
def is_global_config_changed(self) -> bool:
155165
# registry global config can be updated without these stored values being changed
156166
global_config = self.registry.get_global_config()
@@ -163,6 +173,8 @@ def stop(self):
163173
pass
164174

165175
def accepts(self, finding: Finding) -> bool:
176+
if any(mute.is_muted_now() for mute in self.mute_date_intervals):
177+
return False
166178
return (
167179
finding.matches(self.params.match, self.params.scope)
168180
and any(time_slice.is_active_now() for time_slice in self.time_slices)

src/robusta/core/sinks/sink_base_params.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,52 @@ def check_intervals(cls, intervals: List[ActivityInterval]):
5757
return intervals
5858

5959

60+
DATE_TIME_RE = re.compile(r"^\d{2}-\d{2} \d{2}:\d{2}$")
61+
62+
63+
def check_date_time_format(value: str) -> str:
64+
if not DATE_TIME_RE.match(value):
65+
raise ValueError(f"invalid date-time: {value}. Expected format: MM-DD HH:MM")
66+
month, rest = value.split("-", 1)
67+
day, time_part = rest.split(" ", 1)
68+
hour, minute = time_part.split(":")
69+
month, day, hour, minute = int(month), int(day), int(hour), int(minute)
70+
if not (1 <= month <= 12):
71+
raise ValueError(f"invalid month: {month}")
72+
if not (1 <= day <= 31):
73+
raise ValueError(f"invalid day: {day}")
74+
if not (0 <= hour <= 23):
75+
raise ValueError(f"invalid hour: {hour}")
76+
if not (0 <= minute <= 59):
77+
raise ValueError(f"invalid minute: {minute}")
78+
return value
79+
80+
81+
class MuteInterval(BaseModel):
82+
start_date: str # MM-DD HH:MM
83+
end_date: str # MM-DD HH:MM
84+
85+
_validator_start = validator("start_date", allow_reuse=True)(check_date_time_format)
86+
_validator_end = validator("end_date", allow_reuse=True)(check_date_time_format)
87+
88+
89+
class MuteParams(BaseModel):
90+
timezone: str = "UTC"
91+
intervals: List[MuteInterval]
92+
93+
@validator("timezone")
94+
def check_timezone(cls, timezone: str):
95+
if timezone not in pytz.all_timezones:
96+
raise ValueError(f"unknown timezone {timezone}")
97+
return timezone
98+
99+
@validator("intervals")
100+
def check_intervals(cls, intervals: List[MuteInterval]):
101+
if not intervals:
102+
raise ValueError("at least one interval has to be specified for mute_intervals")
103+
return intervals
104+
105+
60106
class RegularNotificationModeParams(BaseModel):
61107
# This is mandatory because using the regular mode without setting it
62108
# would make no sense - all the notifications would just pass through
@@ -108,6 +154,7 @@ class SinkBaseParams(ABC, BaseModel):
108154
match: dict = {}
109155
scope: Optional[ScopeParams]
110156
activity: Optional[ActivityParams]
157+
mute_intervals: Optional[MuteParams]
111158
grouping: Optional[GroupingParams]
112159
stop: bool = False # Stop processing if this sink has been matched
113160

src/robusta/core/sinks/timing.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,41 @@ def is_active_now(self) -> bool:
6565
class TimeSliceAlways(TimeSliceBase):
6666
def is_active_now(self) -> bool:
6767
return True
68+
69+
70+
class MuteDateInterval:
71+
"""Checks if the current date/time falls within a mute interval.
72+
73+
start_date and end_date are in MM-DD HH:MM format (no year).
74+
The interval applies to the current year. If start_date > end_date
75+
(e.g. 12-20 to 01-05), it wraps across the year boundary.
76+
"""
77+
78+
def __init__(self, start_date: str, end_date: str, timezone: str = "UTC"):
79+
self.start_month, self.start_day, self.start_hour, self.start_minute = self._parse(start_date)
80+
self.end_month, self.end_day, self.end_hour, self.end_minute = self._parse(end_date)
81+
try:
82+
self.timezone = pytz.timezone(timezone)
83+
except pytz.exceptions.UnknownTimeZoneError:
84+
raise ValueError(f"Unknown time zone {timezone}")
85+
86+
def _parse(self, date_str: str) -> Tuple[int, int, int, int]:
87+
date_part, time_part = date_str.strip().split(" ")
88+
month, day = date_part.split("-")
89+
hour, minute = time_part.split(":")
90+
return int(month), int(day), int(hour), int(minute)
91+
92+
def _to_tuple(self, month: int, day: int, hour: int, minute: int) -> Tuple[int, int, int, int]:
93+
return (month, day, hour, minute)
94+
95+
def is_muted_now(self) -> bool:
96+
now = datetime.now(self.timezone)
97+
current = self._to_tuple(now.month, now.day, now.hour, now.minute)
98+
start = self._to_tuple(self.start_month, self.start_day, self.start_hour, self.start_minute)
99+
end = self._to_tuple(self.end_month, self.end_day, self.end_hour, self.end_minute)
100+
101+
if start <= end:
102+
return start <= current <= end
103+
else:
104+
# Wraps across year boundary (e.g. 12-20 00:00 to 01-05 00:00)
105+
return current >= start or current <= end

tests/test_sink_timing.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from robusta.core.reporting import Finding
77
from robusta.core.sinks.sink_base import SinkBase
88
from robusta.core.sinks.sink_base_params import ActivityParams
9-
from robusta.core.sinks.timing import TimeSlice
9+
from robusta.core.sinks.timing import MuteDateInterval, TimeSlice
1010

1111

1212
class TestTimeSlice:
@@ -39,6 +39,40 @@ def test_invalid_time(self, time):
3939
TimeSlice([], [time], "UTC")
4040

4141

42+
class TestMuteDateInterval:
43+
def test_unknown_timezone(self):
44+
with pytest.raises(ValueError):
45+
MuteDateInterval("01-01 00:00", "01-02 00:00", "Mars/Cydonia")
46+
47+
@pytest.mark.parametrize(
48+
"start_date,end_date,timezone,expected_muted",
49+
[
50+
# 2012-01-01 13:45 UTC - currently muted (within range)
51+
("01-01 00:00", "01-01 23:59", "UTC", True),
52+
# Currently muted (multi-day range)
53+
("12-31 00:00", "01-02 10:00", "UTC", True),
54+
# Not muted (range in February)
55+
("02-01 00:00", "02-28 23:59", "UTC", False),
56+
# Not muted (same day but hours don't match - before current time)
57+
("01-01 00:00", "01-01 13:00", "UTC", False),
58+
# Muted (same day, hours match)
59+
("01-01 13:00", "01-01 14:00", "UTC", True),
60+
# Year-boundary wrap: Dec 20 to Jan 5 should mute on Jan 1
61+
("12-20 00:00", "01-05 23:59", "UTC", True),
62+
# Year-boundary wrap: March to Feb wraps around, Jan 1 IS inside that range
63+
("03-01 00:00", "02-15 23:59", "UTC", True),
64+
# Not muted: range is Feb 1 to Feb 28, Jan 1 is outside
65+
("02-01 00:00", "02-10 23:59", "UTC", False),
66+
# Timezone test: 2012-01-01 13:45 UTC = 2012-01-01 14:45 CET
67+
("01-01 14:00", "01-01 15:00", "CET", True),
68+
("01-01 15:00", "01-01 16:00", "CET", False),
69+
],
70+
)
71+
def test_is_muted_now(self, start_date, end_date, timezone, expected_muted):
72+
with freeze_time("2012-01-01 13:45"): # UTC time
73+
assert MuteDateInterval(start_date, end_date, timezone).is_muted_now() is expected_muted
74+
75+
4276
class _TestSinkBase(SinkBase):
4377
def write_finding(self, finding: Finding, platform_enabled: bool):
4478
pass
@@ -50,6 +84,10 @@ def _build_time_slices_from_params(self, params: ActivityParams):
5084
# We'll construct time_slices explicitly below in TestSinkBase.test_accepts
5185
pass
5286

87+
def _build_mute_intervals_from_params(self, params):
88+
# We'll construct mute_date_intervals explicitly below
89+
pass
90+
5391

5492
class TestSinkBase:
5593
@pytest.mark.parametrize(
@@ -63,6 +101,29 @@ def test_accepts(self, days, time_intervals, expected_result):
63101
mock_registry = Mock(get_global_config=lambda: Mock())
64102
sink = _TestSinkBase(registry=mock_registry, sink_params=Mock())
65103
sink.time_slices = [TimeSlice(days, time_intervals, "UTC")]
104+
sink.mute_date_intervals = []
66105
mock_finding = Mock(matches=Mock(return_value=True))
67106
with freeze_time("2012-01-01 13:45"): # this is UTC time
68107
assert sink.accepts(mock_finding) is expected_result
108+
109+
def test_accepts_muted(self):
110+
"""When a mute interval is active, accepts() should return False."""
111+
mock_registry = Mock(get_global_config=lambda: Mock())
112+
sink = _TestSinkBase(registry=mock_registry, sink_params=Mock())
113+
sink.time_slices = [TimeSlice(["sun"], [("13:30", "14:00")], "UTC")]
114+
sink.mute_date_intervals = [MuteDateInterval("01-01 00:00", "01-01 23:59", "UTC")]
115+
mock_finding = Mock(matches=Mock(return_value=True))
116+
with freeze_time("2012-01-01 13:45"): # this is UTC time, Sunday
117+
# Would normally be accepted (Sunday 13:45 in 13:30-14:00), but muted
118+
assert sink.accepts(mock_finding) is False
119+
120+
def test_accepts_not_muted(self):
121+
"""When no mute interval is active, accepts() works normally."""
122+
mock_registry = Mock(get_global_config=lambda: Mock())
123+
sink = _TestSinkBase(registry=mock_registry, sink_params=Mock())
124+
sink.time_slices = [TimeSlice(["sun"], [("13:30", "14:00")], "UTC")]
125+
sink.mute_date_intervals = [MuteDateInterval("02-01 00:00", "02-28 23:59", "UTC")]
126+
mock_finding = Mock(matches=Mock(return_value=True))
127+
with freeze_time("2012-01-01 13:45"): # this is UTC time, Sunday
128+
# Mute is for February, so should still accept
129+
assert sink.accepts(mock_finding) is True

0 commit comments

Comments
 (0)