Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Added Support for ElastiCache Reserved Cache Nodes #29832

Merged
merged 19 commits into from
Sep 18, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Added support for ElastiCache reserved cache nodes
mousavian authored and gdavison committed Sep 16, 2024
commit f9e4cc942457efcc0927ae6184e81cf26a5a98e0
1 change: 1 addition & 0 deletions docs/acc-test-environment-variables.md
Original file line number Diff line number Diff line change
@@ -96,3 +96,4 @@ Environment variables (beyond standard AWS Go SDK ones) used by acceptance testi
| `TF_AWS_LICENSE_MANAGER_GRANT_LICENSE_ARN` | ARN for a License Manager license imported into the current account. |
| `TF_AWS_LICENSE_MANAGER_GRANT_PRINCIPAL` | ARN of a principal to share the License Manager license with. Either a root user, Organization, or Organizational Unit. |
| `TF_TEST_CLOUDFRONT_RETAIN` | Flag to disable but dangle CloudFront Distributions during testing to reduce feedback time (must be manually destroyed afterwards) |
| `TF_TEST_ELASTICACHE_RESERVED_CACHE_NODE` | Flag to enable resource tests for ElastiCache reserved nodes. Set to `1` to run tests |
6 changes: 6 additions & 0 deletions internal/service/elasticache/consts.go
Original file line number Diff line number Diff line change
@@ -15,3 +15,9 @@ func engine_Values() []string {
engineRedis,
}
}

const (
reservedCacheNodeStateActive = "active"
reservedCacheNodeStateRetired = "retired"
reservedCacheNodeStatePaymentPending = "payment-pending"
)
1 change: 1 addition & 0 deletions internal/service/elasticache/exports_test.go
Original file line number Diff line number Diff line change
@@ -21,6 +21,7 @@ var (
FindCacheSubnetGroupByName = findCacheSubnetGroupByName
FindGlobalReplicationGroupByID = findGlobalReplicationGroupByID
FindReplicationGroupByID = findReplicationGroupByID
FindReservedCacheNodeByID = findReservedCacheNodeByID
FindServerlessCacheByID = findServerlessCacheByID
FindUserByID = findUserByID
FindUserGroupByID = findUserGroupByID
277 changes: 277 additions & 0 deletions internal/service/elasticache/reserved_cache_node.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
package elasticache

import (
"context"
"fmt"
"log"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/elasticache"
awstypes "github.com/aws/aws-sdk-go-v2/service/elasticache/types"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry"
"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"
"github.com/hashicorp/terraform-provider-aws/internal/errs"
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")
// @Tags(identifierAttribute="arn")
// @Testing(tagsTests=false)
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,
Computed: true,
ForceNew: true,
},
"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,
Computed: 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 any) diag.Diagnostics {
conn := meta.(*conns.AWSClient).ElastiCacheClient(ctx)

input := elasticache.PurchaseReservedCacheNodesOfferingInput{
ReservedCacheNodesOfferingId: aws.String(d.Get("offering_id").(string)),
Tags: getTagsIn(ctx),
}

if v, ok := d.Get("cache_node_count").(int); ok && v > 0 {
input.CacheNodeCount = aws.Int32(int32(d.Get("cache_node_count").(int)))
}

if v, ok := d.Get("reservation_id").(string); ok && v != "" {
input.ReservedCacheNodeId = aws.String(v)
}

resp, err := conn.PurchaseReservedCacheNodesOffering(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 any) diag.Diagnostics {
conn := meta.(*conns.AWSClient).ElastiCacheClient(ctx)

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)

return nil
}

func resourceReservedCacheNodeUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
var diags diag.Diagnostics

// Tags only.

return append(diags, resourceReservedCacheNodeRead(ctx, d, meta)...)
}

func resourceReservedCacheNodeDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
log.Printf("[DEBUG] %s %s cannot be deleted. Removing from state.: %s", names.ElastiCache, ResNameReservedCacheNode, d.Id())

return nil
}

func flattenRecurringCharges(recurringCharges []awstypes.RecurringCharge) []any {
if len(recurringCharges) == 0 {
return []any{}
}

var rawRecurringCharges []any
for _, recurringCharge := range recurringCharges {
rawRecurringCharge := map[string]any{
"recurring_charge_amount": recurringCharge.RecurringChargeAmount,
"recurring_charge_frequency": aws.ToString(recurringCharge.RecurringChargeFrequency),
}

rawRecurringCharges = append(rawRecurringCharges, rawRecurringCharge)
}

return rawRecurringCharges
}

func findReservedCacheNodeByID(ctx context.Context, conn *elasticache.Client, id string) (result awstypes.ReservedCacheNode, err error) {
input := elasticache.DescribeReservedCacheNodesInput{
ReservedCacheNodeId: aws.String(id),
}

output, err := conn.DescribeReservedCacheNodes(ctx, &input)

if errs.IsA[*awstypes.ReservedCacheNodeNotFoundFault](err) {
return result, &retry.NotFoundError{
LastError: err,
LastRequest: input,
}
}
if err != nil {
return result, err
}

if output == nil || len(output.ReservedCacheNodes) == 0 {
return result, tfresource.NewEmptyResultError(input)
}

if count := len(output.ReservedCacheNodes); count > 1 {
return result, tfresource.NewTooManyResultsError(count, input)
}

return output.ReservedCacheNodes[0], nil
}

func waitReservedCacheNodeCreated(ctx context.Context, conn *elasticache.Client, id string, timeout time.Duration) error {
stateConf := &retry.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
}

func statusReservedCacheNode(ctx context.Context, conn *elasticache.Client, id string) retry.StateRefreshFunc {
return func() (any, string, error) {
output, err := findReservedCacheNodeByID(ctx, conn, id)

if tfresource.NotFound(err) {
return nil, "", nil
}

if err != nil {
return nil, "", err
}

return output, aws.ToString(output.State), nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package elasticache

import (
"context"
"fmt"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/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/errs/sdkdiag"
"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 {
var diags diag.Diagnostics

conn := meta.(*conns.AWSClient).ElastiCacheClient(ctx)

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.DescribeReservedCacheNodesOfferings(ctx, &input)
if err != nil {
return sdkdiag.AppendErrorf(diags, "reading ElastiCache Reserved Cache Node Offering: %s", err)
}

if len(resp.ReservedCacheNodesOfferings) == 0 {
return sdkdiag.AppendErrorf(diags, "no %s %s found matching criteria; try different search", names.ElastiCache, ResNameReservedCacheNodeOffering)
}

if len(resp.ReservedCacheNodesOfferings) > 1 {
return sdkdiag.AppendErrorf(diags, "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 diags
}
Loading