diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/SilenceMatcherService.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/SilenceMatcherService.java new file mode 100644 index 00000000..1f60226c --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/SilenceMatcherService.java @@ -0,0 +1,58 @@ +package com.cameleer.server.app.alerting.notify; + +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertRule; +import com.cameleer.server.core.alerting.SilenceMatcher; +import org.springframework.stereotype.Component; + +/** + * Evaluates whether an active silence matches an alert instance at notification-dispatch time. + *

+ * Each non-null field on the matcher is an additional AND constraint. A null field is a wildcard. + * Matching is purely in-process — no I/O. + */ +@Component +public class SilenceMatcherService { + + /** + * Returns {@code true} if the silence covers this alert instance. + * + * @param matcher the silence's matching spec (never null) + * @param instance the alert instance to test (never null) + * @param rule the alert rule; may be null when the rule was deleted after instance creation. + * Scope-based matchers (appSlug, routeId, agentId) return false when rule is null + * because the scope cannot be verified. + */ + public boolean matches(SilenceMatcher matcher, AlertInstance instance, AlertRule rule) { + // ruleId constraint + if (matcher.ruleId() != null && !matcher.ruleId().equals(instance.ruleId())) { + return false; + } + + // severity constraint + if (matcher.severity() != null && matcher.severity() != instance.severity()) { + return false; + } + + // scope-based constraints require the rule to derive scope from + boolean needsScope = matcher.appSlug() != null || matcher.routeId() != null || matcher.agentId() != null; + if (needsScope && rule == null) { + return false; + } + + if (rule != null && rule.condition() != null) { + var scope = rule.condition().scope(); + if (matcher.appSlug() != null && !matcher.appSlug().equals(scope.appSlug())) { + return false; + } + if (matcher.routeId() != null && !matcher.routeId().equals(scope.routeId())) { + return false; + } + if (matcher.agentId() != null && !matcher.agentId().equals(scope.agentId())) { + return false; + } + } + + return true; + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/SilenceMatcherServiceTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/SilenceMatcherServiceTest.java new file mode 100644 index 00000000..aed812d7 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/SilenceMatcherServiceTest.java @@ -0,0 +1,188 @@ +package com.cameleer.server.app.alerting.notify; + +import com.cameleer.server.core.alerting.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class SilenceMatcherServiceTest { + + private SilenceMatcherService service; + + private static final UUID RULE_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static final UUID ENV_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static final UUID INST_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); + + @BeforeEach + void setUp() { + service = new SilenceMatcherService(); + } + + // ---- helpers ---- + + private AlertInstance instance() { + return new AlertInstance( + INST_ID, RULE_ID, Map.of(), ENV_ID, + AlertState.FIRING, AlertSeverity.WARNING, + Instant.now(), null, null, null, null, + false, 1.5, 1.0, + Map.of(), "title", "msg", + List.of(), List.of(), List.of() + ); + } + + private AlertRule ruleWithScope(String appSlug, String routeId, String agentId) { + var scope = new AlertScope(appSlug, routeId, agentId); + var condition = new RouteMetricCondition(scope, RouteMetric.ERROR_RATE, Comparator.GT, 0.1, 60); + return new AlertRule( + RULE_ID, ENV_ID, "Test rule", null, + AlertSeverity.WARNING, true, + ConditionKind.ROUTE_METRIC, condition, + 60, 0, 0, "t", "m", + List.of(), List.of(), + Instant.now(), null, Instant.now(), + Map.of(), Instant.now(), "admin", Instant.now(), "admin" + ); + } + + // ---- wildcard matcher ---- + + @Test + void wildcardMatcher_matchesAnyInstance() { + var matcher = new SilenceMatcher(null, null, null, null, null); + assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", "r1", null))).isTrue(); + } + + @Test + void wildcardMatcher_matchesWithNullRule() { + var matcher = new SilenceMatcher(null, null, null, null, null); + assertThat(service.matches(matcher, instance(), null)).isTrue(); + } + + // ---- ruleId constraint ---- + + @Test + void ruleIdMatcher_matchesWhenEqual() { + var matcher = new SilenceMatcher(RULE_ID, null, null, null, null); + assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, null))).isTrue(); + } + + @Test + void ruleIdMatcher_rejectsWhenDifferent() { + var matcher = new SilenceMatcher(UUID.randomUUID(), null, null, null, null); + assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, null))).isFalse(); + } + + @Test + void ruleIdMatcher_withNullInstanceRuleId_rejects() { + // Instance where rule was deleted (ruleId = null) + var inst = new AlertInstance( + INST_ID, null, Map.of(), ENV_ID, + AlertState.FIRING, AlertSeverity.WARNING, + Instant.now(), null, null, null, null, + false, null, null, + Map.of(), "t", "m", + List.of(), List.of(), List.of() + ); + var matcher = new SilenceMatcher(RULE_ID, null, null, null, null); + assertThat(service.matches(matcher, inst, null)).isFalse(); + } + + @Test + void ruleIdNull_withNullInstanceRuleId_wildcardStillMatches() { + var inst = new AlertInstance( + INST_ID, null, Map.of(), ENV_ID, + AlertState.FIRING, AlertSeverity.WARNING, + Instant.now(), null, null, null, null, + false, null, null, + Map.of(), "t", "m", + List.of(), List.of(), List.of() + ); + var matcher = new SilenceMatcher(null, null, null, null, null); + // Wildcard ruleId + null rule — scope constraints not needed — should match. + assertThat(service.matches(matcher, inst, null)).isTrue(); + } + + // ---- severity constraint ---- + + @Test + void severityMatcher_matchesWhenEqual() { + var matcher = new SilenceMatcher(null, null, null, null, AlertSeverity.WARNING); + assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, null))).isTrue(); + } + + @Test + void severityMatcher_rejectsWhenDifferent() { + var matcher = new SilenceMatcher(null, null, null, null, AlertSeverity.CRITICAL); + assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, null))).isFalse(); + } + + // ---- appSlug constraint ---- + + @Test + void appSlugMatcher_matchesWhenEqual() { + var matcher = new SilenceMatcher(null, "my-app", null, null, null); + assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", null, null))).isTrue(); + } + + @Test + void appSlugMatcher_rejectsWhenDifferent() { + var matcher = new SilenceMatcher(null, "other-app", null, null, null); + assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", null, null))).isFalse(); + } + + @Test + void appSlugMatcher_rejectsWhenRuleIsNull() { + var matcher = new SilenceMatcher(null, "my-app", null, null, null); + assertThat(service.matches(matcher, instance(), null)).isFalse(); + } + + // ---- routeId constraint ---- + + @Test + void routeIdMatcher_matchesWhenEqual() { + var matcher = new SilenceMatcher(null, null, "route-1", null, null); + assertThat(service.matches(matcher, instance(), ruleWithScope(null, "route-1", null))).isTrue(); + } + + @Test + void routeIdMatcher_rejectsWhenDifferent() { + var matcher = new SilenceMatcher(null, null, "route-99", null, null); + assertThat(service.matches(matcher, instance(), ruleWithScope(null, "route-1", null))).isFalse(); + } + + // ---- agentId constraint ---- + + @Test + void agentIdMatcher_matchesWhenEqual() { + var matcher = new SilenceMatcher(null, null, null, "agent-7", null); + assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, "agent-7"))).isTrue(); + } + + @Test + void agentIdMatcher_rejectsWhenDifferent() { + var matcher = new SilenceMatcher(null, null, null, "agent-99", null); + assertThat(service.matches(matcher, instance(), ruleWithScope(null, null, "agent-7"))).isFalse(); + } + + // ---- AND semantics: multiple fields ---- + + @Test + void multipleFields_allMustMatch() { + var matcher = new SilenceMatcher(RULE_ID, "my-app", "route-1", null, AlertSeverity.WARNING); + assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", "route-1", null))).isTrue(); + } + + @Test + void multipleFields_failsWhenOneDoesNotMatch() { + // severity mismatch while everything else matches + var matcher = new SilenceMatcher(RULE_ID, "my-app", "route-1", null, AlertSeverity.CRITICAL); + assertThat(service.matches(matcher, instance(), ruleWithScope("my-app", "route-1", null))).isFalse(); + } +}