From 47a1d8dd01ecdefc1153ea7f03eb839db2b755e5 Mon Sep 17 00:00:00 2001 From: Rahman Mousavian Date: Wed, 8 Mar 2023 00:14:09 +1000 Subject: [PATCH] Added support for ElastiCache reserved nodes --- internal/service/elasticache/consts.go | 11 + internal/service/elasticache/find.go | 29 ++ .../elasticache/reserved_cache_node.go | 247 ++++++++++++++++++ ...eserved_cache_node_offering_data_source.go | 97 +++++++ ...ed_cache_node_offering_data_source_test.go | 44 ++++ .../elasticache/reserved_cache_node_test.go | 106 ++++++++ internal/service/elasticache/status.go | 16 ++ internal/service/elasticache/wait.go | 18 ++ 8 files changed, 568 insertions(+) create mode 100644 internal/service/elasticache/consts.go create mode 100644 internal/service/elasticache/reserved_cache_node.go create mode 100644 internal/service/elasticache/reserved_cache_node_offering_data_source.go create mode 100644 internal/service/elasticache/reserved_cache_node_offering_data_source_test.go create mode 100644 internal/service/elasticache/reserved_cache_node_test.go diff --git a/internal/service/elasticache/consts.go b/internal/service/elasticache/consts.go new file mode 100644 index 00000000000..c05178df2cf --- /dev/null +++ b/internal/service/elasticache/consts.go @@ -0,0 +1,11 @@ +package elasticache + +const ( + ResNameTags = "Tags" +) + +const ( + ReservedCacheNodeStateActive = "active" + ReservedCacheNodeStateRetired = "retired" + ReservedCacheNodeStatePaymentPending = "payment-pending" +) diff --git a/internal/service/elasticache/find.go b/internal/service/elasticache/find.go index 1cbbe6cf2c7..b8ab8c25f04 100644 --- a/internal/service/elasticache/find.go +++ b/internal/service/elasticache/find.go @@ -337,3 +337,32 @@ func FindCacheSubnetGroupByName(ctx context.Context, conn *elasticache.ElastiCac return output.CacheSubnetGroups[0], nil } + +func FindReservedCacheNodeByID(ctx context.Context, conn *elasticache.ElastiCache, id string) (*elasticache.ReservedCacheNode, error) { + input := &elasticache.DescribeReservedCacheNodesInput{ + ReservedCacheNodeId: aws.String(id), + } + + output, err := conn.DescribeReservedCacheNodesWithContext(ctx, input) + + if tfawserr.ErrCodeEquals(err, elasticache.ErrCodeReservedCacheNodeNotFoundFault) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + + if err != nil { + return nil, err + } + + if output == nil || len(output.ReservedCacheNodes) == 0 || output.ReservedCacheNodes[0] == nil { + return nil, tfresource.NewEmptyResultError(input) + } + + if count := len(output.ReservedCacheNodes); count > 1 { + return nil, tfresource.NewTooManyResultsError(count, input) + } + + return output.ReservedCacheNodes[0], nil +} diff --git a/internal/service/elasticache/reserved_cache_node.go b/internal/service/elasticache/reserved_cache_node.go new file mode 100644 index 00000000000..210a5bc6352 --- /dev/null +++ b/internal/service/elasticache/reserved_cache_node.go @@ -0,0 +1,247 @@ +package elasticache + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/create" + 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" +) + +const ( + ResNameReservedCacheNode = "Reserved Cache Node" +) + +// @SDKResource("aws_elasticache_reserved_cache_node") +func ResourceReservedCacheNode() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceReservedCacheNodeCreate, + ReadWithoutTimeout: resourceReservedCacheNodeRead, + UpdateWithoutTimeout: resourceReservedCacheNodeUpdate, + DeleteWithoutTimeout: resourceReservedCacheNodeDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(30 * time.Minute), + Update: schema.DefaultTimeout(10 * time.Minute), + Delete: schema.DefaultTimeout(1 * time.Minute), + }, + Schema: map[string]*schema.Schema{ + "arn": { + Type: schema.TypeString, + Computed: true, + }, + "cache_node_type": { + Type: schema.TypeString, + Computed: true, + }, + "duration": { + Type: schema.TypeInt, + Computed: true, + }, + "fixed_price": { + Type: schema.TypeFloat, + Computed: true, + }, + "cache_node_count": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Default: 1, + }, + "offering_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "offering_type": { + Type: schema.TypeString, + Computed: true, + }, + "product_description": { + Type: schema.TypeString, + Computed: true, + }, + "recurring_charges": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "recurring_charge_amount": { + Type: schema.TypeInt, + Computed: true, + }, + "recurring_charge_frequency": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + "reservation_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "start_time": { + Type: schema.TypeString, + Computed: true, + }, + "state": { + Type: schema.TypeString, + Computed: true, + }, + "usage_price": { + Type: schema.TypeFloat, + Computed: true, + }, + "tags": tftags.TagsSchema(), + "tags_all": tftags.TagsSchemaComputed(), + }, + + CustomizeDiff: verify.SetTagsDiff, + } +} + +func resourceReservedCacheNodeCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).ElastiCacheConn() + defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig + tags := defaultTagsConfig.MergeTags(tftags.New(ctx, d.Get("tags").(map[string]interface{}))) + + input := &elasticache.PurchaseReservedCacheNodesOfferingInput{ + ReservedCacheNodesOfferingId: aws.String(d.Get("offering_id").(string)), + } + + if v, ok := d.Get("cache_node_count").(int); ok && v > 0 { + input.CacheNodeCount = aws.Int64(int64(d.Get("cache_node_count").(int))) + } + + if v, ok := d.Get("reservation_id").(string); ok && v != "" { + input.ReservedCacheNodeId = aws.String(v) + } + + if len(tags) > 0 { + input.Tags = Tags(tags.IgnoreAWS()) + } + + resp, err := conn.PurchaseReservedCacheNodesOfferingWithContext(ctx, input) + if err != nil { + return create.DiagError(names.ElastiCache, create.ErrActionCreating, ResNameReservedCacheNode, fmt.Sprintf("offering_id: %s, reservation_id: %s", d.Get("offering_id").(string), d.Get("reservation_id").(string)), err) + } + + d.SetId(aws.ToString(resp.ReservedCacheNode.ReservedCacheNodeId)) + + if err := waitReservedCacheNodeCreated(ctx, conn, d.Id(), d.Timeout(schema.TimeoutCreate)); err != nil { + return create.DiagError(names.ElastiCache, create.ErrActionWaitingForCreation, ResNameReservedCacheNode, d.Id(), err) + } + + return resourceReservedCacheNodeRead(ctx, d, meta) +} + +func resourceReservedCacheNodeRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).ElastiCacheConn() + defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig + ignoreTagsConfig := meta.(*conns.AWSClient).IgnoreTagsConfig + + reservation, err := FindReservedCacheNodeByID(ctx, conn, d.Id()) + + if !d.IsNewResource() && tfresource.NotFound(err) { + create.LogNotFoundRemoveState(names.ElastiCache, create.ErrActionReading, ResNameReservedCacheNode, d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return create.DiagError(names.ElastiCache, create.ErrActionReading, ResNameReservedCacheNode, d.Id(), err) + } + + d.Set("arn", reservation.ReservationARN) + d.Set("cache_node_type", reservation.CacheNodeType) + d.Set("duration", reservation.Duration) + d.Set("fixed_price", reservation.FixedPrice) + d.Set("cache_node_count", reservation.CacheNodeCount) + d.Set("offering_id", reservation.ReservedCacheNodesOfferingId) + d.Set("offering_type", reservation.OfferingType) + d.Set("product_description", reservation.ProductDescription) + d.Set("recurring_charges", flattenRecurringCharges(reservation.RecurringCharges)) + d.Set("reservation_id", reservation.ReservedCacheNodeId) + d.Set("start_time", (reservation.StartTime).Format(time.RFC3339)) + d.Set("state", reservation.State) + d.Set("usage_price", reservation.UsagePrice) + + tags, err := ListTags(ctx, conn, aws.ToString(reservation.ReservationARN)) + tags = tags.IgnoreAWS().IgnoreConfig(ignoreTagsConfig) + + if err != nil { + return create.DiagError(names.CE, create.ErrActionReading, ResNameTags, d.Id(), err) + } + + //lintignore:AWSR002 + if err := d.Set("tags", tags.RemoveDefaultConfig(defaultTagsConfig).Map()); err != nil { + return create.DiagError(names.CE, create.ErrActionUpdating, ResNameTags, d.Id(), err) + } + + if err := d.Set("tags_all", tags.Map()); err != nil { + return create.DiagError(names.CE, create.ErrActionUpdating, ResNameTags, d.Id(), err) + } + + return nil +} + +func resourceReservedCacheNodeUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).ElastiCacheConn() + + if d.HasChange("tags") { + o, n := d.GetChange("tags") + + if err := UpdateTags(ctx, conn, d.Get("arn").(string), o, n); err != nil { + return create.DiagError(names.ElastiCache, create.ErrActionUpdating, ResNameTags, d.Id(), err) + } + } + + if d.HasChange("tags_all") { + o, n := d.GetChange("tags_all") + + if err := UpdateTags(ctx, conn, d.Get("arn").(string), o, n); err != nil { + return create.DiagError(names.ElastiCache, create.ErrActionUpdating, ResNameTags, d.Id(), err) + } + } + + return resourceReservedCacheNodeRead(ctx, d, meta) +} + +func resourceReservedCacheNodeDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + // Reservations cannot be deleted. Removing from state. + log.Printf("[DEBUG] %s %s cannot be deleted. Removing from state.: %s", names.ElastiCache, ResNameReservedCacheNode, d.Id()) + + return nil +} + +func flattenRecurringCharges(recurringCharges []*elasticache.RecurringCharge) []interface{} { + if len(recurringCharges) == 0 { + return []interface{}{} + } + + var rawRecurringCharges []interface{} + for _, recurringCharge := range recurringCharges { + rawRecurringCharge := map[string]interface{}{ + "recurring_charge_amount": recurringCharge.RecurringChargeAmount, + "recurring_charge_frequency": aws.ToString(recurringCharge.RecurringChargeFrequency), + } + + rawRecurringCharges = append(rawRecurringCharges, rawRecurringCharge) + } + + return rawRecurringCharges +} diff --git a/internal/service/elasticache/reserved_cache_node_offering_data_source.go b/internal/service/elasticache/reserved_cache_node_offering_data_source.go new file mode 100644 index 00000000000..fe933d82137 --- /dev/null +++ b/internal/service/elasticache/reserved_cache_node_offering_data_source.go @@ -0,0 +1,97 @@ +package elasticache + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go/service/elasticache" + "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/validation" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/create" + "github.com/hashicorp/terraform-provider-aws/names" +) + +const ( + ResNameReservedCacheNodeOffering = "Reserved Cache Node Offering" +) + +// @SDKDataSource("aws_elasticache_reserved_cache_node_offering") +func DataSourceReservedCacheNodeOffering() *schema.Resource { + return &schema.Resource{ + ReadWithoutTimeout: dataSourceReservedCacheNodeOfferingRead, + Schema: map[string]*schema.Schema{ + "cache_node_type": { + Type: schema.TypeString, + Required: true, + }, + "duration": { + Type: schema.TypeInt, + Required: true, + }, + "fixed_price": { + Type: schema.TypeFloat, + Computed: true, + }, + "offering_id": { + Type: schema.TypeString, + Computed: true, + }, + "offering_type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ + "Light Utilization", + "Medium Utilization", + "Heavy Utilization", + "Partial Upfront", + "All Upfront", + "No Upfront", + }, false), + }, + "product_description": { + Type: schema.TypeString, + Required: true, + }, + }, + } +} + +func dataSourceReservedCacheNodeOfferingRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).ElastiCacheConn() + + input := &elasticache.DescribeReservedCacheNodesOfferingsInput{ + CacheNodeType: aws.String(d.Get("cache_node_type").(string)), + Duration: aws.String(fmt.Sprint(d.Get("duration").(int))), + OfferingType: aws.String(d.Get("offering_type").(string)), + ProductDescription: aws.String(d.Get("product_description").(string)), + } + + resp, err := conn.DescribeReservedCacheNodesOfferingsWithContext(ctx, input) + + if err != nil { + return create.DiagError(names.ElastiCache, create.ErrActionReading, ResNameReservedCacheNodeOffering, "unknown", err) + } + + if len(resp.ReservedCacheNodesOfferings) == 0 { + return diag.Errorf("no %s %s found matching criteria; try different search", names.ElastiCache, ResNameReservedCacheNodeOffering) + } + + if len(resp.ReservedCacheNodesOfferings) > 1 { + return diag.Errorf("More than one %s %s found matching criteria; try different search", names.ElastiCache, ResNameReservedCacheNodeOffering) + } + + offering := resp.ReservedCacheNodesOfferings[0] + + d.SetId(aws.ToString(offering.ReservedCacheNodesOfferingId)) + d.Set("cache_node_type", offering.CacheNodeType) + d.Set("duration", offering.Duration) + d.Set("fixed_price", offering.FixedPrice) + d.Set("offering_type", offering.OfferingType) + d.Set("product_description", offering.ProductDescription) + d.Set("offering_id", offering.ReservedCacheNodesOfferingId) + + return nil +} diff --git a/internal/service/elasticache/reserved_cache_node_offering_data_source_test.go b/internal/service/elasticache/reserved_cache_node_offering_data_source_test.go new file mode 100644 index 00000000000..299c431e6c4 --- /dev/null +++ b/internal/service/elasticache/reserved_cache_node_offering_data_source_test.go @@ -0,0 +1,44 @@ +package elasticache_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" +) + +func TestAccElastiCacheReservedNodeOffering_basic(t *testing.T) { + dataSourceName := "data.aws_elasticache_reserved_cache_node_offering.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: nil, + ErrorCheck: acctest.ErrorCheck(t, elasticache.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccReservedNodeOfferingConfig_basic(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dataSourceName, "cache_node_type", "cache.t2.micro"), + resource.TestCheckResourceAttr(dataSourceName, "duration", "31536000"), + resource.TestCheckResourceAttrSet(dataSourceName, "fixed_price"), + resource.TestCheckResourceAttrSet(dataSourceName, "offering_id"), + resource.TestCheckResourceAttr(dataSourceName, "offering_type", "No Upfront"), + resource.TestCheckResourceAttr(dataSourceName, "product_description", "redis"), + ), + }, + }, + }) +} + +func testAccReservedNodeOfferingConfig_basic() string { + return ` +data "aws_elasticache_reserved_cache_node_offering" "test" { + cache_node_type = "cache.t2.micro" + duration = 31536000 + offering_type = "No Upfront" + product_description = "redis" +} +` +} diff --git a/internal/service/elasticache/reserved_cache_node_test.go b/internal/service/elasticache/reserved_cache_node_test.go new file mode 100644 index 00000000000..760da4bdcc9 --- /dev/null +++ b/internal/service/elasticache/reserved_cache_node_test.go @@ -0,0 +1,106 @@ +package elasticache_test + +import ( + "context" + "fmt" + "os" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go/service/elasticache" + 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" + tfelasticache "github.com/hashicorp/terraform-provider-aws/internal/service/elasticache" +) + +func TestAccElastiCacheReservedCacheNode_basic(t *testing.T) { + ctx := acctest.Context(t) + key := "RUN_ELASTICACHE_RESERVED_CACHE_NODE_TESTS" + vifId := os.Getenv(key) + if vifId != "true" { + t.Skipf("Environment variable %s is not set to true", key) + } + + var reservation elasticache.ReservedCacheNode + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_elasticache_reserved_cache_node.test" + dataSourceName := "data.aws_elasticache_reserved_cache_node_offering.test" + cacheNodeCount := "1" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: nil, + ErrorCheck: acctest.ErrorCheck(t, elasticache.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccReservedInstanceConfig_basic(rName, cacheNodeCount), + Check: resource.ComposeTestCheckFunc( + testAccReservedInstanceExists(ctx, resourceName, &reservation), + acctest.MatchResourceAttrRegionalARN(resourceName, "arn", "elasticache", regexp.MustCompile(`reserved-instance:.+`)), + resource.TestCheckResourceAttrPair(dataSourceName, "cache_node_type", resourceName, "cache_node_type"), + resource.TestCheckResourceAttrPair(dataSourceName, "duration", resourceName, "duration"), + resource.TestCheckResourceAttrPair(dataSourceName, "fixed_price", resourceName, "fixed_price"), + resource.TestCheckResourceAttr(resourceName, "cache_node_count", cacheNodeCount), + resource.TestCheckResourceAttrPair(dataSourceName, "offering_id", resourceName, "offering_id"), + resource.TestCheckResourceAttrPair(dataSourceName, "offering_type", resourceName, "offering_type"), + resource.TestCheckResourceAttrPair(dataSourceName, "product_description", resourceName, "product_description"), + resource.TestCheckResourceAttrSet(resourceName, "recurring_charges"), + resource.TestCheckResourceAttr(resourceName, "reservation_id", rName), + resource.TestCheckResourceAttrSet(resourceName, "start_time"), + resource.TestCheckResourceAttrSet(resourceName, "state"), + resource.TestCheckResourceAttrSet(resourceName, "usage_price"), + ), + }, + }, + }) +} + +func testAccReservedInstanceExists(ctx context.Context, n string, reservation *elasticache.ReservedCacheNode) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).ElastiCacheConn() + + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ElastiCache Reserved Cache Node reservation id is set") + } + + resp, err := tfelasticache.FindReservedCacheNodeByID(ctx, conn, rs.Primary.ID) + + if err != nil { + return err + } + + if resp == nil { + return fmt.Errorf("ElastiCache Reserved Cache Node %q does not exist", rs.Primary.ID) + } + + *reservation = *resp + + return nil + } +} + +func testAccReservedInstanceConfig_basic(rName string, cacheNodeCount string) string { + return fmt.Sprintf(` +data "aws_elasticache_reserved_cache_node_offering" "test" { + cache_node_type = "cache.t2.micro" + duration = 31536000 + offering_type = "No Upfront" + product_description = "redis" +} + +resource "aws_elasticache_reserved_cache_node" "test" { + offering_id = data.aws_elasticache_reserved_cache_node_offering.test.offering_id + reservation_id = %[1]q + cache_node_count = %[2]s +} +`, rName, cacheNodeCount) +} diff --git a/internal/service/elasticache/status.go b/internal/service/elasticache/status.go index 09dd9ac2755..0415be189b0 100644 --- a/internal/service/elasticache/status.go +++ b/internal/service/elasticache/status.go @@ -147,3 +147,19 @@ func StatusUser(ctx context.Context, conn *elasticache.ElastiCache, userId strin return user, aws.StringValue(user.Status), nil } } + +func statusReservedCacheNode(ctx context.Context, conn *elasticache.ElastiCache, id string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + output, err := FindReservedCacheNodeByID(ctx, conn, id) + + if tfresource.NotFound(err) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + return output, aws.StringValue(output.State), nil + } +} diff --git a/internal/service/elasticache/wait.go b/internal/service/elasticache/wait.go index dee0f218f05..95e1ed11d33 100644 --- a/internal/service/elasticache/wait.go +++ b/internal/service/elasticache/wait.go @@ -262,3 +262,21 @@ func WaitUserDeleted(ctx context.Context, conn *elasticache.ElastiCache, userId return err } + +func waitReservedCacheNodeCreated(ctx context.Context, conn *elasticache.ElastiCache, id string, timeout time.Duration) error { + stateConf := &resource.StateChangeConf{ + Pending: []string{ + ReservedCacheNodeStatePaymentPending, + }, + Target: []string{ReservedCacheNodeStateActive}, + Refresh: statusReservedCacheNode(ctx, conn, id), + NotFoundChecks: 5, + Timeout: timeout, + MinTimeout: 10 * time.Second, + Delay: 30 * time.Second, + } + + _, err := stateConf.WaitForStateContext(ctx) + + return err +}