fix(cloudwatch): retain metric-math alarm query definitions (#4594) #6
+117
@@ -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<MetricDataQuery> metrics = metrics();
|
||||
putMathAlarm(metrics);
|
||||
assertMathAlarm(metrics);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesReplaceQueriesAndSwitchBackToASingleMetric() {
|
||||
putSingleMetricAlarm(300);
|
||||
List<MetricDataQuery> original = metrics();
|
||||
putMathAlarm(original);
|
||||
assertMathAlarm(original);
|
||||
|
||||
List<MetricDataQuery> 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<MetricDataQuery> 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<MetricAlarm> alarms = cloudWatch.describeAlarms(request -> request.alarmNames(alarmName)).metricAlarms();
|
||||
assertThat(alarms).hasSize(1);
|
||||
return alarms.get(0);
|
||||
}
|
||||
|
||||
private void assertMathAlarm(List<MetricDataQuery> 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<MetricDataQuery> metrics() {
|
||||
return List.of(
|
||||
MetricDataQuery.builder().id("rate").expression("100 * errors / requests")
|
||||
.returnData(true).period(300).label("Error <rate> & 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+26
-5
@@ -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());
|
||||
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());
|
||||
}
|
||||
if (a.getUnit() != null) {
|
||||
node.put("Unit", a.getUnit());
|
||||
}
|
||||
}
|
||||
node.put("EvaluationPeriods", a.getEvaluationPeriods());
|
||||
if (a.getDatapointsToAlarm() != null) {
|
||||
node.put("DatapointsToAlarm", a.getDatapointsToAlarm());
|
||||
|
||||
+88
-7
@@ -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<AlarmMetricDataQuery> parseAlarmMetricQueries(MultivaluedMap<String, String> params) {
|
||||
List<AlarmMetricDataQuery> 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<AlarmMetricDataQuery.MetricDimension> 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");
|
||||
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");
|
||||
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.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<AlarmMetricDataQuery> 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;
|
||||
|
||||
+46
@@ -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<MetricDimension> dimensions) { }
|
||||
|
||||
@RegisterForReflection
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record MetricDimension(
|
||||
@JsonProperty("Name") String name,
|
||||
@JsonProperty("Value") String value) { }
|
||||
}
|
||||
+10
-3
@@ -28,7 +28,9 @@ public class MetricAlarm {
|
||||
private String namespace;
|
||||
private String statistic;
|
||||
private List<Dimension> dimensions = new ArrayList<>();
|
||||
private int period;
|
||||
/** Single-metric period; absent for alarms whose periods are defined in Metrics. */
|
||||
private Integer period;
|
||||
private List<AlarmMetricDataQuery> metrics = new ArrayList<>();
|
||||
private String unit;
|
||||
private int evaluationPeriods;
|
||||
/**
|
||||
@@ -100,8 +102,13 @@ public class MetricAlarm {
|
||||
public List<Dimension> getDimensions() { return dimensions; }
|
||||
public void setDimensions(List<Dimension> 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<AlarmMetricDataQuery> getMetrics() { return metrics; }
|
||||
public void setMetrics(List<AlarmMetricDataQuery> metrics) {
|
||||
this.metrics = metrics == null ? new ArrayList<>() : new ArrayList<>(metrics);
|
||||
}
|
||||
|
||||
public String getUnit() { return unit; }
|
||||
public void setUnit(String unit) { this.unit = unit; }
|
||||
|
||||
+218
@@ -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<String, MetricAlarm> 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<Map<String, MetricAlarm>> type = new TypeReference<>() { };
|
||||
initialize(new PersistentStorage<>(storageFile, type));
|
||||
ObjectNode request = metricMathRequest();
|
||||
put(request, queryProtocol);
|
||||
|
||||
PersistentStorage<String, MetricAlarm> 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<String, String> 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<String, JsonNode> field : value.properties()) {
|
||||
assertXmlMatchesJson(field.getValue(), path + "/" + field.getKey(), document, xpath);
|
||||
}
|
||||
} else {
|
||||
assertEquals(value.asText(), xpath.evaluate(path, document), path);
|
||||
}
|
||||
}
|
||||
|
||||
private static MultivaluedMap<String, String> toQuery(JsonNode value) {
|
||||
MultivaluedMap<String, String> params = new MultivaluedHashMap<>();
|
||||
flatten(value, "", params);
|
||||
return params;
|
||||
}
|
||||
|
||||
private static void flatten(JsonNode value, String prefix, MultivaluedMap<String, String> 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<String, JsonNode> 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 <rate> & 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"}
|
||||
""");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user