Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for percentage-based thresholds #23

Merged
merged 2 commits into from
Jan 17, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
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,13 @@
import java.util.List;

@UtilityClass
@SuppressWarnings("deprecation")
public class RequestBuilder {
public static CreateAnomalySubscriptionRequest buildCreateAnomalySubscriptionRequest(ResourceModel model, ResourceHandlerRequest<ResourceModel> request) {
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 @@ -35,6 +37,7 @@ public static UpdateAnomalySubscriptionRequest buildUpdateAnomalySubscriptionReq
.subscriptionArn(model.getSubscriptionArn())
.subscriptionName(model.getSubscriptionName())
.threshold(model.getThreshold())
.thresholdExpression(model.getThresholdExpression() != null ? Utils.toExpressionFromJson(model.getThresholdExpression()) : null)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add a comment to this method about acceptable inputs for Threshold and ThresholdExpression for updates

Copy link
Contributor Author

@keatomue keatomue Jan 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, will add

Update: Added 👍

.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
Copy link

@arodivya arodivya Jan 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a similar test under UpdateHandler? Could that be a good place to document the nuances of Update for Threshold/ThresholdExpression?

Copy link
Contributor Author

@keatomue keatomue Jan 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, can add a similar unit test

Update: Added 👍

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