diff --git a/.changelog/251777.txt b/.changelog/251777.txt new file mode 100644 index 00000000000..3448d181df3 --- /dev/null +++ b/.changelog/251777.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_ce_anomaly_monitor +``` diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 001e56397ab..3a7f7cd1151 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1040,7 +1040,8 @@ func Provider() *schema.Provider { "aws_budgets_budget": budgets.ResourceBudget(), "aws_budgets_budget_action": budgets.ResourceBudgetAction(), - "aws_ce_cost_category": ce.ResourceCostCategory(), + "aws_ce_anomaly_monitor": ce.ResourceAnomalyMonitor(), + "aws_ce_cost_category": ce.ResourceCostCategory(), "aws_chime_voice_connector": chime.ResourceVoiceConnector(), "aws_chime_voice_connector_group": chime.ResourceVoiceConnectorGroup(), diff --git a/internal/service/ce/anomaly_monitor.go b/internal/service/ce/anomaly_monitor.go new file mode 100644 index 00000000000..c9c0bdccb58 --- /dev/null +++ b/internal/service/ce/anomaly_monitor.go @@ -0,0 +1,232 @@ +package ce + +import ( + "context" + "encoding/json" + "regexp" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/costexplorer" + "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/structure" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/internal/verify" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func ResourceAnomalyMonitor() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceAnomalyMonitorCreate, + ReadContext: resourceAnomalyMonitorRead, + UpdateContext: resourceAnomalyMonitorUpdate, + DeleteContext: resourceAnomalyMonitorDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "arn": { + Type: schema.TypeString, + Computed: true, + }, + "monitor_dimension": { + Type: schema.TypeString, + Optional: true, + ConflictsWith: []string{"monitor_specification"}, + ValidateFunc: validation.StringInSlice(costexplorer.MonitorDimension_Values(), false), + }, + "name": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.All( + validation.StringLenBetween(1, 1024), + validation.StringMatch(regexp.MustCompile(`[\\S\\s]*`), "Must be a valid Anomaly Monitor Name matching expression: [\\S\\s]*")), + }, + "monitor_specification": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validation.StringIsJSON, + DiffSuppressFunc: verify.SuppressEquivalentJSONDiffs, + ConflictsWith: []string{"monitor_dimension"}, + }, + "monitor_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(costexplorer.MonitorType_Values(), false), + }, + "tags": tftags.TagsSchema(), + "tags_all": tftags.TagsSchemaComputed(), + }, + + CustomizeDiff: verify.SetTagsDiff, + } +} + +func resourceAnomalyMonitorCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).CEConn + defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig + tags := defaultTagsConfig.MergeTags(tftags.New(d.Get("tags").(map[string]interface{}))) + + input := &costexplorer.CreateAnomalyMonitorInput{ + AnomalyMonitor: &costexplorer.AnomalyMonitor{ + MonitorName: aws.String(d.Get("name").(string)), + MonitorType: aws.String(d.Get("monitor_type").(string)), + }, + } + switch d.Get("monitor_type").(string) { + case costexplorer.MonitorTypeDimensional: + if v, ok := d.GetOk("monitor_dimension"); ok { + input.AnomalyMonitor.MonitorDimension = aws.String(v.(string)) + } else { + return diag.Errorf("If Monitor Type is %s, dimension attrribute is required", costexplorer.MonitorTypeDimensional) + } + case costexplorer.MonitorTypeCustom: + if v, ok := d.GetOk("monitor_specification"); ok { + expression := costexplorer.Expression{} + + if err := json.Unmarshal([]byte(v.(string)), &expression); err != nil { + return diag.Errorf("Error parsing specification: %s", err) + } + + input.AnomalyMonitor.MonitorSpecification = &expression + + } else { + return diag.Errorf("If Monitor Type is %s, dimension attrribute is required", costexplorer.MonitorTypeCustom) + } + } + + if len(tags) > 0 { + input.ResourceTags = Tags(tags.IgnoreAWS()) + } + + resp, err := conn.CreateAnomalyMonitorWithContext(ctx, input) + + if err != nil { + return diag.Errorf("Error creating Anomaly Monitor: %s", err) + } + + if resp == nil || resp.MonitorArn == nil { + return diag.Errorf("creating Cost Explorer Anomaly Monitor resource (%s): empty output", d.Get("name").(string)) + } + + d.SetId(aws.StringValue(resp.MonitorArn)) + + return resourceAnomalyMonitorRead(ctx, d, meta) +} + +func resourceAnomalyMonitorRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).CEConn + defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig + ignoreTagsConfig := meta.(*conns.AWSClient).IgnoreTagsConfig + + monitor, err := FindAnomalyMonitorByARN(ctx, conn, d.Id()) + + if !d.IsNewResource() && tfresource.NotFound(err) { + names.LogNotFoundRemoveState(names.CE, names.ErrActionReading, ResAnomalyMonitor, d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return names.DiagError(names.CE, names.ErrActionReading, ResAnomalyMonitor, d.Id(), err) + } + + if monitor.MonitorSpecification != nil { + specificationToJson, err := json.Marshal(monitor.MonitorSpecification) + if err != nil { + return diag.Errorf("Error parsing specification response: %s", err) + } + specificationToSet, err := structure.NormalizeJsonString(string(specificationToJson)) + + if err != nil { + return diag.Errorf("Specification (%s) is invalid JSON: %s", specificationToSet, err) + } + + d.Set("monitor_specification", specificationToSet) + } + + d.Set("arn", monitor.MonitorArn) + d.Set("monitor_dimension", monitor.MonitorDimension) + d.Set("name", monitor.MonitorName) + d.Set("monitor_type", monitor.MonitorType) + + tags, err := ListTags(conn, aws.StringValue(monitor.MonitorArn)) + tags = tags.IgnoreAWS().IgnoreConfig(ignoreTagsConfig) + + if err != nil { + return names.DiagError(names.CE, names.ErrActionReading, ResTags, d.Id(), err) + } + + //lintignore:AWSR002 + if err := d.Set("tags", tags.RemoveDefaultConfig(defaultTagsConfig).Map()); err != nil { + return names.DiagError(names.CE, names.ErrActionReading, ResTags, d.Id(), err) + } + + if err := d.Set("tags_all", tags.Map()); err != nil { + return names.DiagError(names.CE, names.ErrActionReading, ResTags, d.Id(), err) + } + + return nil +} + +func resourceAnomalyMonitorUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).CEConn + requestUpdate := false + + input := &costexplorer.UpdateAnomalyMonitorInput{ + MonitorArn: aws.String(d.Id()), + } + + if d.HasChange("name") { + input.MonitorName = aws.String(d.Get("name").(string)) + requestUpdate = true + } + + if d.HasChange("tags") { + o, n := d.GetChange("tags") + + if err := UpdateTags(conn, d.Id(), o, n); err != nil { + return names.DiagError(names.CE, names.ErrActionUpdating, ResTags, d.Id(), err) + } + } + + if d.HasChange("tags_all") { + o, n := d.GetChange("tags_all") + + if err := UpdateTags(conn, d.Id(), o, n); err != nil { + return names.DiagError(names.CE, names.ErrActionUpdating, ResTags, d.Id(), err) + } + } + + if requestUpdate { + _, err := conn.UpdateAnomalyMonitorWithContext(ctx, input) + + if err != nil { + return names.DiagError(names.CE, names.ErrActionUpdating, ResAnomalyMonitor, d.Id(), err) + } + } + + return resourceAnomalyMonitorRead(ctx, d, meta) +} + +func resourceAnomalyMonitorDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).CEConn + + _, err := conn.DeleteAnomalyMonitorWithContext(ctx, &costexplorer.DeleteAnomalyMonitorInput{MonitorArn: aws.String(d.Id())}) + + if err != nil && tfawserr.ErrCodeEquals(err, costexplorer.ErrCodeUnknownMonitorException) { + return nil + } + + if err != nil { + return names.DiagError(names.CE, names.ErrActionDeleting, ResAnomalyMonitor, d.Id(), err) + } + + return nil +} diff --git a/internal/service/ce/anomaly_monitor_test.go b/internal/service/ce/anomaly_monitor_test.go new file mode 100644 index 00000000000..1e07be39b11 --- /dev/null +++ b/internal/service/ce/anomaly_monitor_test.go @@ -0,0 +1,336 @@ +package ce_test + +import ( + "context" + "errors" + "fmt" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go/service/costexplorer" + sdkacctest "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" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfce "github.com/hashicorp/terraform-provider-aws/internal/service/ce" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccCEAnomalyMonitor_basic(t *testing.T) { + var monitor costexplorer.AnomalyMonitor + resourceName := "aws_ce_anomaly_monitor.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckAnomalyMonitorDestroy, + ErrorCheck: acctest.ErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAnomalyMonitorConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAnomalyMonitorExists(resourceName, &monitor), + resource.TestCheckResourceAttr(resourceName, "name", rName), + acctest.MatchResourceAttrGlobalARN(resourceName, "arn", "ce", regexp.MustCompile(`anomalymonitor/.+`)), + resource.TestCheckResourceAttr(resourceName, "monitor_type", "CUSTOM"), + resource.TestCheckResourceAttrSet(resourceName, "monitor_specification"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccCEAnomalyMonitor_disappears(t *testing.T) { + var monitor costexplorer.AnomalyMonitor + resourceName := "aws_ce_anomaly_monitor.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckAnomalyMonitorDestroy, + ErrorCheck: acctest.ErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAnomalyMonitorConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAnomalyMonitorExists(resourceName, &monitor), + acctest.CheckResourceDisappears(acctest.Provider, tfce.ResourceAnomalyMonitor(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccCEAnomalyMonitor_update(t *testing.T) { + var monitor costexplorer.AnomalyMonitor + resourceName := "aws_ce_anomaly_monitor.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + rName2 := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckAnomalyMonitorDestroy, + ErrorCheck: acctest.ErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAnomalyMonitorConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAnomalyMonitorExists(resourceName, &monitor), + resource.TestCheckResourceAttr(resourceName, "name", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccAnomalyMonitorConfig_basic(rName2), + Check: resource.ComposeTestCheckFunc( + testAccCheckAnomalyMonitorExists(resourceName, &monitor), + resource.TestCheckResourceAttr(resourceName, "name", rName2), + ), + }, + }, + }) +} + +func TestAccCEAnomalyMonitor_tags(t *testing.T) { + var monitor costexplorer.AnomalyMonitor + resourceName := "aws_ce_anomaly_monitor.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckAnomalyMonitorDestroy, + ErrorCheck: acctest.ErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAnomalyMonitorConfig_Tags1(rName, "key1", "value1"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckAnomalyMonitorExists(resourceName, &monitor), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccAnomalyMonitorConfig_Tags2(rName, "key1", "value1updated", "key2", "value2"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckAnomalyMonitorExists(resourceName, &monitor), + resource.TestCheckResourceAttr(resourceName, "tags.%", "2"), + resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1updated"), + resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"), + ), + }, + { + Config: testAccAnomalyMonitorConfig_Tags1(rName, "key2", "value2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAnomalyMonitorExists(resourceName, &monitor), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"), + ), + }, + }, + }) +} + +// An AWS account can only have one anomaly monitor of type DIMENSIONAL. As +// such, if additional tests are added, they should be combined with the +// following test in a serial test +func TestAccCEAnomalyMonitor_Dimensional(t *testing.T) { + var monitor costexplorer.AnomalyMonitor + resourceName := "aws_ce_anomaly_monitor.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckAnomalyMonitorDestroy, + ErrorCheck: acctest.ErrorCheck(t, costexplorer.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccAnomalyMonitorConfig_Dimensional(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAnomalyMonitorExists(resourceName, &monitor), + resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttr(resourceName, "monitor_type", "DIMENSIONAL"), + resource.TestCheckResourceAttr(resourceName, "monitor_dimension", "SERVICE"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckAnomalyMonitorExists(n string, anomalyMonitor *costexplorer.AnomalyMonitor) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).CEConn + + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No Cost Explorer Anomaly Monitor is set") + } + + resp, err := tfce.FindAnomalyMonitorByARN(context.Background(), conn, rs.Primary.ID) + + if err != nil { + return err + } + + if resp == nil { + return fmt.Errorf("Cost Explorer %q does not exist", rs.Primary.ID) + } + + *anomalyMonitor = *resp + + return nil + } +} + +func testAccCheckAnomalyMonitorDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).CEConn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_ce_anomaly_monitor" { + continue + } + + _, err := tfce.FindAnomalyMonitorByARN(context.Background(), conn, rs.Primary.ID) + + if tfresource.NotFound(err) { + continue + } + + if err != nil { + return err + } + + return names.Error(names.CE, names.ErrActionCheckingDestroyed, tfce.ResAnomalyMonitor, rs.Primary.ID, errors.New("still exists")) + + } + + return nil + +} + +func testAccAnomalyMonitorConfig_basic(rName string) string { + return fmt.Sprintf(` +resource "aws_ce_anomaly_monitor" "test" { + name = %[1]q + monitor_type = "CUSTOM" + + monitor_specification = < 0 { + input := &costexplorer.UntagResourceInput{ + ResourceArn: aws.String(identifier), + ResourceTagKeys: aws.StringSlice(removedTags.IgnoreAWS().Keys()), + } + + _, err := conn.UntagResource(input) + + if err != nil { + return fmt.Errorf("error untagging resource (%s): %w", identifier, err) + } + } + + if updatedTags := oldTags.Updated(newTags); len(updatedTags) > 0 { + input := &costexplorer.TagResourceInput{ + ResourceArn: aws.String(identifier), + ResourceTags: Tags(updatedTags.IgnoreAWS()), + } + + _, err := conn.TagResource(input) + + if err != nil { + return fmt.Errorf("error tagging resource (%s): %w", identifier, err) + } + } + + return nil +} 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 00000000000..0ad94d982d2 --- /dev/null +++ b/website/docs/r/ce_anomaly_monitor.html.markdown @@ -0,0 +1,77 @@ +--- +subcategory: "CE (Cost Explorer)" +layout: "aws" +page_title: "AWS: aws_ce_anomaly_monitor" +description: |- + Provides a CE Cost Anomaly Monitor +--- + +# Resource: aws_ce_anomaly_monitor + +Provides a CE Anomaly Monitor. + +## Example Usage + +There are two main types of a Cost Anomaly Monitor: `DIMENSIONAL` and `CUSTOM`. + +### Dimensional Example + +```terraform +resource "aws_ce_anomaly_monitor" "service_monitor" { + name = "AWSServiceMonitor" + type = "DIMENSIONAL" + dimension = "SERVICE" +} +``` + +### Custom Example + +```terraform +resource "aws_ce_anomaly_monitor" "test" { + name = "AWSCustomAnomalyMonitor" + type = "CUSTOM" + + specification = <