From cbb95ba26cf04fe8c7c2289d1b35336d775d940f Mon Sep 17 00:00:00 2001 From: Aniket Shukla Date: Tue, 29 Sep 2026 17:12:52 +0530 Subject: [PATCH] fix(cloudwatch): retain metric-math alarm query definitions (#4594) * fix(cloudwatch): retain metric-math alarm query definitions * test(cloudwatch): use shared XML parser for alarm assertions (cherry picked from commit 1d646c6fea70a8d4bfeea4d0ca6d8fc390ea6170) --- .../test/CloudWatchMetricMathAlarmTest.java | 117 ++++++++++ docs/services/cloudwatch.md | 12 + .../cloudwatch/metrics/AlarmEvaluator.java | 3 +- .../metrics/CloudWatchMetricsJsonHandler.java | 45 +++- .../CloudWatchMetricsQueryHandler.java | 101 +++++++- .../metrics/model/AlarmMetricDataQuery.java | 46 ++++ .../cloudwatch/metrics/model/MetricAlarm.java | 13 +- .../CloudWatchMetricMathAlarmTest.java | 218 ++++++++++++++++++ 8 files changed, 529 insertions(+), 26 deletions(-) create mode 100644 compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchMetricMathAlarmTest.java create mode 100644 src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/AlarmMetricDataQuery.java create mode 100644 src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java diff --git a/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchMetricMathAlarmTest.java b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchMetricMathAlarmTest.java new file mode 100644 index 0000000..d32f9d5 --- /dev/null +++ b/compatibility-tests/sdk-test-java/src/test/java/com/floci/test/CloudWatchMetricMathAlarmTest.java @@ -0,0 +1,117 @@ +package com.floci.test; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.cloudwatch.CloudWatchClient; +import software.amazon.awssdk.services.cloudwatch.model.ComparisonOperator; +import software.amazon.awssdk.services.cloudwatch.model.Dimension; +import software.amazon.awssdk.services.cloudwatch.model.Metric; +import software.amazon.awssdk.services.cloudwatch.model.MetricAlarm; +import software.amazon.awssdk.services.cloudwatch.model.MetricDataQuery; +import software.amazon.awssdk.services.cloudwatch.model.MetricStat; +import software.amazon.awssdk.services.cloudwatch.model.StandardUnit; +import software.amazon.awssdk.services.cloudwatch.model.Statistic; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("CloudWatch - Metric-math alarm definitions") +class CloudWatchMetricMathAlarmTest { + + private CloudWatchClient cloudWatch; + private String alarmName; + + @BeforeEach + void setup() { + cloudWatch = TestFixtures.cloudWatchClient(); + alarmName = TestFixtures.uniqueName("sdk-metric-math-alarm"); + } + + @AfterEach + void cleanup() { + try { + cloudWatch.deleteAlarms(request -> request.alarmNames(alarmName)); + } finally { + cloudWatch.close(); + } + } + + @Test + void metricsRoundTripWithoutInventingSingleMetricFields() { + List metrics = metrics(); + putMathAlarm(metrics); + assertMathAlarm(metrics); + } + + @Test + void updatesReplaceQueriesAndSwitchBackToASingleMetric() { + putSingleMetricAlarm(300); + List original = metrics(); + putMathAlarm(original); + assertMathAlarm(original); + + List replacement = List.of( + original.get(0).toBuilder().expression("errors / requests").label("Ratio").build(), + original.get(1), original.get(2)); + putMathAlarm(replacement); + assertMathAlarm(replacement); + + putSingleMetricAlarm(120); + MetricAlarm alarm = describe(); + assertThat(alarm.hasMetrics()).isFalse(); + assertThat(alarm.period()).isEqualTo(120); + assertThat(alarm.metricName()).isEqualTo("5xx"); + assertThat(alarm.namespace()).isEqualTo("AWS/ApiGateway"); + } + + private void putMathAlarm(List metrics) { + cloudWatch.putMetricAlarm(request -> request.alarmName(alarmName) + .comparisonOperator(ComparisonOperator.GREATER_THAN_THRESHOLD) + .evaluationPeriods(2).threshold(5.0).metrics(metrics)); + } + + private void putSingleMetricAlarm(int period) { + cloudWatch.putMetricAlarm(request -> request.alarmName(alarmName) + .comparisonOperator(ComparisonOperator.GREATER_THAN_THRESHOLD) + .evaluationPeriods(2).threshold(5.0).namespace("AWS/ApiGateway") + .metricName("5xx").statistic(Statistic.SUM).period(period)); + } + + private MetricAlarm describe() { + List alarms = cloudWatch.describeAlarms(request -> request.alarmNames(alarmName)).metricAlarms(); + assertThat(alarms).hasSize(1); + return alarms.get(0); + } + + private void assertMathAlarm(List metrics) { + MetricAlarm alarm = describe(); + assertThat(alarm.metrics()).containsExactlyElementsOf(metrics); + assertThat(alarm.period()).isNull(); + assertThat(alarm.metricName()).isNull(); + assertThat(alarm.namespace()).isNull(); + assertThat(alarm.statistic()).isNull(); + assertThat(alarm.extendedStatistic()).isNull(); + assertThat(alarm.unit()).isNull(); + assertThat(alarm.hasDimensions()).isFalse(); + assertThat(alarm.evaluationPeriods()).isEqualTo(2); + assertThat(alarm.threshold()).isEqualTo(5.0); + } + + private static List metrics() { + return List.of( + MetricDataQuery.builder().id("rate").expression("100 * errors / requests") + .returnData(true).period(300).label("Error & requests").build(), + MetricDataQuery.builder().id("errors").returnData(false).accountId("123456789012") + .metricStat(MetricStat.builder().period(300).stat("Sum").unit(StandardUnit.COUNT) + .metric(Metric.builder().namespace("AWS/ApiGateway").metricName("5xx") + .dimensions(Dimension.builder().name("ApiName").value("api<&>").build()) + .build()).build()).build(), + MetricDataQuery.builder().id("requests").returnData(false) + .metricStat(MetricStat.builder().period(300).stat("Sum") + .metric(Metric.builder().namespace("AWS/ApiGateway").metricName("Count").build()) + .build()).build()); + } +} diff --git a/docs/services/cloudwatch.md b/docs/services/cloudwatch.md index e9e1b61..121e9c5 100644 --- a/docs/services/cloudwatch.md +++ b/docs/services/cloudwatch.md @@ -331,6 +331,18 @@ aws logs put-retention-policy \ Metric streams are stored as definitions with their `running` or `stopped` state. Floci never delivers metrics to the Firehose delivery stream a metric stream names. +### Metric-math alarm definitions + +`PutMetricAlarm` stores the `Metrics` query definitions and `DescribeAlarms` returns them over +both JSON and Query protocols. Expressions, labels, `ReturnData`, account IDs, query periods, +and nested metric statistics (including dimensions and units) survive storage reloads. +Metric-math alarm responses omit the single-metric fields, including top-level `Period`. +Updating an alarm replaces its metric definition, so changing between `Metrics` and a single +metric does not retain the previous definition. + +This is management-plane support: the alarm evaluator currently evaluates only single-metric +alarms, not metric-math expressions. `SetAlarmState` remains available for manual transitions. + ### Examples ```bash diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmEvaluator.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmEvaluator.java index f7b0eeb..335ef51 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmEvaluator.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/AlarmEvaluator.java @@ -275,9 +275,10 @@ public class AlarmEvaluator { private static boolean isEvaluable(MetricAlarm alarm) { return alarm.getRegion() != null + && alarm.getMetrics().isEmpty() && alarm.getNamespace() != null && !alarm.getNamespace().isBlank() && alarm.getMetricName() != null && !alarm.getMetricName().isBlank() - && alarm.getPeriod() > 0 + && alarm.getPeriod() != null && alarm.getPeriod() > 0 && alarm.getEvaluationPeriods() > 0 && alarm.getComparisonOperator() != null; } diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java index d0edf31..5aa036d 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsJsonHandler.java @@ -12,6 +12,7 @@ import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMe import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStream; import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamFilter; import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamStatisticsConfiguration; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum; @@ -156,7 +157,13 @@ public class CloudWatchMetricsJsonHandler { alarm.setMetricName(request.path("MetricName").asText(null)); alarm.setNamespace(request.path("Namespace").asText(null)); alarm.setStatistic(request.path("Statistic").asText(null)); - alarm.setPeriod(request.path("Period").asInt(60)); + JsonNode metrics = request.path("Metrics"); + if (metrics.isArray()) { + for (JsonNode query : metrics) { + alarm.getMetrics().add(objectMapper.convertValue(query, AlarmMetricDataQuery.class)); + } + } + alarm.setPeriod(alarm.getMetrics().isEmpty() ? request.path("Period").asInt(60) : null); alarm.setUnit(request.path("Unit").asText(null)); alarm.setEvaluationPeriods(request.path("EvaluationPeriods").asInt(1)); alarm.setDatapointsToAlarm(request.hasNonNull("DatapointsToAlarm") @@ -214,17 +221,31 @@ public class CloudWatchMetricsJsonHandler { a.getOkActions().forEach(okActions::add); ArrayNode insufficientDataActions = node.putArray("InsufficientDataActions"); a.getInsufficientDataActions().forEach(insufficientDataActions::add); - if (a.getMetricName() != null) node.put("MetricName", a.getMetricName()); - if (a.getNamespace() != null) node.put("Namespace", a.getNamespace()); - if (a.getStatistic() != null) node.put("Statistic", a.getStatistic()); - ArrayNode dimensions = node.putArray("Dimensions"); - a.getDimensions().forEach(d -> { - ObjectNode dimNode = dimensions.addObject(); - dimNode.put("Name", d.name()); - dimNode.put("Value", d.value()); - }); - node.put("Period", a.getPeriod()); - if (a.getUnit() != null) node.put("Unit", a.getUnit()); + if (!a.getMetrics().isEmpty()) { + node.set("Metrics", objectMapper.valueToTree(a.getMetrics())); + } else { + if (a.getMetricName() != null) { + node.put("MetricName", a.getMetricName()); + } + if (a.getNamespace() != null) { + node.put("Namespace", a.getNamespace()); + } + if (a.getStatistic() != null) { + node.put("Statistic", a.getStatistic()); + } + ArrayNode dimensions = node.putArray("Dimensions"); + a.getDimensions().forEach(d -> { + ObjectNode dimNode = dimensions.addObject(); + dimNode.put("Name", d.name()); + dimNode.put("Value", d.value()); + }); + if (a.getPeriod() != null) { + node.put("Period", a.getPeriod()); + } + if (a.getUnit() != null) { + node.put("Unit", a.getUnit()); + } + } node.put("EvaluationPeriods", a.getEvaluationPeriods()); if (a.getDatapointsToAlarm() != null) { node.put("DatapointsToAlarm", a.getDatapointsToAlarm()); diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java index 61584f1..6d67684 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricsQueryHandler.java @@ -11,6 +11,7 @@ import io.github.hectorvent.floci.services.cloudwatch.metricstreams.CloudWatchMe import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStream; import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamFilter; import io.github.hectorvent.floci.services.cloudwatch.metricstreams.model.MetricStreamStatisticsConfiguration; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.AlarmMetricDataQuery; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.Dimension; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricDatum; @@ -660,7 +661,8 @@ public class CloudWatchMetricsQueryHandler { a.setMetricName(params.getFirst("MetricName")); a.setNamespace(params.getFirst("Namespace")); a.setStatistic(params.getFirst("Statistic")); - a.setPeriod(parseIntParam(params, "Period", 60)); + a.setMetrics(parseAlarmMetricQueries(params)); + a.setPeriod(a.getMetrics().isEmpty() ? parseIntParam(params, "Period", 60) : null); a.setUnit(params.getFirst("Unit")); a.setEvaluationPeriods(parseIntParam(params, "EvaluationPeriods", 1)); String datapointsToAlarm = params.getFirst("DatapointsToAlarm"); @@ -709,6 +711,50 @@ public class CloudWatchMetricsQueryHandler { return a; } + private List parseAlarmMetricQueries(MultivaluedMap params) { + List queries = new ArrayList<>(); + for (int index = 1; ; index++) { + String prefix = "Metrics.member." + index; + String id = params.getFirst(prefix + ".Id"); + if (id == null) { + break; + } + AlarmMetricDataQuery.MetricStat metricStat = null; + String metricPrefix = prefix + ".MetricStat.Metric"; + if (params.getFirst(metricPrefix + ".Namespace") != null) { + List dimensions = null; + for (int dimensionIndex = 1; ; dimensionIndex++) { + String dimensionPrefix = metricPrefix + ".Dimensions.member." + dimensionIndex; + String name = params.getFirst(dimensionPrefix + ".Name"); + if (name == null) { + break; + } + if (dimensions == null) { + dimensions = new ArrayList<>(); + } + dimensions.add(new AlarmMetricDataQuery.MetricDimension( + name, params.getFirst(dimensionPrefix + ".Value"))); + } + AlarmMetricDataQuery.Metric metric = new AlarmMetricDataQuery.Metric( + params.getFirst(metricPrefix + ".Namespace"), + params.getFirst(metricPrefix + ".MetricName"), dimensions); + metricStat = new AlarmMetricDataQuery.MetricStat(metric, + optionalInteger(params.getFirst(prefix + ".MetricStat.Period")), + params.getFirst(prefix + ".MetricStat.Stat"), params.getFirst(prefix + ".MetricStat.Unit")); + } + String returnData = params.getFirst(prefix + ".ReturnData"); + queries.add(new AlarmMetricDataQuery(id, params.getFirst(prefix + ".Expression"), + params.getFirst(prefix + ".Label"), returnData == null ? null : Boolean.valueOf(returnData), + optionalInteger(params.getFirst(prefix + ".Period")), + params.getFirst(prefix + ".AccountId"), metricStat)); + } + return queries; + } + + private static Integer optionalInteger(String value) { + return value == null ? null : Integer.valueOf(value); + } + private void toAlarmXml(XmlBuilder xml, MetricAlarm a) { xml.start("member") .elem("AlarmName", a.getAlarmName()) @@ -726,21 +772,25 @@ public class CloudWatchMetricsQueryHandler { xml.start("InsufficientDataActions"); a.getInsufficientDataActions().forEach(act -> xml.elem("member", act)); xml.end("InsufficientDataActions"); - xml.start("Dimensions"); - for (Dimension d : a.getDimensions()) { - xml.start("member").elem("Name", d.name()).elem("Value", d.value()).end("member"); + if (!a.getMetrics().isEmpty()) { + toAlarmMetricsXml(xml, a.getMetrics()); + } else { + xml.start("Dimensions"); + for (Dimension d : a.getDimensions()) { + xml.start("member").elem("Name", d.name()).elem("Value", d.value()).end("member"); + } + xml.end("Dimensions") + .elem("MetricName", a.getMetricName()) + .elem("Namespace", a.getNamespace()) + .elem("Statistic", a.getStatistic()) + .elem("Period", a.getPeriod() == null ? null : a.getPeriod().toString()) + .elem("Unit", a.getUnit()); } - xml.end("Dimensions"); xml.elem("StateValue", a.getStateValue()) .elem("StateReason", a.getStateReason()) .elem("StateReasonData", a.getStateReasonData()) .elem("StateUpdatedTimestamp", Instant.ofEpochSecond(a.getStateUpdatedTimestamp()).toString()) - .elem("MetricName", a.getMetricName()) - .elem("Namespace", a.getNamespace()) - .elem("Statistic", a.getStatistic()) - .elem("Period", String.valueOf(a.getPeriod())) - .elem("Unit", a.getUnit()) .elem("EvaluationPeriods", String.valueOf(a.getEvaluationPeriods())) .elem("Threshold", String.valueOf(a.getThreshold())) .elem("ComparisonOperator", a.getComparisonOperator()) @@ -753,6 +803,37 @@ public class CloudWatchMetricsQueryHandler { xml.end("member"); } + private void toAlarmMetricsXml(XmlBuilder xml, List metrics) { + xml.start("Metrics"); + for (AlarmMetricDataQuery query : metrics) { + xml.start("member").elem("Id", query.id()).elem("Expression", query.expression()) + .elem("Label", query.label()).elem("AccountId", query.accountId()) + .elem("Period", query.period() == null ? null : query.period().toString()) + .elem("ReturnData", query.returnData() == null ? null : query.returnData().toString()); + AlarmMetricDataQuery.MetricStat stat = query.metricStat(); + if (stat != null) { + xml.start("MetricStat"); + AlarmMetricDataQuery.Metric metric = stat.metric(); + if (metric != null) { + xml.start("Metric").elem("Namespace", metric.namespace()).elem("MetricName", metric.metricName()); + if (metric.dimensions() != null) { + xml.start("Dimensions"); + for (AlarmMetricDataQuery.MetricDimension dimension : metric.dimensions()) { + xml.start("member").elem("Name", dimension.name()).elem("Value", dimension.value()) + .end("member"); + } + xml.end("Dimensions"); + } + xml.end("Metric"); + } + xml.elem("Period", stat.period() == null ? null : stat.period().toString()) + .elem("Stat", stat.stat()).elem("Unit", stat.unit()).end("MetricStat"); + } + xml.end("member"); + } + xml.end("Metrics"); + } + private Instant parseInstant(String value) { if (value == null || value.isBlank()) { return null; diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/AlarmMetricDataQuery.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/AlarmMetricDataQuery.java new file mode 100644 index 0000000..effc9f6 --- /dev/null +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/AlarmMetricDataQuery.java @@ -0,0 +1,46 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.List; + +/** The persisted AWS wire shape of a metric-math alarm query, including optional members. */ +@RegisterForReflection +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AlarmMetricDataQuery( + @JsonProperty("Id") String id, + @JsonProperty("Expression") String expression, + @JsonProperty("Label") String label, + @JsonProperty("ReturnData") Boolean returnData, + @JsonProperty("Period") Integer period, + @JsonProperty("AccountId") String accountId, + @JsonProperty("MetricStat") MetricStat metricStat) { + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record MetricStat( + @JsonProperty("Metric") Metric metric, + @JsonProperty("Period") Integer period, + @JsonProperty("Stat") String stat, + @JsonProperty("Unit") String unit) { } + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Metric( + @JsonProperty("Namespace") String namespace, + @JsonProperty("MetricName") String metricName, + @JsonProperty("Dimensions") List dimensions) { } + + @RegisterForReflection + @JsonIgnoreProperties(ignoreUnknown = true) + @JsonInclude(JsonInclude.Include.NON_NULL) + public record MetricDimension( + @JsonProperty("Name") String name, + @JsonProperty("Value") String value) { } +} diff --git a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java index e23c518..7347be3 100644 --- a/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java +++ b/src/main/java/io/github/hectorvent/floci/services/cloudwatch/metrics/model/MetricAlarm.java @@ -28,7 +28,9 @@ public class MetricAlarm { private String namespace; private String statistic; private List dimensions = new ArrayList<>(); - private int period; + /** Single-metric period; absent for alarms whose periods are defined in Metrics. */ + private Integer period; + private List metrics = new ArrayList<>(); private String unit; private int evaluationPeriods; /** @@ -100,8 +102,13 @@ public class MetricAlarm { public List getDimensions() { return dimensions; } public void setDimensions(List dimensions) { this.dimensions = dimensions; } - public int getPeriod() { return period; } - public void setPeriod(int period) { this.period = period; } + public Integer getPeriod() { return period; } + public void setPeriod(Integer period) { this.period = period; } + + public List getMetrics() { return metrics; } + public void setMetrics(List metrics) { + this.metrics = metrics == null ? new ArrayList<>() : new ArrayList<>(metrics); + } public String getUnit() { return unit; } public void setUnit(String unit) { this.unit = unit; } diff --git a/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java new file mode 100644 index 0000000..e7b16a9 --- /dev/null +++ b/src/test/java/io/github/hectorvent/floci/services/cloudwatch/metrics/CloudWatchMetricMathAlarmTest.java @@ -0,0 +1,218 @@ +package io.github.hectorvent.floci.services.cloudwatch.metrics; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.core.common.RegionResolver; +import io.github.hectorvent.floci.core.common.XmlParser; +import io.github.hectorvent.floci.core.storage.InMemoryStorage; +import io.github.hectorvent.floci.core.storage.PersistentStorage; +import io.github.hectorvent.floci.core.storage.StorageBackend; +import io.github.hectorvent.floci.services.cloudwatch.dashboards.CloudWatchDashboardsService; +import io.github.hectorvent.floci.services.cloudwatch.metrics.model.MetricAlarm; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.w3c.dom.Document; + +import javax.xml.xpath.XPath; +import javax.xml.xpath.XPathFactory; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class CloudWatchMetricMathAlarmTest { + + private static final String REGION = "us-east-1"; + private static final String ALARM_NAME = "metric-math-round-trip"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String XML_ALARM = "/DescribeAlarmsResponse/DescribeAlarmsResult/MetricAlarms/member"; + + private CloudWatchMetricsJsonHandler jsonHandler; + private CloudWatchMetricsQueryHandler queryHandler; + + @BeforeEach + void setup() { + initialize(new InMemoryStorage<>()); + } + + private void initialize(StorageBackend alarmStore) { + RegionResolver resolver = new RegionResolver(REGION, "000000000000"); + CloudWatchMetricsService service = new CloudWatchMetricsService(new InMemoryStorage<>(), alarmStore, resolver); + CloudWatchDashboardsService dashboards = new CloudWatchDashboardsService(new InMemoryStorage<>(), resolver); + jsonHandler = new CloudWatchMetricsJsonHandler(service, dashboards, null, MAPPER); + queryHandler = new CloudWatchMetricsQueryHandler(service, dashboards, null); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void metricMathQueriesRoundTripThroughBothProtocols(boolean queryProtocol) throws Exception { + ObjectNode request = metricMathRequest(); + put(request, queryProtocol); + assertMetricMathAlarm(request); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void metricMathQueriesSurvivePersistentStorageReload(boolean queryProtocol, @TempDir Path directory) + throws Exception { + Path storageFile = directory.resolve("alarms.json"); + TypeReference> type = new TypeReference<>() { }; + initialize(new PersistentStorage<>(storageFile, type)); + ObjectNode request = metricMathRequest(); + put(request, queryProtocol); + + PersistentStorage reloaded = new PersistentStorage<>(storageFile, type); + reloaded.load(); + assertEquals(1, reloaded.keys().size(), "the stored alarm must deserialize after a restart"); + initialize(reloaded); + assertMetricMathAlarm(request); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void switchingBetweenSingleMetricAndMetricMathReplacesThePreviousDefinition(boolean queryProtocol) + throws Exception { + ObjectNode singleMetric = singleMetricRequest(); + put(singleMetric, queryProtocol); + assertSingleMetricAlarm(300); + + ObjectNode math = metricMathRequest(); + put(math, !queryProtocol); + assertMetricMathAlarm(math); + + singleMetric.put("Period", 120); + put(singleMetric, queryProtocol); + assertSingleMetricAlarm(120); + } + + @Test + void existingSingleMetricDefaultPeriodIsPreserved() throws Exception { + ObjectNode singleMetric = singleMetricRequest(); + singleMetric.remove("Period"); + put(singleMetric, false); + assertSingleMetricAlarm(60); + } + + private void put(ObjectNode request, boolean queryProtocol) { + if (queryProtocol) { + assertEquals(200, queryHandler.handle("PutMetricAlarm", toQuery(request), REGION).getStatus()); + } else { + assertEquals(200, jsonHandler.handle("PutMetricAlarm", request, REGION).getStatus()); + } + } + + private JsonNode describeJson() { + ObjectNode request = MAPPER.createObjectNode(); + request.putArray("AlarmNames").add(ALARM_NAME); + JsonNode response = (JsonNode) jsonHandler.handle("DescribeAlarms", request, REGION).getEntity(); + assertEquals(1, response.path("MetricAlarms").size()); + return response.path("MetricAlarms").get(0); + } + + private Document describeXml() throws Exception { + MultivaluedMap params = new MultivaluedHashMap<>(); + params.putSingle("AlarmNames.member.1", ALARM_NAME); + String xml = (String) queryHandler.handle("DescribeAlarms", params, REGION).getEntity(); + return XmlParser.parseDocument(xml); + } + + private void assertMetricMathAlarm(ObjectNode request) throws Exception { + JsonNode alarm = describeJson(); + assertEquals(request.path("Metrics"), alarm.path("Metrics")); + Document document = describeXml(); + XPath xpath = XPathFactory.newInstance().newXPath(); + for (String field : new String[]{"Period", "MetricName", "Namespace", "Statistic", "ExtendedStatistic", + "Unit", "Dimensions"}) { + assertFalse(alarm.has(field), "metric-math JSON must not contain " + field); + assertEquals("0", xpath.evaluate("count(" + XML_ALARM + "/" + field + ")", document), + "metric-math XML must not contain " + field); + } + assertEquals("3", xpath.evaluate("count(" + XML_ALARM + "/Metrics/member)", document)); + assertEquals("5", xpath.evaluate(XML_ALARM + "/Threshold", document).replace(".0", "")); + assertEquals("2", xpath.evaluate(XML_ALARM + "/EvaluationPeriods", document)); + assertEquals(5.0, alarm.path("Threshold").asDouble()); + assertEquals(2, alarm.path("EvaluationPeriods").asInt()); + assertXmlMatchesJson(request.path("Metrics"), XML_ALARM + "/Metrics", document, xpath); + } + + private void assertSingleMetricAlarm(int period) throws Exception { + JsonNode alarm = describeJson(); + assertEquals(period, alarm.path("Period").asInt()); + assertEquals("AWS/ApiGateway", alarm.path("Namespace").asText()); + assertEquals("5xx", alarm.path("MetricName").asText()); + assertEquals("Sum", alarm.path("Statistic").asText()); + assertFalse(alarm.has("Metrics")); + Document document = describeXml(); + XPath xpath = XPathFactory.newInstance().newXPath(); + assertEquals(Integer.toString(period), xpath.evaluate(XML_ALARM + "/Period", document)); + assertEquals("0", xpath.evaluate("count(" + XML_ALARM + "/Metrics)", document)); + } + + private static void assertXmlMatchesJson(JsonNode value, String path, Document document, XPath xpath) + throws Exception { + if (value.isArray()) { + assertEquals(Integer.toString(value.size()), xpath.evaluate("count(" + path + "/member)", document)); + for (int index = 0; index < value.size(); index++) { + assertXmlMatchesJson(value.get(index), path + "/member[" + (index + 1) + "]", document, xpath); + } + } else if (value.isObject()) { + for (Map.Entry field : value.properties()) { + assertXmlMatchesJson(field.getValue(), path + "/" + field.getKey(), document, xpath); + } + } else { + assertEquals(value.asText(), xpath.evaluate(path, document), path); + } + } + + private static MultivaluedMap toQuery(JsonNode value) { + MultivaluedMap params = new MultivaluedHashMap<>(); + flatten(value, "", params); + return params; + } + + private static void flatten(JsonNode value, String prefix, MultivaluedMap params) { + if (value.isArray()) { + for (int index = 0; index < value.size(); index++) { + flatten(value.get(index), prefix + ".member." + (index + 1), params); + } + } else if (value.isObject()) { + for (Map.Entry field : value.properties()) { + flatten(field.getValue(), prefix.isEmpty() ? field.getKey() : prefix + "." + field.getKey(), params); + } + } else { + params.putSingle(prefix, value.asText()); + } + } + + private static ObjectNode metricMathRequest() throws Exception { + return (ObjectNode) MAPPER.readTree(""" + {"AlarmName":"metric-math-round-trip","EvaluationPeriods":2,"Threshold":5, + "ComparisonOperator":"GreaterThanThreshold","Metrics":[ + {"Id":"rate","Expression":"100 * errors / requests","ReturnData":true, + "Period":300,"Label":"Error & requests"}, + {"Id":"errors","ReturnData":false,"AccountId":"123456789012","Label":"Errors", + "MetricStat":{"Metric":{"Namespace":"AWS/ApiGateway","MetricName":"5xx", + "Dimensions":[{"Name":"ApiName","Value":"api<&>"},{"Name":"Stage","Value":"test"}]}, + "Period":300,"Stat":"Sum","Unit":"Count"}}, + {"Id":"requests","ReturnData":false,"MetricStat":{ + "Metric":{"Namespace":"AWS/ApiGateway","MetricName":"Count"},"Period":300,"Stat":"Sum"}} + ]} + """); + } + + private static ObjectNode singleMetricRequest() throws Exception { + return (ObjectNode) MAPPER.readTree(""" + {"AlarmName":"metric-math-round-trip","Namespace":"AWS/ApiGateway","MetricName":"5xx", + "Statistic":"Sum","Period":300,"EvaluationPeriods":2,"Threshold":5, + "ComparisonOperator":"GreaterThanThreshold"} + """); + } +}