From 983b698266b2eb4247a08739e9fdea4f1eedfc4a Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:34:47 +0200 Subject: [PATCH] feat(alerting): DEPLOYMENT_STATE evaluator Co-Authored-By: Claude Sonnet 4.6 --- .../eval/DeploymentStateEvaluator.java | 58 +++++++++ .../eval/DeploymentStateEvaluatorTest.java | 113 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluatorTest.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java new file mode 100644 index 00000000..13ef07b6 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluator.java @@ -0,0 +1,58 @@ +package com.cameleer.server.app.alerting.eval; + +import com.cameleer.server.core.alerting.AlertRule; +import com.cameleer.server.core.alerting.ConditionKind; +import com.cameleer.server.core.alerting.DeploymentStateCondition; +import com.cameleer.server.core.runtime.App; +import com.cameleer.server.core.runtime.AppRepository; +import com.cameleer.server.core.runtime.Deployment; +import com.cameleer.server.core.runtime.DeploymentRepository; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +public class DeploymentStateEvaluator implements ConditionEvaluator { + + private final AppRepository appRepo; + private final DeploymentRepository deploymentRepo; + + public DeploymentStateEvaluator(AppRepository appRepo, DeploymentRepository deploymentRepo) { + this.appRepo = appRepo; + this.deploymentRepo = deploymentRepo; + } + + @Override + public ConditionKind kind() { return ConditionKind.DEPLOYMENT_STATE; } + + @Override + public EvalResult evaluate(DeploymentStateCondition c, AlertRule rule, EvalContext ctx) { + String appSlug = c.scope() != null ? c.scope().appSlug() : null; + App app = (appSlug != null) + ? appRepo.findByEnvironmentIdAndSlug(rule.environmentId(), appSlug).orElse(null) + : null; + + if (app == null) return EvalResult.Clear.INSTANCE; + + Set wanted = Set.copyOf(c.states()); + List hits = deploymentRepo.findByAppId(app.id()).stream() + .filter(d -> wanted.contains(d.status().name())) + .toList(); + + if (hits.isEmpty()) return EvalResult.Clear.INSTANCE; + + Deployment d = hits.get(0); + return new EvalResult.Firing( + (double) hits.size(), null, + Map.of( + "deployment", Map.of( + "id", d.id().toString(), + "status", d.status().name() + ), + "app", Map.of("slug", app.slug()) + ) + ); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluatorTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluatorTest.java new file mode 100644 index 00000000..5f345ffa --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/eval/DeploymentStateEvaluatorTest.java @@ -0,0 +1,113 @@ +package com.cameleer.server.app.alerting.eval; + +import com.cameleer.server.core.alerting.*; +import com.cameleer.server.core.runtime.*; +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.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DeploymentStateEvaluatorTest { + + private AppRepository appRepo; + private DeploymentRepository deploymentRepo; + private DeploymentStateEvaluator eval; + + private static final UUID ENV_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + private static final UUID RULE_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static final UUID APP_ID = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); + private static final UUID DEP_ID = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + private static final Instant NOW = Instant.parse("2026-04-19T10:00:00Z"); + + @BeforeEach + void setUp() { + appRepo = mock(AppRepository.class); + deploymentRepo = mock(DeploymentRepository.class); + eval = new DeploymentStateEvaluator(appRepo, deploymentRepo); + } + + private AlertRule ruleWith(AlertCondition condition) { + return new AlertRule(RULE_ID, ENV_ID, "test", null, + AlertSeverity.WARNING, true, condition.kind(), condition, + 60, 0, 0, null, null, List.of(), List.of(), + null, null, null, Map.of(), null, null, null, null); + } + + private App app(String slug) { + return new App(APP_ID, ENV_ID, slug, "Orders", null, NOW.minusSeconds(3600), NOW.minusSeconds(3600)); + } + + private Deployment deployment(DeploymentStatus status) { + return new Deployment(DEP_ID, APP_ID, UUID.randomUUID(), ENV_ID, status, + null, null, List.of(), null, null, "orders-0", null, + Map.of(), NOW.minusSeconds(60), null, NOW.minusSeconds(120)); + } + + @Test + void firesWhenDeploymentInWantedState() { + var condition = new DeploymentStateCondition(new AlertScope("orders", null, null), List.of("FAILED")); + var rule = ruleWith(condition); + when(appRepo.findByEnvironmentIdAndSlug(ENV_ID, "orders")).thenReturn(Optional.of(app("orders"))); + when(deploymentRepo.findByAppId(APP_ID)).thenReturn(List.of(deployment(DeploymentStatus.FAILED))); + + EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache())); + assertThat(r).isInstanceOf(EvalResult.Firing.class); + assertThat(((EvalResult.Firing) r).currentValue()).isEqualTo(1.0); + } + + @Test + void clearWhenDeploymentNotInWantedState() { + var condition = new DeploymentStateCondition(new AlertScope("orders", null, null), List.of("FAILED")); + var rule = ruleWith(condition); + when(appRepo.findByEnvironmentIdAndSlug(ENV_ID, "orders")).thenReturn(Optional.of(app("orders"))); + when(deploymentRepo.findByAppId(APP_ID)).thenReturn(List.of(deployment(DeploymentStatus.RUNNING))); + + EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache())); + assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE); + } + + @Test + void clearWhenAppNotFound() { + var condition = new DeploymentStateCondition(new AlertScope("unknown-app", null, null), List.of("FAILED")); + var rule = ruleWith(condition); + when(appRepo.findByEnvironmentIdAndSlug(ENV_ID, "unknown-app")).thenReturn(Optional.empty()); + + EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache())); + assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE); + } + + @Test + void clearWhenNoDeployments() { + var condition = new DeploymentStateCondition(new AlertScope("orders", null, null), List.of("FAILED")); + var rule = ruleWith(condition); + when(appRepo.findByEnvironmentIdAndSlug(ENV_ID, "orders")).thenReturn(Optional.of(app("orders"))); + when(deploymentRepo.findByAppId(APP_ID)).thenReturn(List.of()); + + EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache())); + assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE); + } + + @Test + void firesWhenMultipleWantedStates() { + var condition = new DeploymentStateCondition(new AlertScope("orders", null, null), List.of("FAILED", "DEGRADED")); + var rule = ruleWith(condition); + when(appRepo.findByEnvironmentIdAndSlug(ENV_ID, "orders")).thenReturn(Optional.of(app("orders"))); + when(deploymentRepo.findByAppId(APP_ID)).thenReturn(List.of(deployment(DeploymentStatus.DEGRADED))); + + EvalResult r = eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache())); + assertThat(r).isInstanceOf(EvalResult.Firing.class); + } + + @Test + void kindIsDeploymentState() { + assertThat(eval.kind()).isEqualTo(ConditionKind.DEPLOYMENT_STATE); + } +}