From 3b038aa3c67f9eb9258c879aeeb5627f8be1ab9d Mon Sep 17 00:00:00 2001 From: Edgar Lopez Date: Wed, 15 Sep 2021 13:55:44 -0600 Subject: [PATCH 1/3] feat: added resource for anomaly monitor and subscription with its tests docs --- aws/config.go | 3 + .../service/costexplorer/waiter/waiter.go | 14 + aws/provider.go | 2 + aws/resource_aws_ce_anomaly_monitor.go | 448 ++++++++++++++++++ aws/resource_aws_ce_anomaly_monitor_test.go | 125 +++++ aws/resource_aws_ce_anomaly_subscription.go | 268 +++++++++++ ...source_aws_ce_anomaly_subscription_test.go | 138 ++++++ .../docs/r/ce_anomaly_monitor.html.markdown | 80 ++++ .../r/ce_anomaly_subscription.html.markdown | 66 +++ 9 files changed, 1144 insertions(+) create mode 100644 aws/internal/service/costexplorer/waiter/waiter.go create mode 100644 aws/resource_aws_ce_anomaly_monitor.go create mode 100644 aws/resource_aws_ce_anomaly_monitor_test.go create mode 100644 aws/resource_aws_ce_anomaly_subscription.go create mode 100644 aws/resource_aws_ce_anomaly_subscription_test.go create mode 100644 website/docs/r/ce_anomaly_monitor.html.markdown create mode 100644 website/docs/r/ce_anomaly_subscription.html.markdown diff --git a/aws/config.go b/aws/config.go index ff09b2920648..0b9dc948a075 100644 --- a/aws/config.go +++ b/aws/config.go @@ -50,6 +50,7 @@ import ( "github.com/aws/aws-sdk-go/service/configservice" "github.com/aws/aws-sdk-go/service/connect" "github.com/aws/aws-sdk-go/service/costandusagereportservice" + "github.com/aws/aws-sdk-go/service/costexplorer" "github.com/aws/aws-sdk-go/service/databasemigrationservice" "github.com/aws/aws-sdk-go/service/dataexchange" "github.com/aws/aws-sdk-go/service/datapipeline" @@ -260,6 +261,7 @@ type AWSClient struct { configconn *configservice.ConfigService connectconn *connect.Connect costandusagereportconn *costandusagereportservice.CostandUsageReportService + costexplorerconn *costexplorer.CostExplorer dataexchangeconn *dataexchange.DataExchange datapipelineconn *datapipeline.DataPipeline datasyncconn *datasync.DataSync @@ -514,6 +516,7 @@ func (c *Config) Client() (interface{}, error) { configconn: configservice.New(sess.Copy(&aws.Config{Endpoint: aws.String(c.Endpoints["configservice"])})), connectconn: connect.New(sess.Copy(&aws.Config{Endpoint: aws.String(c.Endpoints["connect"])})), costandusagereportconn: costandusagereportservice.New(sess.Copy(&aws.Config{Endpoint: aws.String(c.Endpoints["cur"])})), + costexplorerconn: costexplorer.New(sess.Copy(&aws.Config{Endpoint: aws.String(c.Endpoints["ce"])})), dataexchangeconn: dataexchange.New(sess.Copy(&aws.Config{Endpoint: aws.String(c.Endpoints["dataexchange"])})), datapipelineconn: datapipeline.New(sess.Copy(&aws.Config{Endpoint: aws.String(c.Endpoints["datapipeline"])})), datasyncconn: datasync.New(sess.Copy(&aws.Config{Endpoint: aws.String(c.Endpoints["datasync"])})), diff --git a/aws/internal/service/costexplorer/waiter/waiter.go b/aws/internal/service/costexplorer/waiter/waiter.go new file mode 100644 index 000000000000..956d915ae72d --- /dev/null +++ b/aws/internal/service/costexplorer/waiter/waiter.go @@ -0,0 +1,14 @@ +package waiter + +import ( + "time" +) + +const ( + // CostCategoryDefinitionOperationTimeout Maximum amount of time to wait for Cost Category eventual consistency + CostCategoryDefinitionOperationTimeout = 4 * time.Minute + // AnomalyMonitorOperationTimeout Maximum amount of time to wait for AnomalyMonitor eventual consistency + AnomalyMonitorOperationTimeout = 4 * time.Minute + // AnomalySubscriptionOperationTimeout Maximum amount of time to wait for AnomalySubscription eventual consistency + AnomalySubscriptionOperationTimeout = 4 * time.Minute +) diff --git a/aws/provider.go b/aws/provider.go index 93e42452494b..ad3e0d71b147 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -555,6 +555,8 @@ func Provider() *schema.Provider { "aws_backup_vault_policy": resourceAwsBackupVaultPolicy(), "aws_budgets_budget": resourceAwsBudgetsBudget(), "aws_budgets_budget_action": resourceAwsBudgetsBudgetAction(), + "aws_ce_anomaly_monitor": resourceAwsCEAnomalyMonitor(), + "aws_ce_anomaly_subscription": resourceAwsCEAnomalySubscription(), "aws_chime_voice_connector": resourceAwsChimeVoiceConnector(), "aws_chime_voice_connector_group": resourceAwsChimeVoiceConnectorGroup(), "aws_cloud9_environment_ec2": resourceAwsCloud9EnvironmentEc2(), diff --git a/aws/resource_aws_ce_anomaly_monitor.go b/aws/resource_aws_ce_anomaly_monitor.go new file mode 100644 index 000000000000..5755727af5d0 --- /dev/null +++ b/aws/resource_aws_ce_anomaly_monitor.go @@ -0,0 +1,448 @@ +package aws + +import ( + "context" + "fmt" + "log" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/costexplorer" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/costexplorer/waiter" +) + +func resourceAwsCEAnomalyMonitor() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceAwsCEAnomalyMonitorCreate, + ReadWithoutTimeout: resourceAwsCEAnomalyMonitorRead, + UpdateWithoutTimeout: resourceAwsCEAnomalyMonitorUpdate, + DeleteWithoutTimeout: resourceAwsCEAnomalyMonitorDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "creation_date": { + Type: schema.TypeString, + Computed: true, + }, + "dimensional_value_count": { + Type: schema.TypeInt, + Computed: true, + }, + "last_evaluated_date": { + Type: schema.TypeString, + Computed: true, + }, + "last_updated_date": { + Type: schema.TypeString, + Computed: true, + }, + "monitor_dimension": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(costexplorer.MonitorDimension_Values(), false), + }, + "monitor_name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + "monitor_specification": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: schemaAWSCECostCategoryRule(), + }, + "monitor_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(costexplorer.MonitorType_Values(), false), + }, + }, + } +} + +func schemaAWSCECostCategoryRule() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "cost_category": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringLenBetween(1, 50), + }, + "match_options": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice(costexplorer.MatchOption_Values(), false), + }, + Set: schema.HashString, + }, + "values": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + Set: schema.HashString, + }, + }, + }, + }, + "dimension": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(costexplorer.Dimension_Values(), false), + }, + "match_options": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice(costexplorer.MatchOption_Values(), false), + }, + Set: schema.HashString, + }, + "values": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + Set: schema.HashString, + }, + }, + }, + }, + "tags": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Optional: true, + }, + "match_options": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice(costexplorer.MatchOption_Values(), false), + }, + Set: schema.HashString, + }, + "values": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + Set: schema.HashString, + }, + }, + }, + }, + }, + } +} + +func resourceAwsCEAnomalyMonitorCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*AWSClient).costexplorerconn + input := &costexplorer.AnomalyMonitor{ + MonitorName: aws.String(d.Get("monitor_name").(string)), + MonitorType: aws.String(d.Get("monitor_type").(string)), + } + + if v, ok := d.GetOk("monitor_dimension"); ok { + input.MonitorDimension = aws.String(v.(string)) + } + + if v, ok := d.GetOk("monitor_specification"); ok { + input.MonitorSpecification = expandCECostExpressions(v.([]interface{}))[0] + } + var err error + var output *costexplorer.CreateAnomalyMonitorOutput + err = resource.RetryContext(ctx, waiter.AnomalyMonitorOperationTimeout, func() *resource.RetryError { + output, err = conn.CreateAnomalyMonitor(&costexplorer.CreateAnomalyMonitorInput{ + AnomalyMonitor: input, + }) + if err != nil { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + return resource.RetryableError(err) + } + + return resource.NonRetryableError(err) + } + + return nil + }) + + if isResourceTimeoutError(err) { + output, err = conn.CreateAnomalyMonitor(&costexplorer.CreateAnomalyMonitorInput{ + AnomalyMonitor: input, + }) + } + + if err != nil { + return diag.FromErr(fmt.Errorf("error creating CE Anomaly Monitor (%s): %w", d.Id(), err)) + } + + d.SetId(aws.StringValue(output.MonitorArn)) + + return resourceAwsCEAnomalyMonitorRead(ctx, d, meta) +} + +func resourceAwsCEAnomalyMonitorRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*AWSClient).costexplorerconn + + resp, err := conn.GetAnomalyMonitorsWithContext(ctx, &costexplorer.GetAnomalyMonitorsInput{MonitorArnList: []*string{aws.String(d.Id())}}) + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + log.Printf("[WARN] CE Anomaly Monitor (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return diag.FromErr(fmt.Errorf("error reading CE Anomaly Monitor (%s): %w", d.Id(), err)) + } + for _, v := range resp.AnomalyMonitors { + d.Set("creation_date", v.CreationDate) + d.Set("dimensional_value_count", v.DimensionalValueCount) + d.Set("last_evaluated_date", v.LastEvaluatedDate) + d.Set("last_updated_date", v.LastUpdatedDate) + d.Set("monitor_dimension", v.MonitorDimension) + d.Set("monitor_name", v.MonitorName) + if err = d.Set("monitor_specification", flattenCECostCategoryRuleExpression(v.MonitorSpecification)); err != nil { + return diag.FromErr(fmt.Errorf("error setting `%s` for CE Anomaly Monitor (%s): %w", "monitor_specification", d.Id(), err)) + } + d.Set("monitor_type", v.MonitorType) + } + + return nil +} + +func resourceAwsCEAnomalyMonitorUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*AWSClient).costexplorerconn + + input := &costexplorer.UpdateAnomalyMonitorInput{ + MonitorArn: aws.String(d.Id()), + } + + if d.HasChange("monitor_name") { + input.MonitorName = aws.String(d.Get("monitor_name").(string)) + } + + _, err := conn.UpdateAnomalyMonitorWithContext(ctx, input) + + if err != nil { + diag.FromErr(fmt.Errorf("error updating CE Anomaly Monitor (%s): %w", d.Id(), err)) + } + + return resourceAwsCEAnomalyMonitorRead(ctx, d, meta) +} + +func resourceAwsCEAnomalyMonitorDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*AWSClient).costexplorerconn + + _, err := conn.DeleteAnomalyMonitorWithContext(ctx, &costexplorer.DeleteAnomalyMonitorInput{ + MonitorArn: aws.String(d.Id()), + }) + if err != nil { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + return nil + } + return diag.FromErr(fmt.Errorf("error deleting CE Anomaly Monitor (%s): %w", d.Id(), err)) + } + + return nil +} +func expandCECostExpression(tfMap map[string]interface{}) *costexplorer.Expression { + if tfMap == nil { + return nil + } + + apiObject := &costexplorer.Expression{} + if v, ok := tfMap["cost_category"]; ok { + apiObject.CostCategories = expandCECostExpressionCostCategory(v.([]interface{})) + } + if v, ok := tfMap["dimension"]; ok { + apiObject.Dimensions = expandCECostExpressionDimension(v.([]interface{})) + } + if v, ok := tfMap["tags"]; ok { + apiObject.Tags = expandCECostExpressionTag(v.([]interface{})) + } + + return apiObject +} + +func expandCECostExpressionCostCategory(tfList []interface{}) *costexplorer.CostCategoryValues { + if len(tfList) == 0 { + return nil + } + + tfMap := tfList[0].(map[string]interface{}) + + apiObject := &costexplorer.CostCategoryValues{} + if v, ok := tfMap["key"]; ok { + apiObject.Key = aws.String(v.(string)) + } + if v, ok := tfMap["match_options"]; ok { + apiObject.MatchOptions = expandStringSet(v.(*schema.Set)) + } + if v, ok := tfMap["values"]; ok { + apiObject.Values = expandStringSet(v.(*schema.Set)) + } + + return apiObject +} + +func expandCECostExpressionDimension(tfList []interface{}) *costexplorer.DimensionValues { + if len(tfList) == 0 { + return nil + } + + tfMap := tfList[0].(map[string]interface{}) + + apiObject := &costexplorer.DimensionValues{} + if v, ok := tfMap["key"]; ok { + apiObject.Key = aws.String(v.(string)) + } + if v, ok := tfMap["match_options"]; ok { + apiObject.MatchOptions = expandStringSet(v.(*schema.Set)) + } + if v, ok := tfMap["values"]; ok { + apiObject.Values = expandStringSet(v.(*schema.Set)) + } + + return apiObject +} + +func expandCECostExpressionTag(tfList []interface{}) *costexplorer.TagValues { + if len(tfList) == 0 { + return nil + } + + tfMap := tfList[0].(map[string]interface{}) + + apiObject := &costexplorer.TagValues{} + if v, ok := tfMap["key"]; ok { + apiObject.Key = aws.String(v.(string)) + } + if v, ok := tfMap["match_options"]; ok { + apiObject.MatchOptions = expandStringSet(v.(*schema.Set)) + } + if v, ok := tfMap["values"]; ok { + apiObject.Values = expandStringSet(v.(*schema.Set)) + } + + return apiObject +} + +func expandCECostExpressions(tfList []interface{}) []*costexplorer.Expression { + if len(tfList) == 0 { + return nil + } + + var apiObjects []*costexplorer.Expression + + for _, tfMapRaw := range tfList { + tfMap, ok := tfMapRaw.(map[string]interface{}) + + if !ok { + continue + } + + apiObject := expandCECostExpression(tfMap) + + apiObjects = append(apiObjects, apiObject) + } + + return apiObjects +} + +func flattenCECostCategoryRuleExpression(apiObject *costexplorer.Expression) map[string]interface{} { + if apiObject == nil { + return nil + } + + tfMap := map[string]interface{}{} + tfMap["cost_category"] = flattenCECostCategoryRuleExpressionCostCategory(apiObject.CostCategories) + tfMap["dimension"] = flattenCECostCategoryRuleExpressionDimension(apiObject.Dimensions) + tfMap["tags"] = flattenCECostCategoryRuleExpressionTag(apiObject.Tags) + + return tfMap +} + +func flattenCECostCategoryRuleExpressionCostCategory(apiObject *costexplorer.CostCategoryValues) []map[string]interface{} { + if apiObject == nil { + return nil + } + + var tfList []map[string]interface{} + tfMap := map[string]interface{}{} + + tfMap["key"] = aws.StringValue(apiObject.Key) + tfMap["match_options"] = flattenStringList(apiObject.MatchOptions) + tfMap["values"] = flattenStringList(apiObject.Values) + + tfList = append(tfList, tfMap) + + return tfList +} + +func flattenCECostCategoryRuleExpressionDimension(apiObject *costexplorer.DimensionValues) []map[string]interface{} { + if apiObject == nil { + return nil + } + + var tfList []map[string]interface{} + tfMap := map[string]interface{}{} + + tfMap["key"] = aws.StringValue(apiObject.Key) + tfMap["match_options"] = flattenStringList(apiObject.MatchOptions) + tfMap["values"] = flattenStringList(apiObject.Values) + + tfList = append(tfList, tfMap) + + return tfList +} + +func flattenCECostCategoryRuleExpressionTag(apiObject *costexplorer.TagValues) []map[string]interface{} { + if apiObject == nil { + return nil + } + + var tfList []map[string]interface{} + tfMap := map[string]interface{}{} + + tfMap["key"] = aws.StringValue(apiObject.Key) + tfMap["match_options"] = flattenStringList(apiObject.MatchOptions) + tfMap["values"] = flattenStringList(apiObject.Values) + + tfList = append(tfList, tfMap) + + return tfList +} diff --git a/aws/resource_aws_ce_anomaly_monitor_test.go b/aws/resource_aws_ce_anomaly_monitor_test.go new file mode 100644 index 000000000000..604ca8c70b22 --- /dev/null +++ b/aws/resource_aws_ce_anomaly_monitor_test.go @@ -0,0 +1,125 @@ +package aws + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/costexplorer" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccAwsCEAnomalyMonitor_basic(t *testing.T) { + var output costexplorer.AnomalyMonitor + resourceName := "aws_ce_anomaly_monitor.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckAwsCEAnomalyMonitorDestroy, + ErrorCheck: testAccErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAwsCEAnomalyMonitorConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAwsCEAnomalyMonitorExists(resourceName, &output), + resource.TestCheckResourceAttr(resourceName, "monitor_name", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAwsCEAnomalyMonitor_disappears(t *testing.T) { + var output costexplorer.AnomalyMonitor + resourceName := "aws_ce_anomaly_monitor.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckAwsCEAnomalyMonitorDestroy, + ErrorCheck: testAccErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAwsCEAnomalyMonitorConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAwsCEAnomalyMonitorExists(resourceName, &output), + testAccCheckResourceDisappears(testAccProvider, resourceAwsCEAnomalyMonitor(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckAwsCEAnomalyMonitorExists(resourceName string, output *costexplorer.AnomalyMonitor) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + + conn := testAccProvider.Meta().(*AWSClient).costexplorerconn + resp, err := conn.GetAnomalyMonitorsWithContext(context.Background(), &costexplorer.GetAnomalyMonitorsInput{MonitorArnList: []*string{aws.String(rs.Primary.ID)}}) + + if err != nil { + return fmt.Errorf("problem checking for CE Anomaly Monitor existence: %w", err) + } + + if resp == nil && len(resp.AnomalyMonitors) == 0 { + return fmt.Errorf("CE Anomaly Monitor %q does not exist", rs.Primary.ID) + } + + *output = *resp.AnomalyMonitors[0] + + return nil + } +} + +func testAccCheckAwsCEAnomalyMonitorDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).costexplorerconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_ce_anomaly_monitor" { + continue + } + + resp, err := conn.GetAnomalyMonitorsWithContext(context.Background(), &costexplorer.GetAnomalyMonitorsInput{MonitorArnList: []*string{aws.String(rs.Primary.ID)}}) + + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + continue + } + + if err != nil { + return fmt.Errorf("problem while checking CE Anomaly Monitor was destroyed: %w", err) + } + + if resp != nil && len(resp.AnomalyMonitors) > 0 { + return fmt.Errorf("CE Anomaly Monitor %q still exists", rs.Primary.ID) + } + } + + return nil + +} + +func testAccAwsCEAnomalyMonitorConfig(name string) string { + return fmt.Sprintf(` +resource "aws_ce_anomaly_monitor" "test" { + monitor_dimension = "SERVICE" + monitor_name = %[1]q + monitor_type = "DIMENSIONAL" +} +`, name) +} diff --git a/aws/resource_aws_ce_anomaly_subscription.go b/aws/resource_aws_ce_anomaly_subscription.go new file mode 100644 index 000000000000..3efb8345cafd --- /dev/null +++ b/aws/resource_aws_ce_anomaly_subscription.go @@ -0,0 +1,268 @@ +package aws + +import ( + "context" + "fmt" + "log" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/costexplorer" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/costexplorer/waiter" +) + +func resourceAwsCEAnomalySubscription() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceAwsCEAnomalySuscriptionCreate, + ReadWithoutTimeout: resourceAwsCEAnomalySuscriptionRead, + UpdateWithoutTimeout: resourceAwsCEAnomalySuscriptionUpdate, + DeleteWithoutTimeout: resourceAwsCEAnomalySuscriptionDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "account_id": { + Type: schema.TypeString, + Computed: true, + }, + "frequency": { + Type: schema.TypeString, + Required: true, + }, + "monitor_arn_list": { + Type: schema.TypeSet, + Elem: &schema.Schema{Type: schema.TypeString}, + Required: true, + Set: schema.HashString, + }, + "subscriber": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "address": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringLenBetween(6, 302), + }, + "status": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(costexplorer.SubscriberStatus_Values(), false), + }, + "type": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(costexplorer.SubscriberType_Values(), false), + }, + }, + }, + Set: schema.HashString, + }, + "subscription_name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + "threshold": { + Type: schema.TypeFloat, + Required: true, + ValidateFunc: validation.FloatAtLeast(0.0), + }, + }, + } +} + +func resourceAwsCEAnomalySuscriptionCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*AWSClient).costexplorerconn + input := &costexplorer.AnomalySubscription{ + Frequency: aws.String(d.Get("frequency").(string)), + MonitorArnList: expandStringSet(d.Get("monitor_arn_list").(*schema.Set)), + Subscribers: expandCEAnomalySubscriptionSubscribers(d.Get("subscriber").(*schema.Set).List()), + SubscriptionName: aws.String(d.Get("subscription_name").(string)), + Threshold: aws.Float64(d.Get("threshold").(float64)), + } + + var err error + var output *costexplorer.CreateAnomalySubscriptionOutput + err = resource.RetryContext(ctx, waiter.AnomalySubscriptionOperationTimeout, func() *resource.RetryError { + output, err = conn.CreateAnomalySubscription(&costexplorer.CreateAnomalySubscriptionInput{ + AnomalySubscription: input, + }) + if err != nil { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + return resource.RetryableError(err) + } + + return resource.NonRetryableError(err) + } + + return nil + }) + + if isResourceTimeoutError(err) { + output, err = conn.CreateAnomalySubscription(&costexplorer.CreateAnomalySubscriptionInput{ + AnomalySubscription: input, + }) + } + + if err != nil { + return diag.FromErr(fmt.Errorf("error creating CE Anomaly Subscription (%s): %w", d.Id(), err)) + } + + d.SetId(aws.StringValue(output.SubscriptionArn)) + + return resourceAwsCEAnomalySuscriptionRead(ctx, d, meta) +} + +func resourceAwsCEAnomalySuscriptionRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*AWSClient).costexplorerconn + + resp, err := conn.GetAnomalySubscriptionsWithContext(ctx, &costexplorer.GetAnomalySubscriptionsInput{SubscriptionArnList: []*string{aws.String(d.Id())}}) + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + log.Printf("[WARN] CE Anomaly Subscription (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return diag.FromErr(fmt.Errorf("error reading CE Anomaly Subscription (%s): %w", d.Id(), err)) + } + for _, v := range resp.AnomalySubscriptions { + d.Set("account_id", v.AccountId) + d.Set("frequency", v.Frequency) + d.Set("monitor_arn_list", flattenStringSet(v.MonitorArnList)) + if err = d.Set("subscriber", flattenCEAnomalySubscriptionSubscribers(v.Subscribers)); err != nil { + return diag.FromErr(fmt.Errorf("error setting `%s` for CE Anomaly Subscription (%s): %w", "subscriber", d.Id(), err)) + } + d.Set("subscription_name", v.SubscriptionName) + d.Set("threshold", v.Threshold) + } + + return nil +} + +func resourceAwsCEAnomalySuscriptionUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*AWSClient).costexplorerconn + + input := &costexplorer.UpdateAnomalySubscriptionInput{ + SubscriptionArn: aws.String(d.Id()), + } + + if d.HasChange("frequency") { + input.Frequency = aws.String(d.Get("frequency").(string)) + } + if d.HasChange("subscriber") { + input.Subscribers = expandCEAnomalySubscriptionSubscribers(d.Get("monitor_arn_list").(*schema.Set).List()) + } + if d.HasChange("monitor_arn_list") { + input.MonitorArnList = expandStringSet(d.Get("monitor_arn_list").(*schema.Set)) + } + if d.HasChange("subscription_name") { + input.SubscriptionName = aws.String(d.Get("subscription_name").(string)) + } + if d.HasChange("threshold") { + input.Threshold = aws.Float64(d.Get("threshold").(float64)) + } + + _, err := conn.UpdateAnomalySubscriptionWithContext(ctx, input) + + if err != nil { + diag.FromErr(fmt.Errorf("error updating CE Anomaly Subscription (%s): %w", d.Id(), err)) + } + + return resourceAwsCEAnomalySuscriptionRead(ctx, d, meta) +} + +func resourceAwsCEAnomalySuscriptionDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*AWSClient).costexplorerconn + + _, err := conn.DeleteAnomalySubscriptionWithContext(ctx, &costexplorer.DeleteAnomalySubscriptionInput{ + SubscriptionArn: aws.String(d.Id()), + }) + if err != nil { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + return nil + } + return diag.FromErr(fmt.Errorf("error deleting CE Anomaly Subscription (%s): %w", d.Id(), err)) + } + + return nil +} + +func expandCEAnomalySubscriptionSubscriber(tfMap map[string]interface{}) *costexplorer.Subscriber { + if tfMap == nil { + return nil + } + + apiObject := &costexplorer.Subscriber{} + + if v, ok := tfMap["address"]; ok && v.(string) != "" { + apiObject.Address = aws.String(v.(string)) + } + if v, ok := tfMap["status"]; ok && v.(string) != "" { + apiObject.Status = aws.String(v.(string)) + } + if v, ok := tfMap["type"]; ok && v.(string) != "" { + apiObject.Type = aws.String(v.(string)) + } + + return apiObject +} + +func expandCEAnomalySubscriptionSubscribers(tfList []interface{}) []*costexplorer.Subscriber { + if len(tfList) == 0 { + return nil + } + + var apiObjects []*costexplorer.Subscriber + + for _, tfMapRaw := range tfList { + tfMap, ok := tfMapRaw.(map[string]interface{}) + + if !ok { + continue + } + + apiObject := expandCEAnomalySubscriptionSubscriber(tfMap) + + apiObjects = append(apiObjects, apiObject) + } + + return apiObjects +} + +func flattenCEAnomalySubscriptionSubscriber(apiObject *costexplorer.Subscriber) map[string]interface{} { + if apiObject == nil { + return nil + } + + tfMap := map[string]interface{}{} + tfMap["address"] = aws.StringValue(apiObject.Address) + tfMap["type"] = aws.StringValue(apiObject.Type) + tfMap["status"] = aws.StringValue(apiObject.Status) + + return tfMap +} + +func flattenCEAnomalySubscriptionSubscribers(apiObjects []*costexplorer.Subscriber) []map[string]interface{} { + if len(apiObjects) == 0 { + return nil + } + + var tfList []map[string]interface{} + + for _, apiObject := range apiObjects { + if apiObject == nil { + continue + } + + tfList = append(tfList, flattenCEAnomalySubscriptionSubscriber(apiObject)) + } + + return tfList +} diff --git a/aws/resource_aws_ce_anomaly_subscription_test.go b/aws/resource_aws_ce_anomaly_subscription_test.go new file mode 100644 index 000000000000..0a64f9e5e79c --- /dev/null +++ b/aws/resource_aws_ce_anomaly_subscription_test.go @@ -0,0 +1,138 @@ +package aws + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/costexplorer" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccAwsCEAnomalySubscription_basic(t *testing.T) { + var output costexplorer.AnomalySubscription + resourceName := "aws_ce_anomaly_subscription.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckAwsCEAnomalySubscriptionDestroy, + ErrorCheck: testAccErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAwsCEAnomalySubscriptionConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAwsCEAnomalySubscriptionExists(resourceName, &output), + resource.TestCheckResourceAttr(resourceName, "subscription_name", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAwsCEAnomalySubscription_disappears(t *testing.T) { + var output costexplorer.AnomalySubscription + resourceName := "aws_ce_anomaly_subscription.test" + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: testAccCheckAwsCEAnomalySubscriptionDestroy, + ErrorCheck: testAccErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAwsCEAnomalySubscriptionConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAwsCEAnomalySubscriptionExists(resourceName, &output), + testAccCheckResourceDisappears(testAccProvider, resourceAwsCEAnomalySubscription(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckAwsCEAnomalySubscriptionExists(resourceName string, output *costexplorer.AnomalySubscription) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + + conn := testAccProvider.Meta().(*AWSClient).costexplorerconn + resp, err := conn.GetAnomalySubscriptionsWithContext(context.Background(), &costexplorer.GetAnomalySubscriptionsInput{SubscriptionArnList: []*string{aws.String(rs.Primary.ID)}}) + + if err != nil { + return fmt.Errorf("problem checking for CE Anomaly Subscription existence: %w", err) + } + + if resp == nil && len(resp.AnomalySubscriptions) == 0 { + return fmt.Errorf("CE Anomaly Subscription %q does not exist", rs.Primary.ID) + } + + *output = *resp.AnomalySubscriptions[0] + + return nil + } +} + +func testAccCheckAwsCEAnomalySubscriptionDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).costexplorerconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_ce_anomaly_subscription" { + continue + } + + resp, err := conn.GetAnomalySubscriptionsWithContext(context.Background(), &costexplorer.GetAnomalySubscriptionsInput{SubscriptionArnList: []*string{aws.String(rs.Primary.ID)}}) + + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + continue + } + + if err != nil { + return fmt.Errorf("problem while checking CE Anomaly Subscription was destroyed: %w", err) + } + + if resp != nil && len(resp.AnomalySubscriptions) > 0 { + return fmt.Errorf("CE Anomaly Subscription %q still exists", rs.Primary.ID) + } + } + + return nil + +} + +func testAccAwsCEAnomalySubscriptionConfig(name string) string { + return fmt.Sprintf(` +resource "aws_ce_anomaly_monitor" "test" { + monitor_dimension = "SERVICE" + monitor_name = %[1]q + monitor_type = "DIMENSIONAL" +} + +resource "aws_ce_anomaly_subscription" "test" { + subscription_name = %[1]q + threshold = 0 + frequency = "IMMEDIATE" + monitor_arn_list = [ + aws_ce_anomaly_monitor.test.id, + ] + subscriber { + type = "EMAIL" + address = "abc@example.com" + } +} +`, name) +} diff --git a/website/docs/r/ce_anomaly_monitor.html.markdown b/website/docs/r/ce_anomaly_monitor.html.markdown new file mode 100644 index 000000000000..92a3660197ea --- /dev/null +++ b/website/docs/r/ce_anomaly_monitor.html.markdown @@ -0,0 +1,80 @@ +--- +subcategory: "CostExplorer" +layout: "aws" +page_title: "AWS: aws_ce_anomaly_monitor" +description: |- + Provides a CostExplorer Anomaly Monitor +--- + +# Resource: aws_ce_anomaly_monitor + +Provides an CostExplorer Anomaly Monitor. + +## Example Usage + +```terraform +resource "aws_ce_anomaly_monitor" "example" { + monitor_dimension = "SERVICE" + monitor_name = "EXAMPLE ANOMALY MONITOR" + monitor_type = "DIMENSIONAL" +} +``` + +## Argument Reference + +The following arguments are required: + +* `monitor_name` - (Required) Name of the monitor. +* `monitor_type` - (Required) Possible type values. + +The following arguments are optional: + +* `monitor_dimension` - (Optional) Dimensions to evaluate. +* `monitor_specification` - (Optional) Configuration block for the `Expression` object used to categorize costs. See below. + + +### `monitor_specification` + +* `and` - (Optional) Return results that match both `Dimension` objects. +* `cost_category` - (Optional) Configuration block for the filter that's based on `CostCategory` values. See below. +* `dimension` - (Optional) Configuration block for the specific `Dimension` to use for `Expression`. See below. +* `not` - (Optional) Return results that match both `Dimension` object. +* `or` - (Optional) Return results that match both `Dimension` object. +* `tag` - (Optional) Configuration block for the specific `Tag` to use for `Expression`. See below. + +### `cost_category` + +* `key` - (Optional) Unique name of the Cost Category. +* `match_options` - (Optional) Match options that you can use to filter your results. MatchOptions is only applicable for actions related to cost category. The default values for MatchOptions is `EQUALS` and `CASE_SENSITIVE`. Valid values are: `EQUALS`, `ABSENT`, `STARTS_WITH`, `ENDS_WITH`, `CONTAINS`, `CASE_SENSITIVE`, `CASE_INSENSITIVE`. +* `values` - (Optional) Specific value of the Cost Category. + +### `dimension` + +* `key` - (Optional) Unique name of the Cost Category. +* `match_options` - (Optional) Match options that you can use to filter your results. MatchOptions is only applicable for actions related to cost category. The default values for MatchOptions is `EQUALS` and `CASE_SENSITIVE`. Valid values are: `EQUALS`, `ABSENT`, `STARTS_WITH`, `ENDS_WITH`, `CONTAINS`, `CASE_SENSITIVE`, `CASE_INSENSITIVE`. +* `values` - (Optional) Specific value of the Cost Category. + +### `tag` + +* `key` - (Optional) Key for the tag. +* `match_options` - (Optional) Match options that you can use to filter your results. MatchOptions is only applicable for actions related to cost category. The default values for MatchOptions is `EQUALS` and `CASE_SENSITIVE`. Valid values are: `EQUALS`, `ABSENT`, `STARTS_WITH`, `ENDS_WITH`, `CONTAINS`, `CASE_SENSITIVE`, `CASE_INSENSITIVE`. +* `values` - (Optional) Specific value of the Cost Category. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `creation_date` - Date when the monitor was created. +* `dimensional_value_count` - Value for evaluated dimensions. +* `id` - Unique ID of the anomaly monitor. +* `last_evaluated_date` - Date when the monitor last evaluated for anomalies. +* `last_updated_date` - Date when the monitor was last updated. + + +## Import + +`aws_ce_anomaly_monitor` can be imported using the id, e.g. + +``` +$ terraform import aws_ce_anomaly_monitor.example anomalyMonitorID +``` diff --git a/website/docs/r/ce_anomaly_subscription.html.markdown b/website/docs/r/ce_anomaly_subscription.html.markdown new file mode 100644 index 000000000000..3b88d70d1041 --- /dev/null +++ b/website/docs/r/ce_anomaly_subscription.html.markdown @@ -0,0 +1,66 @@ +--- +subcategory: "CostExplorer" +layout: "aws" +page_title: "AWS: aws_ce_anomaly_subscription" +description: |- + Provides a CostExplorer Anomaly Monitor +--- + +# Resource: aws_ce_anomaly_subscription + +Provides an CostExplorer Anomaly Subscription. + +## Example Usage + +```terraform +resource "aws_ce_anomaly_monitor" "example" { + monitor_dimension = "SERVICE" + monitor_name = "EXAMPLE ANOMALY MONITOR" + monitor_type = "DIMENSIONAL" +} +resource "aws_ce_anomaly_monitor" "example" { + subscription_name = "EXAMPLE ANOMALY SUBSCRIPTION" + threshold = 0 + frequency = "IMMEDIATE" + monitor_arn_list = [ + aws_ce_anomaly_monitor.test.id, + ] + subscriber { + type = "EMAIL" + address = "abc@example.com" + } +} +``` + +## Argument Reference + +The following arguments are required: + +* `frequency` - (Required) Frequency that anomaly reports are sent over email. +* `monitor_arn_list` - (Required) A list of cost anomaly monitors. +* `subscriber` - (Required) Configuration block for the list of subscribers to notify. +* `subscription_name` - (Required) Name for the subscription. +* `threshold` - (Required) Dollar value that triggers a notification if the threshold is exceeded. + +### `subscriber` + +* `address` - (Optional) Email address or SNS Amazon Resource Name (ARN). This depends on the `type`. +* `status` - (Optional) Indicates if the subscriber accepts the notifications. Valid values are `CONFIRMED`, `DECLINED` +* `type` - (Optional) Notification delivery channel. Valid values are `EMAIL`, `SNS` + + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `account_id` - account ID of the Anomaly Subscription. +* `id` - Unique ID of the anomaly subscription. + + +## Import + +`aws_ce_anomaly_subscription` can be imported using the id, e.g. + +``` +$ terraform import aws_ce_anomaly_subscription.example anomalySubscriptionID +``` From 54778157f3e3d95c7edbfb1c5e3a487bc98dd4ea Mon Sep 17 00:00:00 2001 From: Edgar Lopez Date: Tue, 21 Sep 2021 14:06:40 -0600 Subject: [PATCH 2/3] refactor --- aws/resource_aws_ce_anomaly_monitor.go | 187 +++++++++++++++++- aws/resource_aws_ce_anomaly_monitor_test.go | 8 +- aws/resource_aws_ce_anomaly_subscription.go | 20 +- ...source_aws_ce_anomaly_subscription_test.go | 7 +- .../r/ce_anomaly_subscription.html.markdown | 3 +- 5 files changed, 211 insertions(+), 14 deletions(-) diff --git a/aws/resource_aws_ce_anomaly_monitor.go b/aws/resource_aws_ce_anomaly_monitor.go index 5755727af5d0..a54d5e9d4f80 100644 --- a/aws/resource_aws_ce_anomaly_monitor.go +++ b/aws/resource_aws_ce_anomaly_monitor.go @@ -68,6 +68,124 @@ func resourceAwsCEAnomalyMonitor() *schema.Resource { } func schemaAWSCECostCategoryRule() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "and": { + Type: schema.TypeSet, + Optional: true, + Elem: schemaAWSCECostCategoryRuleExpression(), + }, + "cost_category": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringLenBetween(1, 50), + }, + "match_options": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice(costexplorer.MatchOption_Values(), false), + }, + Set: schema.HashString, + }, + "values": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + Set: schema.HashString, + }, + }, + }, + }, + "dimension": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice(costexplorer.Dimension_Values(), false), + }, + "match_options": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice(costexplorer.MatchOption_Values(), false), + }, + Set: schema.HashString, + }, + "values": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + Set: schema.HashString, + }, + }, + }, + }, + "not": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: schemaAWSCECostCategoryRuleExpression(), + }, + "or": { + Type: schema.TypeSet, + Optional: true, + Elem: schemaAWSCECostCategoryRuleExpression(), + }, + "tags": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Optional: true, + }, + "match_options": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice(costexplorer.MatchOption_Values(), false), + }, + Set: schema.HashString, + }, + "values": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + Set: schema.HashString, + }, + }, + }, + }, + }, + } +} + +func schemaAWSCECostCategoryRuleExpression() *schema.Resource { return &schema.Resource{ Schema: map[string]*schema.Schema{ "cost_category": { @@ -219,7 +337,8 @@ func resourceAwsCEAnomalyMonitorRead(ctx context.Context, d *schema.ResourceData conn := meta.(*AWSClient).costexplorerconn resp, err := conn.GetAnomalyMonitorsWithContext(ctx, &costexplorer.GetAnomalyMonitorsInput{MonitorArnList: []*string{aws.String(d.Id())}}) - if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) || + tfawserr.ErrMessageContains(err, costexplorer.ErrCodeUnknownMonitorException, "No monitor present") { log.Printf("[WARN] CE Anomaly Monitor (%s) not found, removing from state", d.Id()) d.SetId("") return nil @@ -235,7 +354,7 @@ func resourceAwsCEAnomalyMonitorRead(ctx context.Context, d *schema.ResourceData d.Set("last_updated_date", v.LastUpdatedDate) d.Set("monitor_dimension", v.MonitorDimension) d.Set("monitor_name", v.MonitorName) - if err = d.Set("monitor_specification", flattenCECostCategoryRuleExpression(v.MonitorSpecification)); err != nil { + if err = d.Set("monitor_specification", flattenCECostCategoryRuleExpressions([]*costexplorer.Expression{v.MonitorSpecification})); err != nil { return diag.FromErr(fmt.Errorf("error setting `%s` for CE Anomaly Monitor (%s): %w", "monitor_specification", d.Id(), err)) } d.Set("monitor_type", v.MonitorType) @@ -271,7 +390,8 @@ func resourceAwsCEAnomalyMonitorDelete(ctx context.Context, d *schema.ResourceDa MonitorArn: aws.String(d.Id()), }) if err != nil { - if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) || + tfawserr.ErrMessageContains(err, costexplorer.ErrCodeUnknownMonitorException, "No monitor present") { return nil } return diag.FromErr(fmt.Errorf("error deleting CE Anomaly Monitor (%s): %w", d.Id(), err)) @@ -285,12 +405,21 @@ func expandCECostExpression(tfMap map[string]interface{}) *costexplorer.Expressi } apiObject := &costexplorer.Expression{} + if v, ok := tfMap["and"]; ok { + apiObject.And = expandCECostExpressions(v.(*schema.Set).List()) + } if v, ok := tfMap["cost_category"]; ok { apiObject.CostCategories = expandCECostExpressionCostCategory(v.([]interface{})) } if v, ok := tfMap["dimension"]; ok { apiObject.Dimensions = expandCECostExpressionDimension(v.([]interface{})) } + if v, ok := tfMap["not"]; ok && len(v.([]interface{})) > 0 { + apiObject.Not = expandCECostExpressions(v.([]interface{}))[0] + } + if v, ok := tfMap["or"]; ok { + apiObject.Or = expandCECostExpressions(v.(*schema.Set).List()) + } if v, ok := tfMap["tags"]; ok { apiObject.Tags = expandCECostExpressionTag(v.([]interface{})) } @@ -389,8 +518,11 @@ func flattenCECostCategoryRuleExpression(apiObject *costexplorer.Expression) map } tfMap := map[string]interface{}{} + tfMap["and"] = flattenCECostCategoryRuleOperandExpressions(apiObject.And) tfMap["cost_category"] = flattenCECostCategoryRuleExpressionCostCategory(apiObject.CostCategories) tfMap["dimension"] = flattenCECostCategoryRuleExpressionDimension(apiObject.Dimensions) + tfMap["not"] = flattenCECostCategoryRuleOperandExpressions([]*costexplorer.Expression{apiObject.Not}) + tfMap["or"] = flattenCECostCategoryRuleOperandExpressions(apiObject.Or) tfMap["tags"] = flattenCECostCategoryRuleExpressionTag(apiObject.Tags) return tfMap @@ -446,3 +578,52 @@ func flattenCECostCategoryRuleExpressionTag(apiObject *costexplorer.TagValues) [ return tfList } + +func flattenCECostCategoryRuleExpressions(apiObjects []*costexplorer.Expression) []map[string]interface{} { + if len(apiObjects) == 0 { + return nil + } + + var tfList []map[string]interface{} + + for _, apiObject := range apiObjects { + if apiObject == nil { + continue + } + + tfList = append(tfList, flattenCECostCategoryRuleExpression(apiObject)) + } + + return tfList +} + +func flattenCECostCategoryRuleOperandExpression(apiObject *costexplorer.Expression) map[string]interface{} { + if apiObject == nil { + return nil + } + + tfMap := map[string]interface{}{} + tfMap["cost_category"] = flattenCECostCategoryRuleExpressionCostCategory(apiObject.CostCategories) + tfMap["dimension"] = flattenCECostCategoryRuleExpressionDimension(apiObject.Dimensions) + tfMap["tags"] = flattenCECostCategoryRuleExpressionTag(apiObject.Tags) + + return tfMap +} + +func flattenCECostCategoryRuleOperandExpressions(apiObjects []*costexplorer.Expression) []map[string]interface{} { + if len(apiObjects) == 0 { + return nil + } + + var tfList []map[string]interface{} + + for _, apiObject := range apiObjects { + if apiObject == nil { + continue + } + + tfList = append(tfList, flattenCECostCategoryRuleOperandExpression(apiObject)) + } + + return tfList +} diff --git a/aws/resource_aws_ce_anomaly_monitor_test.go b/aws/resource_aws_ce_anomaly_monitor_test.go index 604ca8c70b22..7a69086a2655 100644 --- a/aws/resource_aws_ce_anomaly_monitor_test.go +++ b/aws/resource_aws_ce_anomaly_monitor_test.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) -func TestAccAwsCEAnomalyMonitor_basic(t *testing.T) { +func testAccAwsCEAnomalyMonitor_basic(t *testing.T) { var output costexplorer.AnomalyMonitor resourceName := "aws_ce_anomaly_monitor.test" rName := acctest.RandomWithPrefix("tf-acc-test") @@ -40,7 +40,7 @@ func TestAccAwsCEAnomalyMonitor_basic(t *testing.T) { }) } -func TestAccAwsCEAnomalyMonitor_disappears(t *testing.T) { +func testAccAwsCEAnomalyMonitor_disappears(t *testing.T) { var output costexplorer.AnomalyMonitor resourceName := "aws_ce_anomaly_monitor.test" rName := acctest.RandomWithPrefix("tf-acc-test") @@ -57,7 +57,6 @@ func TestAccAwsCEAnomalyMonitor_disappears(t *testing.T) { testAccCheckAwsCEAnomalyMonitorExists(resourceName, &output), testAccCheckResourceDisappears(testAccProvider, resourceAwsCEAnomalyMonitor(), resourceName), ), - ExpectNonEmptyPlan: true, }, }, }) @@ -97,7 +96,8 @@ func testAccCheckAwsCEAnomalyMonitorDestroy(s *terraform.State) error { resp, err := conn.GetAnomalyMonitorsWithContext(context.Background(), &costexplorer.GetAnomalyMonitorsInput{MonitorArnList: []*string{aws.String(rs.Primary.ID)}}) - if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) || + tfawserr.ErrMessageContains(err, costexplorer.ErrCodeUnknownMonitorException, "No monitor present") { continue } diff --git a/aws/resource_aws_ce_anomaly_subscription.go b/aws/resource_aws_ce_anomaly_subscription.go index 3efb8345cafd..85283744054f 100644 --- a/aws/resource_aws_ce_anomaly_subscription.go +++ b/aws/resource_aws_ce_anomaly_subscription.go @@ -1,6 +1,7 @@ package aws import ( + "bytes" "context" "fmt" "log" @@ -12,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/hashcode" "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/costexplorer/waiter" ) @@ -61,7 +63,7 @@ func resourceAwsCEAnomalySubscription() *schema.Resource { }, }, }, - Set: schema.HashString, + Set: ceAnomalySubscriptionSubscriber, }, "subscription_name": { Type: schema.TypeString, @@ -123,7 +125,8 @@ func resourceAwsCEAnomalySuscriptionRead(ctx context.Context, d *schema.Resource conn := meta.(*AWSClient).costexplorerconn resp, err := conn.GetAnomalySubscriptionsWithContext(ctx, &costexplorer.GetAnomalySubscriptionsInput{SubscriptionArnList: []*string{aws.String(d.Id())}}) - if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) || + tfawserr.ErrMessageContains(err, costexplorer.ErrCodeUnknownSubscriptionException, "No anomaly subscription") { log.Printf("[WARN] CE Anomaly Subscription (%s) not found, removing from state", d.Id()) d.SetId("") return nil @@ -185,7 +188,8 @@ func resourceAwsCEAnomalySuscriptionDelete(ctx context.Context, d *schema.Resour SubscriptionArn: aws.String(d.Id()), }) if err != nil { - if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) || + tfawserr.ErrMessageContains(err, costexplorer.ErrCodeUnknownSubscriptionException, "No anomaly subscription") { return nil } return diag.FromErr(fmt.Errorf("error deleting CE Anomaly Subscription (%s): %w", d.Id(), err)) @@ -242,6 +246,7 @@ func flattenCEAnomalySubscriptionSubscriber(apiObject *costexplorer.Subscriber) } tfMap := map[string]interface{}{} + tfMap["address"] = aws.StringValue(apiObject.Address) tfMap["type"] = aws.StringValue(apiObject.Type) tfMap["status"] = aws.StringValue(apiObject.Status) @@ -266,3 +271,12 @@ func flattenCEAnomalySubscriptionSubscribers(apiObjects []*costexplorer.Subscrib return tfList } + +func ceAnomalySubscriptionSubscriber(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + buf.WriteString(m["address"].(string)) + buf.WriteString(m["status"].(string)) + buf.WriteString(m["type"].(string)) + return hashcode.String(buf.String()) +} diff --git a/aws/resource_aws_ce_anomaly_subscription_test.go b/aws/resource_aws_ce_anomaly_subscription_test.go index 0a64f9e5e79c..222ef457a43f 100644 --- a/aws/resource_aws_ce_anomaly_subscription_test.go +++ b/aws/resource_aws_ce_anomaly_subscription_test.go @@ -57,7 +57,6 @@ func TestAccAwsCEAnomalySubscription_disappears(t *testing.T) { testAccCheckAwsCEAnomalySubscriptionExists(resourceName, &output), testAccCheckResourceDisappears(testAccProvider, resourceAwsCEAnomalySubscription(), resourceName), ), - ExpectNonEmptyPlan: true, }, }, }) @@ -97,7 +96,8 @@ func testAccCheckAwsCEAnomalySubscriptionDestroy(s *terraform.State) error { resp, err := conn.GetAnomalySubscriptionsWithContext(context.Background(), &costexplorer.GetAnomalySubscriptionsInput{SubscriptionArnList: []*string{aws.String(rs.Primary.ID)}}) - if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) { + if tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeResourceNotFoundException) || + tfawserr.ErrMessageContains(err, costexplorer.ErrCodeUnknownSubscriptionException, "No anomaly subscription") { continue } @@ -125,13 +125,14 @@ resource "aws_ce_anomaly_monitor" "test" { resource "aws_ce_anomaly_subscription" "test" { subscription_name = %[1]q threshold = 0 - frequency = "IMMEDIATE" + frequency = "DAILY" monitor_arn_list = [ aws_ce_anomaly_monitor.test.id, ] subscriber { type = "EMAIL" address = "abc@example.com" + status = "CONFIRMED" } } `, name) diff --git a/website/docs/r/ce_anomaly_subscription.html.markdown b/website/docs/r/ce_anomaly_subscription.html.markdown index 3b88d70d1041..479e10f9c876 100644 --- a/website/docs/r/ce_anomaly_subscription.html.markdown +++ b/website/docs/r/ce_anomaly_subscription.html.markdown @@ -21,13 +21,14 @@ resource "aws_ce_anomaly_monitor" "example" { resource "aws_ce_anomaly_monitor" "example" { subscription_name = "EXAMPLE ANOMALY SUBSCRIPTION" threshold = 0 - frequency = "IMMEDIATE" + frequency = "DAILY" monitor_arn_list = [ aws_ce_anomaly_monitor.test.id, ] subscriber { type = "EMAIL" address = "abc@example.com" + status = "CONFIRMED" } } ``` From 6c08515c095911215e8d2478c78a5dc4fb3ec5d2 Mon Sep 17 00:00:00 2001 From: Edgar Lopez Date: Tue, 21 Sep 2021 14:06:50 -0600 Subject: [PATCH 3/3] added serial test --- aws/resource_aws_ce_test.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 aws/resource_aws_ce_test.go diff --git a/aws/resource_aws_ce_test.go b/aws/resource_aws_ce_test.go new file mode 100644 index 000000000000..1bfdee42d2d3 --- /dev/null +++ b/aws/resource_aws_ce_test.go @@ -0,0 +1,30 @@ +package aws + +import ( + "testing" +) + +func TestAccAWSCE_serial(t *testing.T) { + testCases := map[string]map[string]func(t *testing.T){ + "AnomalyMonitor": { + "basic": testAccAwsCEAnomalyMonitor_basic, + "disappears": testAccAwsCEAnomalyMonitor_disappears, + }, + "AnomalySubscription": { + "basic": TestAccAwsCEAnomalySubscription_basic, + "disappears": TestAccAwsCEAnomalySubscription_disappears, + }, + } + + for group, m := range testCases { + m := m + t.Run(group, func(t *testing.T) { + for name, tc := range m { + tc := tc + t.Run(name, func(t *testing.T) { + tc(t) + }) + } + }) + } +}