Skip to content

Commit

Permalink
Add support for percentage-based thresholds (#23)
Browse files Browse the repository at this point in the history
* Add support for percentage-based thresholds

* Add unit test and comments
  • Loading branch information
keatomue committed Jan 17, 2023
1 parent 50cdc5b commit 2332b09
Show file tree
Hide file tree
Showing 17 changed files with 196 additions and 15 deletions.
2 changes: 1 addition & 1 deletion anomalymonitor/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>costexplorer</artifactId>
<version>2.17.162</version>
<version>2.18.39</version>
</dependency>

<!-- https://mvnrepository.com/artifact/software.amazon.cloudformation/aws-cloudformation-rpdk-java-plugin -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
@UtilityClass
public class RequestBuilder {
public static CreateAnomalyMonitorRequest buildCreateAnomalyMonitorRequest(ResourceModel model, ResourceHandlerRequest <ResourceModel> request) {
// This request builder forwards along whatever's in the ResourceModel to a CreateAnomalyMonitorRequest.
// Note that the Create API does not allow for both MonitorDimension and MonitorSpecification at once,
// it's up to the supplier of the ResourceModel to guarantee that
Expression monitorSpec = model.getMonitorSpecification() != null ? Utils.toExpressionFromJson(model.getMonitorSpecification()) : null;
AnomalyMonitor anomalyMonitor = AnomalyMonitor.builder()
.monitorName(model.getMonitorName())
Expand Down
8 changes: 7 additions & 1 deletion anomalysubscription/aws-ce-anomalysubscription.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@
"type": "number",
"minimum": 0
},
"ThresholdExpression": {
"description": "An Expression object in JSON String format used to specify the anomalies that you want to generate alerts for.",
"type": "string"
},
"Frequency": {
"description": "The frequency at which anomaly reports are sent over email. ",
"type": "string",
Expand All @@ -123,7 +127,6 @@
"required": [
"MonitorArnList",
"Subscribers",
"Threshold",
"Frequency",
"SubscriptionName"
],
Expand All @@ -135,6 +138,9 @@
"/properties/AccountId",
"/properties/Subscribers/*/Status"
],
"writeOnlyProperties": [
"/properties/ResourceTags"
],
"primaryIdentifier": [
"/properties/SubscriptionArn"
],
Expand Down
2 changes: 1 addition & 1 deletion anomalysubscription/inputs/inputs_1_create.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"SubscriptionName": "ContractTestSubscriptionName",
"Threshold": 1,
"ThresholdExpression": "{\n \"Dimensions\" : {\n \"Key\" : \"ANOMALY_TOTAL_IMPACT_PERCENTAGE\",\n \"Values\" : [ \"1\" ],\n \"MatchOptions\" : [ \"GREATER_THAN_OR_EQUAL\" ]\n }\n}",
"MonitorArnList": [],
"Subscribers": [
{
Expand Down
2 changes: 1 addition & 1 deletion anomalysubscription/inputs/inputs_1_invalid.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"SubscriptionName": "ContractTestSubscriptionName",
"SubscriptionArn": "arn:aws:ce::313025035011:anomalysubscription/d79c551d-ea6b-4b90-8a9e-e5c40dc3b491",
"Threshold": 1,
"ThresholdExpression": "{\n \"Dimensions\" : {\n \"Key\" : \"ANOMALY_TOTAL_IMPACT_ABSOLUTE\",\n \"Values\" : [ \"1\" ],\n \"MatchOptions\" : [ \"GREATER_THAN_OR_EQUAL\" ]\n }\n}",
"MonitorArnList": [],
"Subscribers": [
{
Expand Down
2 changes: 1 addition & 1 deletion anomalysubscription/inputs/inputs_1_update.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"SubscriptionName": "UpdateContractTestSubscriptionName",
"Threshold": 1000,
"ThresholdExpression": "{\n \"Dimensions\" : {\n \"Key\" : \"ANOMALY_TOTAL_IMPACT_ABSOLUTE\",\n \"Values\" : [ \"1000\" ],\n \"MatchOptions\" : [ \"GREATER_THAN_OR_EQUAL\" ]\n }\n}",
"MonitorArnList": [],
"Subscribers": [
{
Expand Down
2 changes: 1 addition & 1 deletion anomalysubscription/inputs/inputs_2_create.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"SubscriptionName": "ContractTestSubscriptionName",
"Threshold": 1,
"ThresholdExpression": "{\n \"Dimensions\" : {\n \"Key\" : \"ANOMALY_TOTAL_IMPACT_PERCENTAGE\",\n \"Values\" : [ \"1\" ],\n \"MatchOptions\" : [ \"GREATER_THAN_OR_EQUAL\" ]\n }\n}",
"MonitorArnList": [],
"Subscribers": [
{
Expand Down
2 changes: 1 addition & 1 deletion anomalysubscription/inputs/inputs_2_invalid.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"SubscriptionName": "ContractTestSubscriptionName",
"SubscriptionArn": "arn:aws:ce::313025035011:anomalysubscription/d79c551d-ea6b-4b90-8a9e-e5c40dc3b491",
"Threshold": 1,
"ThresholdExpression": "{\n \"Dimensions\" : {\n \"Key\" : \"ANOMALY_TOTAL_IMPACT_PERCENTAGE\",\n \"Values\" : [ \"1\" ],\n \"MatchOptions\" : [ \"GREATER_THAN_OR_EQUAL\" ]\n }\n}",
"MonitorArnList": [],
"Subscribers": [
{
Expand Down
2 changes: 1 addition & 1 deletion anomalysubscription/inputs/inputs_2_update.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"SubscriptionName": "UpdateContractTestSubscriptionName",
"Threshold": 1000,
"ThresholdExpression": "{\n \"Dimensions\" : {\n \"Key\" : \"ANOMALY_TOTAL_IMPACT_PERCENTAGE\",\n \"Values\" : [ \"1000\" ],\n \"MatchOptions\" : [ \"GREATER_THAN_OR_EQUAL\" ]\n }\n}",
"MonitorArnList": [],
"Subscribers": [
{
Expand Down
2 changes: 1 addition & 1 deletion anomalysubscription/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>costexplorer</artifactId>
<version>2.17.162</version>
<version>2.18.39</version>
</dependency>

<!-- https://mvnrepository.com/artifact/software.amazon.cloudformation/aws-cloudformation-rpdk-java-plugin -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;

@SuppressWarnings("deprecation")
public class ListHandler extends AnomalySubscriptionBaseHandler {

public ListHandler() {
Expand Down Expand Up @@ -42,6 +43,7 @@ public ProgressEvent<ResourceModel, CallbackContext> handleRequest(
.accountId(anomalySubscription.accountId())
.monitorArnList(anomalySubscription.monitorArnList())
.threshold(anomalySubscription.threshold())
.thresholdExpression(Utils.toJson(anomalySubscription.thresholdExpression()))
.frequency(anomalySubscription.frequency().toString())
.subscribers(ResourceModelTranslator.toSubscribers(anomalySubscription.subscribers()))
.build()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.util.stream.Collectors;
import java.util.stream.Stream;

@SuppressWarnings("deprecation")
public class ReadHandler extends AnomalySubscriptionBaseHandler {

public ReadHandler() {
Expand Down Expand Up @@ -54,6 +55,7 @@ public ProgressEvent<ResourceModel, CallbackContext> handleRequest(
model.setMonitorArnList(anomalySubscription.monitorArnList());
model.setSubscribers(ResourceModelTranslator.toSubscribers(anomalySubscription.subscribers()));
model.setThreshold(anomalySubscription.threshold());
model.setThresholdExpression(Utils.toJson(anomalySubscription.thresholdExpression()));
model.setFrequency(anomalySubscription.frequency().toString());
} catch (UnknownSubscriptionException e) {
return ProgressEvent.<ResourceModel, CallbackContext>builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@
import java.util.List;

@UtilityClass
@SuppressWarnings("deprecation")
public class RequestBuilder {
public static CreateAnomalySubscriptionRequest buildCreateAnomalySubscriptionRequest(ResourceModel model, ResourceHandlerRequest<ResourceModel> request) {
// This request builder forwards along whatever's in the ResourceModel to a CreateAnomalySubscriptionRequest.
// Note that the Create API does not allow for both Threshold and ThresholdExpression at once,
// it's up to the supplier of the ResourceModel to guarantee that
AnomalySubscription anomalySubscription = AnomalySubscription.builder()
.subscriptionName(model.getSubscriptionName())
.threshold(model.getThreshold())
.thresholdExpression(model.getThresholdExpression() != null ? Utils.toExpressionFromJson(model.getThresholdExpression()) : null)
.frequency(model.getFrequency())
.monitorArnList(model.getMonitorArnList())
.subscribers(ResourceModelTranslator.toSDKSubscribers(model.getSubscribers()))
Expand All @@ -31,10 +36,14 @@ public static CreateAnomalySubscriptionRequest buildCreateAnomalySubscriptionReq
}

public static UpdateAnomalySubscriptionRequest buildUpdateAnomalySubscriptionRequest(ResourceModel model) {
// This request builder forwards along whatever's in the ResourceModel to an UpdateAnomalySubscriptionRequest.
// Note that the Update API does not allow for both Threshold and ThresholdExpression at once,
// it's up to the supplier of the ResourceModel to guarantee that
return UpdateAnomalySubscriptionRequest.builder()
.subscriptionArn(model.getSubscriptionArn())
.subscriptionName(model.getSubscriptionName())
.threshold(model.getThreshold())
.thresholdExpression(model.getThresholdExpression() != null ? Utils.toExpressionFromJson(model.getThresholdExpression()) : null)
.frequency(model.getFrequency())
.subscribers(ResourceModelTranslator.toSDKSubscribers(model.getSubscribers()))
.monitorArnList(model.getMonitorArnList())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package software.amazon.ce.anomalysubscription;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import com.fasterxml.jackson.databind.introspect.AnnotatedClass;
import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector;
import com.google.common.collect.ImmutableMap;

import lombok.experimental.UtilityClass;
import software.amazon.awssdk.services.costexplorer.model.CostCategoryValues;
import software.amazon.awssdk.services.costexplorer.model.DimensionValues;
import software.amazon.awssdk.services.costexplorer.model.Expression;
import software.amazon.awssdk.services.costexplorer.model.TagValues;
import software.amazon.cloudformation.exceptions.CfnInvalidRequestException;

import java.util.Map;

@UtilityClass
public class Utils {
static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static ObjectWriter objectWriter;

static {
OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);

// Use upper case in JSON key
OBJECT_MAPPER.setPropertyNamingStrategy(new PropertyNamingStrategy.UpperCamelCaseStrategy());

// SDK model has private field without getter/setter, make Jackson to use field directly
OBJECT_MAPPER.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);

// Skip null or empty values when serializing to JSON
OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL);
OBJECT_MAPPER.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);

// SDK model has no default constructor, and build class is private.
// Add custom builder so Jackson can deserialize the class.
final Map<Class<?>, Class<?>> buildersMap = ImmutableMap.of(
Expression.class, Expression.serializableBuilderClass(),
DimensionValues.class, DimensionValues.serializableBuilderClass(),
TagValues.class, TagValues.serializableBuilderClass(),
CostCategoryValues.class, CostCategoryValues.serializableBuilderClass()
);
OBJECT_MAPPER.setAnnotationIntrospector(new JacksonAnnotationIntrospector() {
private static final long serialVersionUID = 1L;

@Override
public Class<?> findPOJOBuilder(AnnotatedClass ac) {
if (buildersMap.containsKey(ac.getRawType())) {
return buildersMap.get(ac.getRawType());
}
return super.findPOJOBuilder(ac);
}
});
objectWriter = OBJECT_MAPPER.writerWithDefaultPrettyPrinter();
}

public static Expression toExpressionFromJson(String expressionJson) {
try {
return OBJECT_MAPPER.readValue(expressionJson, Expression.class);
} catch (JsonMappingException e) {
throw new CfnInvalidRequestException(String.format("Unsupported JSON '%s' for Expression", expressionJson), e);
} catch (Exception e) {
throw new CfnInvalidRequestException(String.format("Invalid JSON '%s' for Expression", expressionJson), e);
}
}

public static String toJson(Expression expression) {
try {
return objectWriter.writeValueAsString(expression);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,40 @@ public void handleRequest_Success() {
assertThat(response.getErrorCode()).isNull();
}

@Test
public void handleRequest_Success_thresholdExpression() {
final ResourceModel model = ResourceModel.builder()
.subscriptionName(TestFixtures.SUBSCRIPTION_NAME)
.thresholdExpression(TestFixtures.THRESHOLD_EXPRESSION)
.subscribers(TestFixtures.CFN_MODEL_SUBSCRIBERS)
.frequency(TestFixtures.FREQUENCY)
.monitorArnList(TestFixtures.MONITOR_ARNS)
.build();

final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder()
.desiredResourceState(model)
.build();

final CreateAnomalySubscriptionResponse mockResponse = CreateAnomalySubscriptionResponse.builder()
.subscriptionArn(TestFixtures.SUBSCRIPTION_ARN)
.build();

doReturn(mockResponse)
.when(proxy).injectCredentialsAndInvokeV2(any(), any());

final ProgressEvent<ResourceModel, CallbackContext> response
= handler.handleRequest(proxy, request, null, logger);

assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS);
assertThat(response.getCallbackContext()).isNull();
assertThat(response.getCallbackDelaySeconds()).isEqualTo(0);
assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState());
assertThat(response.getResourceModels()).isNull();
assertThat(response.getMessage()).isNull();
assertThat(response.getErrorCode()).isNull();
}

@Test
public void handleRequest_Success_tagsSubscription() {
final ResourceModel model = ResourceModel.builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
package software.amazon.ce.anomalysubscription;

import software.amazon.awssdk.services.costexplorer.model.*;
import software.amazon.awssdk.services.costexplorer.model.AnomalySubscription;
import software.amazon.awssdk.services.costexplorer.model.Dimension;
import software.amazon.awssdk.services.costexplorer.model.DimensionValues;
import software.amazon.awssdk.services.costexplorer.model.Expression;
import software.amazon.awssdk.services.costexplorer.model.MatchOption;
import software.amazon.awssdk.services.costexplorer.model.Subscriber;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class TestFixtures {
public static String SUBSCRIPTION_NAME = "TestSubscriptionName";
public static String SUBSCRIPTION_ARN = "arn:aws:ce::123456789012:anomalysubscription/subscriptionId";
public static double THRESHOLD = 100;
public static String THRESHOLD_EXPRESSION = "{\"Dimensions\":{\"Key\":\"ANOMALY_TOTAL_IMPACT_PERCENTAGE\",\"MatchOptions\":[\"GREATER_THAN_OR_EQUAL\"],\"Values\":[\"100\"]}}";
public static String FREQUENCY = "DAILY";
public static String NEXT_TOKEN = "nextToken";
public static Subscriber SUBSCRIBER = Subscriber.builder()
.address("test@gmail.com")
.status("CONFIRMED")
.type("EMAIL")
.build();
.address("test@gmail.com")
.status("CONFIRMED")
.type("EMAIL")
.build();
public static List<Subscriber> SUBSCRIBERS = Arrays.asList(SUBSCRIBER);
public static List<software.amazon.ce.anomalysubscription.Subscriber> CFN_MODEL_SUBSCRIBERS = ResourceModelTranslator.toSubscribers(SUBSCRIBERS);
public static String MONITOR_ARN_1 = "arn:aws:ce::123456789012:anomalymonitor/monitorId1";
Expand All @@ -26,7 +32,13 @@ public class TestFixtures {
.subscriptionArn(SUBSCRIPTION_ARN)
.subscriptionName(SUBSCRIPTION_NAME)
.monitorArnList(MONITOR_ARNS)
.threshold(THRESHOLD)
.thresholdExpression(Expression.builder()
.dimensions(DimensionValues.builder()
.key(Dimension.ANOMALY_TOTAL_IMPACT_PERCENTAGE)
.matchOptions(Collections.singletonList(MatchOption.GREATER_THAN_OR_EQUAL))
.values(Collections.singletonList("100"))
.build())
.build())
.subscribers(SUBSCRIBERS)
.frequency(FREQUENCY)
.build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,37 @@ public void handleRequest_SimpleSuccess() {
assertThat(response.getErrorCode()).isNull();
}

@Test
public void handleRequest_SimpleSuccess_ThresholdExpression() {
final ResourceModel model = ResourceModel.builder()
.subscriptionArn(TestFixtures.SUBSCRIPTION_ARN)
.thresholdExpression(TestFixtures.THRESHOLD_EXPRESSION)
.build();

final ResourceHandlerRequest<ResourceModel> request = ResourceHandlerRequest.<ResourceModel>builder()
.desiredResourceState(model)
.build();

final UpdateAnomalySubscriptionResponse mockResponse = UpdateAnomalySubscriptionResponse.builder()
.subscriptionArn(TestFixtures.SUBSCRIPTION_ARN)
.build();

doReturn(mockResponse)
.when(proxy).injectCredentialsAndInvokeV2(any(), any());

final ProgressEvent<ResourceModel, CallbackContext> response
= handler.handleRequest(proxy, request, null, logger);

assertThat(response).isNotNull();
assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS);
assertThat(response.getCallbackContext()).isNull();
assertThat(response.getCallbackDelaySeconds()).isEqualTo(0);
assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState());
assertThat(response.getResourceModels()).isNull();
assertThat(response.getMessage()).isNull();
assertThat(response.getErrorCode()).isNull();
}

@Test
public void handleRequest_Failure_Delete() {
final ResourceModel model = ResourceModel.builder()
Expand Down

0 comments on commit 2332b09

Please sign in to comment.