diff --git a/.changelog/29832.txt b/.changelog/29832.txt new file mode 100644 index 00000000000..bd5fb3ccb30 --- /dev/null +++ b/.changelog/29832.txt @@ -0,0 +1,7 @@ +```release-note:new-resource +aws_elasticache_reserved_cache_node +``` + +```release-note:new-data-source +aws_elasticache_reserved_cache_node_offering +``` diff --git a/docs/acc-test-environment-variables.md b/docs/acc-test-environment-variables.md index 20c9a0e1b69..60dd1418e7e 100644 --- a/docs/acc-test-environment-variables.md +++ b/docs/acc-test-environment-variables.md @@ -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 | diff --git a/docs/data-handling-and-conversion.md b/docs/data-handling-and-conversion.md index a5ffa5bfa67..e35db243be5 100644 --- a/docs/data-handling-and-conversion.md +++ b/docs/data-handling-and-conversion.md @@ -259,6 +259,21 @@ type scheduleModel struct { } ``` +To ignore a field when flattening, but include it when expanding, use the option `noflatten`. + +For example, from the struct `dataSourceReservedCacheNodeOfferingModel` for the ElastiCache Reserved Cache Node Offering: + +```go +type dataSourceReservedCacheNodeOfferingModel struct { + CacheNodeType types.String `tfsdk:"cache_node_type"` + Duration fwtypes.RFC3339Duration `tfsdk:"duration" autoflex:",noflatten"` + FixedPrice types.Float64 `tfsdk:"fixed_price"` + OfferingID types.String `tfsdk:"offering_id"` + OfferingType types.String `tfsdk:"offering_type"` + ProductDescription types.String `tfsdk:"product_description"` +} +``` + #### Overriding Default Behavior In some cases, flattening and expanding need conditional handling. diff --git a/internal/framework/flex/autoflex.go b/internal/framework/flex/autoflex.go index 4b160e06068..176c4c521ef 100644 --- a/internal/framework/flex/autoflex.go +++ b/internal/framework/flex/autoflex.go @@ -166,6 +166,13 @@ func autoFlexConvertStruct(ctx context.Context, sourcePath path.Path, from any, }) continue } + if toOpts.NoFlatten() { + tflog.SubsystemTrace(ctx, subsystemName, "Skipping noflatten target field", map[string]any{ + logAttrKeySourceFieldname: fieldName, + logAttrKeyTargetFieldname: toFieldName, + }) + continue + } if !toFieldVal.CanSet() { // Corresponding field value can't be changed. tflog.SubsystemDebug(ctx, subsystemName, "Field cannot be set", map[string]any{ diff --git a/internal/framework/flex/tags.go b/internal/framework/flex/tags.go index e32542b063f..e4710cb00cc 100644 --- a/internal/framework/flex/tags.go +++ b/internal/framework/flex/tags.go @@ -45,3 +45,7 @@ func (o tagOptions) Legacy() bool { func (o tagOptions) OmitEmpty() bool { return o.Contains("omitempty") } + +func (o tagOptions) NoFlatten() bool { + return o.Contains("noflatten") +} diff --git a/internal/framework/types/rfc3339_duration.go b/internal/framework/types/rfc3339_duration.go new file mode 100644 index 00000000000..8df8092d9d2 --- /dev/null +++ b/internal/framework/types/rfc3339_duration.go @@ -0,0 +1,168 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "context" + "fmt" + "time" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-provider-aws/internal/types/duration" +) + +var ( + _ basetypes.StringTypable = (*rfc3339DurationType)(nil) +) + +type rfc3339DurationType struct { + basetypes.StringType +} + +var ( + RFC3339DurationType = rfc3339DurationType{} +) + +func (t rfc3339DurationType) Equal(o attr.Type) bool { + other, ok := o.(rfc3339DurationType) + + if !ok { + return false + } + + return t.StringType.Equal(other.StringType) +} + +func (rfc3339DurationType) String() string { + return "RFC3339DurationType" +} + +func (t rfc3339DurationType) ValueFromString(_ context.Context, in types.String) (basetypes.StringValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + if in.IsNull() { + return RFC3339DurationNull(), diags + } + if in.IsUnknown() { + return RFC3339DurationUnknown(), diags + } + + valueString := in.ValueString() + if _, err := duration.Parse(valueString); err != nil { + return RFC3339DurationUnknown(), diags // Must not return validation errors + } + + return RFC3339DurationValue(valueString), diags +} + +func (t rfc3339DurationType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.StringType.ValueFromTerraform(ctx, in) + + if err != nil { + return nil, err + } + + stringValue, ok := attrValue.(basetypes.StringValue) + + if !ok { + return nil, fmt.Errorf("unexpected value type of %T", attrValue) + } + + stringValuable, diags := t.ValueFromString(ctx, stringValue) + + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags) + } + + return stringValuable, nil +} + +func (rfc3339DurationType) ValueType(context.Context) attr.Value { + return RFC3339Duration{} +} + +var ( + _ basetypes.StringValuable = (*RFC3339Duration)(nil) + _ xattr.ValidateableAttribute = (*RFC3339Duration)(nil) +) + +func RFC3339DurationNull() RFC3339Duration { + return RFC3339Duration{StringValue: basetypes.NewStringNull()} +} + +func RFC3339DurationUnknown() RFC3339Duration { + return RFC3339Duration{StringValue: basetypes.NewStringUnknown()} +} + +// DurationValue initializes a new RFC3339Duration type with the provided value +// +// This function does not return diagnostics, and therefore invalid duration values +// are not handled during construction. Invalid values will be detected by the +// ValidateAttribute method, called by the ValidateResourceConfig RPC during +// operations like `terraform validate`, `plan`, or `apply`. +func RFC3339DurationValue(value string) RFC3339Duration { + // swallow any RFC3339Duration parsing errors here and just pass along the + // zero value duration.Duration. Invalid values will be handled downstream + // by the ValidateAttribute method. + v, _ := duration.Parse(value) + + return RFC3339Duration{ + StringValue: basetypes.NewStringValue(value), + value: v, + } +} + +func RFC3339DurationTimeDurationValue(value time.Duration) RFC3339Duration { + v := duration.NewFromTimeDuration(value) + + return RFC3339Duration{ + StringValue: basetypes.NewStringValue(v.String()), + value: v, + } +} + +type RFC3339Duration struct { + basetypes.StringValue + value duration.Duration +} + +func (v RFC3339Duration) Equal(o attr.Value) bool { + other, ok := o.(RFC3339Duration) + + if !ok { + return false + } + + return v.StringValue.Equal(other.StringValue) +} + +func (RFC3339Duration) Type(context.Context) attr.Type { + return RFC3339DurationType +} + +// ValueDuration returns the known duration.Duration value. If RFC3339Duration is null or unknown, returns 0. +func (v RFC3339Duration) ValueDuration() duration.Duration { + return v.value +} + +func (v RFC3339Duration) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) { + if v.IsNull() || v.IsUnknown() { + return + } + + if _, err := duration.Parse(v.ValueString()); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Invalid Duration Value", + "The provided value cannot be parsed as a Duration.\n\n"+ + "Path: "+req.Path.String()+"\n"+ + "Error: "+err.Error(), + ) + } +} diff --git a/internal/framework/types/rfc3339_duration_test.go b/internal/framework/types/rfc3339_duration_test.go new file mode 100644 index 00000000000..8cd0f3de37a --- /dev/null +++ b/internal/framework/types/rfc3339_duration_test.go @@ -0,0 +1,135 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/attr/xattr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types" +) + +func TestRFC3339DurationTypeValueFromTerraform(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + val tftypes.Value + expected attr.Value + }{ + "null value": { + val: tftypes.NewValue(tftypes.String, nil), + expected: fwtypes.RFC3339DurationNull(), + }, + "unknown value": { + val: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + expected: fwtypes.RFC3339DurationUnknown(), + }, + "valid duration": { + val: tftypes.NewValue(tftypes.String, "P2Y"), + expected: fwtypes.RFC3339DurationValue("P2Y"), + }, + "invalid duration": { + val: tftypes.NewValue(tftypes.String, "not ok"), + expected: fwtypes.RFC3339DurationUnknown(), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + val, err := fwtypes.RFC3339DurationType.ValueFromTerraform(ctx, test.val) + + if err != nil { + t.Fatalf("got unexpected error: %s", err) + } + + if diff := cmp.Diff(val, test.expected); diff != "" { + t.Errorf("unexpected diff (+wanted, -got): %s", diff) + } + }) + } +} + +func TestRFC3339DurationValidateAttribute(t *testing.T) { + t.Parallel() + + type testCase struct { + val fwtypes.RFC3339Duration + expectError bool + } + tests := map[string]testCase{ + "unknown": { + val: fwtypes.RFC3339DurationUnknown(), + }, + "null": { + val: fwtypes.RFC3339DurationNull(), + }, + "valid": { + val: fwtypes.RFC3339DurationValue("P2Y"), + }, + "invalid": { + val: fwtypes.RFC3339DurationValue("not ok"), + expectError: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + req := xattr.ValidateAttributeRequest{} + resp := xattr.ValidateAttributeResponse{} + + test.val.ValidateAttribute(ctx, req, &resp) + if resp.Diagnostics.HasError() != test.expectError { + t.Errorf("resp.Diagnostics.HasError() = %t, want = %t", resp.Diagnostics.HasError(), test.expectError) + } + }) + } +} + +func TestRFC3339DurationToStringValue(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + duration fwtypes.RFC3339Duration + expected types.String + }{ + "value": { + duration: fwtypes.RFC3339DurationValue("P2Y"), + expected: types.StringValue("P2Y"), + }, + "null": { + duration: fwtypes.RFC3339DurationNull(), + expected: types.StringNull(), + }, + "unknown": { + duration: fwtypes.RFC3339DurationUnknown(), + expected: types.StringUnknown(), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + s, _ := test.duration.ToStringValue(ctx) + + if !test.expected.Equal(s) { + t.Fatalf("expected %#v to equal %#v", s, test.expected) + } + }) + } +} diff --git a/internal/service/elasticache/consts.go b/internal/service/elasticache/consts.go index 439a7f5b420..0c8e2dd96d0 100644 --- a/internal/service/elasticache/consts.go +++ b/internal/service/elasticache/consts.go @@ -15,3 +15,9 @@ func engine_Values() []string { engineRedis, } } + +const ( + reservedCacheNodeStateActive = "active" + reservedCacheNodeStateRetired = "retired" + reservedCacheNodeStatePaymentPending = "payment-pending" +) diff --git a/internal/service/elasticache/exports_test.go b/internal/service/elasticache/exports_test.go index c4d719c6f15..c256fac3107 100644 --- a/internal/service/elasticache/exports_test.go +++ b/internal/service/elasticache/exports_test.go @@ -21,6 +21,7 @@ var ( FindCacheSubnetGroupByName = findCacheSubnetGroupByName FindGlobalReplicationGroupByID = findGlobalReplicationGroupByID FindReplicationGroupByID = findReplicationGroupByID + FindReservedCacheNodeByID = findReservedCacheNodeByID FindServerlessCacheByID = findServerlessCacheByID FindUserByID = findUserByID FindUserGroupByID = findUserGroupByID diff --git a/internal/service/elasticache/reserved_cache_node.go b/internal/service/elasticache/reserved_cache_node.go new file mode 100644 index 00000000000..9716af3eb14 --- /dev/null +++ b/internal/service/elasticache/reserved_cache_node.go @@ -0,0 +1,309 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package elasticache + +import ( + "context" + "fmt" + "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-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + "github.com/hashicorp/terraform-provider-aws/internal/errs/fwdiag" + "github.com/hashicorp/terraform-provider-aws/internal/framework" + "github.com/hashicorp/terraform-provider-aws/internal/framework/flex" + fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types" + tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @FrameworkResource("aws_elasticache_reserved_cache_node") +// @Tags(identifierAttribute="arn") +// @Testing(tagsTests=false) +func newResourceReservedCacheNode(context.Context) (resource.ResourceWithConfigure, error) { + r := &resourceReservedCacheNode{} + r.SetDefaultCreateTimeout(30 * time.Minute) + r.SetDefaultUpdateTimeout(10 * time.Minute) + r.SetDefaultDeleteTimeout(1 * time.Minute) + + return r, nil +} + +type resourceReservedCacheNode struct { + framework.ResourceWithConfigure + framework.WithNoOpUpdate[resourceReservedCacheNodeModel] + framework.WithNoOpDelete + framework.WithTimeouts +} + +func (r *resourceReservedCacheNode) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { + response.TypeName = "aws_elasticache_reserved_cache_node" +} + +func (r *resourceReservedCacheNode) Schema(ctx context.Context, request resource.SchemaRequest, response *resource.SchemaResponse) { + response.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + names.AttrARN: schema.StringAttribute{ + Computed: true, + }, + "cache_node_count": schema.Int32Attribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int32{ + int32planmodifier.RequiresReplace(), + int32planmodifier.UseStateForUnknown(), + }, + }, + "cache_node_type": schema.StringAttribute{ + CustomType: fwtypes.RFC3339DurationType, + Computed: true, + }, + names.AttrDuration: schema.StringAttribute{ + Computed: true, + }, + "fixed_price": schema.Float64Attribute{ + Computed: true, + }, + names.AttrID: schema.StringAttribute{ + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + stringplanmodifier.UseStateForUnknown(), + }, + }, + "reserved_cache_nodes_offering_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "offering_type": schema.StringAttribute{ + Computed: true, + }, + "product_description": schema.StringAttribute{ + Computed: true, + }, + "recurring_charges": schema.ListAttribute{ + CustomType: fwtypes.NewListNestedObjectTypeOf[recurringChargeModel](ctx), + Computed: true, + ElementType: types.ObjectType{ + AttrTypes: fwtypes.AttributeTypesMust[recurringChargeModel](ctx), + }, + }, + names.AttrStartTime: schema.StringAttribute{ + Computed: true, + }, + names.AttrState: schema.StringAttribute{ + Computed: true, + }, + names.AttrTags: tftags.TagsAttribute(), + names.AttrTagsAll: tftags.TagsAttributeComputedOnly(), + "usage_price": schema.Float64Attribute{ + Computed: true, + }, + }, + + Blocks: map[string]schema.Block{ + names.AttrTimeouts: timeouts.Block(ctx, timeouts.Opts{ + Create: true, + Update: true, + Delete: true, + }), + }, + } +} + +// Create is called when the provider must create a new resource. +// Config and planned state values should be read from the CreateRequest and new state values set on the CreateResponse. +func (r *resourceReservedCacheNode) Create(ctx context.Context, request resource.CreateRequest, response *resource.CreateResponse) { + var data resourceReservedCacheNodeModel + + response.Diagnostics.Append(request.Plan.Get(ctx, &data)...) + if response.Diagnostics.HasError() { + return + } + + conn := r.Meta().ElastiCacheClient(ctx) + + var input elasticache.PurchaseReservedCacheNodesOfferingInput + response.Diagnostics.Append(flex.Expand(ctx, data, &input, r.flexOpts()...)...) + + input.Tags = getTagsIn(ctx) + + resp, err := conn.PurchaseReservedCacheNodesOffering(ctx, &input) + if err != nil { + response.Diagnostics.AddError( + "Creating ElastiCache Reserved Cache Node", + fmt.Sprintf("Could not create ElastiCache Reserved Cache Node with Offering ID %q\nError: %s", data.ReservedCacheNodesOfferingID.ValueString(), err.Error()), + ) + return + } + + createTimeout := r.CreateTimeout(ctx, data.Timeouts) + if err := waitReservedCacheNodeCreated(ctx, conn, aws.ToString(resp.ReservedCacheNode.ReservedCacheNodeId), createTimeout); err != nil { + response.Diagnostics.AddError( + "Creating ElastiCache Reserved Cache Node", + fmt.Sprintf("Creating ElastiCache Reserved Cache Node with Offering ID %q failed while waiting for completion.\nError: %s", data.ReservedCacheNodesOfferingID.ValueString(), err.Error()), + ) + return + } + + response.Diagnostics.Append(flex.Flatten(ctx, resp.ReservedCacheNode, &data, r.flexOpts()...)...) + if response.Diagnostics.HasError() { + return + } + + duration := time.Duration(aws.ToInt32(resp.ReservedCacheNode.Duration)) * time.Second + data.Duration = fwtypes.RFC3339DurationTimeDurationValue(duration) + + response.Diagnostics.Append(response.State.Set(ctx, &data)...) +} + +func (r *resourceReservedCacheNode) Read(ctx context.Context, request resource.ReadRequest, response *resource.ReadResponse) { + var data resourceReservedCacheNodeModel + + response.Diagnostics.Append(request.State.Get(ctx, &data)...) + if response.Diagnostics.HasError() { + return + } + + conn := r.Meta().ElastiCacheClient(ctx) + + reservation, err := findReservedCacheNodeByID(ctx, conn, data.ID.ValueString()) + + if tfresource.NotFound(err) { + response.Diagnostics.Append(fwdiag.NewResourceNotFoundWarningDiagnostic(err)) + response.State.RemoveResource(ctx) + return + } + + if err != nil { + response.Diagnostics.AddError(fmt.Sprintf("reading ElastiCache Reserved Cache Node (%s)", data.ID.ValueString()), err.Error()) + return + } + + response.Diagnostics.Append(flex.Flatten(ctx, reservation, &data)...) + if response.Diagnostics.HasError() { + return + } + + duration := time.Duration(aws.ToInt32(reservation.Duration)) * time.Second + data.Duration = fwtypes.RFC3339DurationTimeDurationValue(duration) + + response.Diagnostics.Append(response.State.Set(ctx, &data)...) +} + +func (r *resourceReservedCacheNode) ImportState(ctx context.Context, request resource.ImportStateRequest, response *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root(names.AttrID), request, response) +} + +func (r *resourceReservedCacheNode) ModifyPlan(ctx context.Context, request resource.ModifyPlanRequest, response *resource.ModifyPlanResponse) { + r.SetTagsAll(ctx, request, response) +} + +func (r *resourceReservedCacheNode) flexOpts() []flex.AutoFlexOptionsFunc { + return []flex.AutoFlexOptionsFunc{ + flex.WithFieldNamePrefix("ReservedCacheNode"), + } +} + +type resourceReservedCacheNodeModel struct { + ARN types.String `tfsdk:"arn"` + CacheNodeCount types.Int32 `tfsdk:"cache_node_count"` + CacheNodeType types.String `tfsdk:"cache_node_type"` + Duration fwtypes.RFC3339Duration `tfsdk:"duration" autoflex:",noflatten"` + FixedPrice types.Float64 `tfsdk:"fixed_price"` + ID types.String `tfsdk:"id"` + ReservedCacheNodesOfferingID types.String `tfsdk:"reserved_cache_nodes_offering_id"` + OfferingType types.String `tfsdk:"offering_type"` + ProductDescription types.String `tfsdk:"product_description"` + RecurringCharges fwtypes.ListNestedObjectValueOf[recurringChargeModel] `tfsdk:"recurring_charges"` + StartTime types.String `tfsdk:"start_time"` + State types.String `tfsdk:"state"` + Tags tftags.Map `tfsdk:"tags"` + TagsAll tftags.Map `tfsdk:"tags_all"` + UsagePrice types.Float64 `tfsdk:"usage_price"` + + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +type recurringChargeModel struct { + RecurringChargeAmount types.Float64 `tfsdk:"recurring_charge_amount"` + RecurringChargeFrequency types.String `tfsdk:"recurring_charge_frequency"` +} + +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 + } +} 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..ef28c11da54 --- /dev/null +++ b/internal/service/elasticache/reserved_cache_node_offering_data_source.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package elasticache + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/elasticache" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-provider-aws/internal/framework" + "github.com/hashicorp/terraform-provider-aws/internal/framework/flex" + fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @FrameworkDataSource("aws_elasticache_reserved_cache_node_offering") +func newDataSourceReservedCacheNodeOffering(context.Context) (datasource.DataSourceWithConfigure, error) { + return &dataSourceReservedCacheNodeOffering{}, nil +} + +type dataSourceReservedCacheNodeOffering struct { + framework.DataSourceWithConfigure +} + +func (d *dataSourceReservedCacheNodeOffering) Metadata(_ context.Context, request datasource.MetadataRequest, response *datasource.MetadataResponse) { + response.TypeName = "aws_elasticache_reserved_cache_node_offering" +} + +func (d *dataSourceReservedCacheNodeOffering) Schema(ctx context.Context, request datasource.SchemaRequest, response *datasource.SchemaResponse) { + response.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "cache_node_type": schema.StringAttribute{ + Required: true, + }, + names.AttrDuration: schema.StringAttribute{ + CustomType: fwtypes.RFC3339DurationType, + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf("P1Y", "P3Y"), + }, + }, + "fixed_price": schema.Float64Attribute{ + Computed: true, + }, + "offering_id": schema.StringAttribute{ + Computed: true, + }, + "offering_type": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf( + "Light Utilization", + "Medium Utilization", + "Heavy Utilization", + "Partial Upfront", + "All Upfront", + "No Upfront", + ), + }, + }, + "product_description": schema.StringAttribute{ + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(engine_Values()...), + }, + }, + }, + } +} + +// Read is called when the provider must read data source values in order to update state. +// Config values should be read from the ReadRequest and new state values set on the ReadResponse. +func (d *dataSourceReservedCacheNodeOffering) Read(ctx context.Context, request datasource.ReadRequest, response *datasource.ReadResponse) { + var data dataSourceReservedCacheNodeOfferingModel + + response.Diagnostics.Append(request.Config.Get(ctx, &data)...) + if response.Diagnostics.HasError() { + return + } + + conn := d.Meta().ElastiCacheClient(ctx) + + flexOpt := flex.WithFieldNamePrefix("ReservedCacheNodes") + + var input elasticache.DescribeReservedCacheNodesOfferingsInput + response.Diagnostics.Append(flex.Expand(ctx, data, &input, flexOpt)...) + if response.Diagnostics.HasError() { + return + } + + resp, err := conn.DescribeReservedCacheNodesOfferings(ctx, &input) + if err != nil { + response.Diagnostics.AddError("reading ElastiCache Reserved Cache Node Offering", err.Error()) + return + } + + offering, err := tfresource.AssertSingleValueResult(resp.ReservedCacheNodesOfferings) + if err != nil { + response.Diagnostics.AddError("reading ElastiCache Reserved Cache Node Offering", err.Error()) + return + } + + response.Diagnostics.Append(flex.Flatten(ctx, offering, &data, flexOpt)...) + if response.Diagnostics.HasError() { + return + } + + response.Diagnostics.Append(response.State.Set(ctx, &data)...) +} + +type dataSourceReservedCacheNodeOfferingModel struct { + CacheNodeType types.String `tfsdk:"cache_node_type"` + Duration fwtypes.RFC3339Duration `tfsdk:"duration" autoflex:",noflatten"` + FixedPrice types.Float64 `tfsdk:"fixed_price"` + OfferingID types.String `tfsdk:"offering_id"` + OfferingType types.String `tfsdk:"offering_type"` + ProductDescription types.String `tfsdk:"product_description"` +} 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..1e45423e480 --- /dev/null +++ b/internal/service/elasticache/reserved_cache_node_offering_data_source_test.go @@ -0,0 +1,48 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package elasticache_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccElastiCacheReservedNodeOffering_basic(t *testing.T) { + ctx := acctest.Context(t) + dataSourceName := "data.aws_elasticache_reserved_cache_node_offering.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: nil, + ErrorCheck: acctest.ErrorCheck(t, names.ElastiCacheServiceID), + Steps: []resource.TestStep{ + { + Config: testAccReservedNodeOfferingConfig_basic(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dataSourceName, "cache_node_type", "cache.t4g.small"), + resource.TestCheckResourceAttr(dataSourceName, names.AttrDuration, "P1Y"), + 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.t4g.small" + duration = "P1Y" + 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..47b22253e9e --- /dev/null +++ b/internal/service/elasticache/reserved_cache_node_test.go @@ -0,0 +1,151 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package elasticache_test + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/YakDriver/regexache" + awstypes "github.com/aws/aws-sdk-go-v2/service/elasticache/types" + sdkacctest "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "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" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccElastiCacheReservedCacheNode_basic(t *testing.T) { + ctx := acctest.Context(t) + if os.Getenv("TF_TEST_ELASTICACHE_RESERVED_CACHE_NODE") == "" { + t.Skip("Environment variable TF_TEST_ELASTICACHE_RESERVED_CACHE_NODE is not set") + } + + var reservation awstypes.ReservedCacheNode + resourceName := "aws_elasticache_reserved_cache_node.test" + dataSourceName := "data.aws_elasticache_reserved_cache_node_offering.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: nil, + ErrorCheck: acctest.ErrorCheck(t, names.ElastiCacheServiceID), + Steps: []resource.TestStep{ + { + Config: testAccReservedInstanceConfig_basic(), + Check: resource.ComposeTestCheckFunc( + testAccReservedInstanceExists(ctx, resourceName, &reservation), + acctest.MatchResourceAttrRegionalARN(resourceName, names.AttrARN, "elasticache", regexache.MustCompile(`reserved-instance:.+`)), + resource.TestCheckResourceAttr(resourceName, "cache_node_count", acctest.Ct1), + resource.TestCheckResourceAttrPair(dataSourceName, "cache_node_type", resourceName, "cache_node_type"), + resource.TestCheckResourceAttrPair(dataSourceName, names.AttrDuration, resourceName, names.AttrDuration), + resource.TestCheckResourceAttrPair(dataSourceName, "fixed_price", resourceName, "fixed_price"), + resource.TestCheckResourceAttrSet(resourceName, names.AttrID), + resource.TestCheckResourceAttrPair(dataSourceName, "reserved_cache_nodes_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.TestCheckResourceAttrSet(resourceName, names.AttrStartTime), + resource.TestCheckResourceAttrSet(resourceName, names.AttrState), + resource.TestCheckResourceAttrSet(resourceName, "usage_price"), + ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New(names.AttrTags), knownvalue.MapExact(map[string]knownvalue.Check{})), + statecheck.ExpectKnownValue(resourceName, tfjsonpath.New(names.AttrTagsAll), knownvalue.MapExact(map[string]knownvalue.Check{})), + }, + }, + }, + }) +} + +func TestAccElastiCacheReservedCacheNode_ID(t *testing.T) { + ctx := acctest.Context(t) + if os.Getenv("TF_TEST_ELASTICACHE_RESERVED_CACHE_NODE") == "" { + t.Skip("Environment variable TF_TEST_ELASTICACHE_RESERVED_CACHE_NODE is not set") + } + + var reservation awstypes.ReservedCacheNode + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_elasticache_reserved_cache_node.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: nil, + ErrorCheck: acctest.ErrorCheck(t, names.ElastiCacheServiceID), + Steps: []resource.TestStep{ + { + Config: testAccReservedInstanceConfig_ID(rName), + Check: resource.ComposeTestCheckFunc( + testAccReservedInstanceExists(ctx, resourceName, &reservation), + resource.TestCheckResourceAttr(resourceName, names.AttrID, rName), + resource.TestCheckResourceAttrSet(resourceName, "usage_price"), + ), + }, + }, + }) +} + +func testAccReservedInstanceExists(ctx context.Context, n string, reservation *awstypes.ReservedCacheNode) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).ElastiCacheClient(ctx) + + 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 + } + + *reservation = resp + + return nil + } +} + +func testAccReservedInstanceConfig_basic() string { + return ` +resource "aws_elasticache_reserved_cache_node" "test" { + offering_id = data.aws_elasticache_reserved_cache_node_offering.test.offering_id +} + +data "aws_elasticache_reserved_cache_node_offering" "test" { + cache_node_type = "cache.t4g.small" + duration = 31536000 + offering_type = "No Upfront" + product_description = "redis" +} +` +} + +func testAccReservedInstanceConfig_ID(rName string) string { + return fmt.Sprintf(` +resource "aws_elasticache_reserved_cache_node" "test" { + offering_id = data.aws_elasticache_reserved_cache_node_offering.test.offering_id + id = %[1]q +} + +data "aws_elasticache_reserved_cache_node_offering" "test" { + cache_node_type = "cache.t4g.small" + duration = 31536000 + offering_type = "No Upfront" + product_description = "redis" +} +`, rName) +} diff --git a/internal/service/elasticache/service_package_gen.go b/internal/service/elasticache/service_package_gen.go index dc973a3bc2f..88d85fd6e45 100644 --- a/internal/service/elasticache/service_package_gen.go +++ b/internal/service/elasticache/service_package_gen.go @@ -15,11 +15,21 @@ import ( type servicePackage struct{} func (p *servicePackage) FrameworkDataSources(ctx context.Context) []*types.ServicePackageFrameworkDataSource { - return []*types.ServicePackageFrameworkDataSource{} + return []*types.ServicePackageFrameworkDataSource{ + { + Factory: newDataSourceReservedCacheNodeOffering, + }, + } } func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.ServicePackageFrameworkResource { return []*types.ServicePackageFrameworkResource{ + { + Factory: newResourceReservedCacheNode, + Tags: &types.ServicePackageResourceTags{ + IdentifierAttribute: names.AttrARN, + }, + }, { Factory: newServerlessCacheResource, Name: "Serverless Cache", diff --git a/internal/types/duration/duration.go b/internal/types/duration/duration.go index c282862b186..d235ad7edaf 100644 --- a/internal/types/duration/duration.go +++ b/internal/types/duration/duration.go @@ -64,6 +64,26 @@ func Parse(s string) (Duration, error) { return duration, nil } +// NewFromTimeDuration converts a time.Duration to a duration.Duration. +// Only the years and days fields are populated. +func NewFromTimeDuration(t time.Duration) (result Duration) { + u := uint64(t) + + const ( + day = uint64(24 * time.Hour) + year = 365 * day + ) + + if u >= year { + result.years = int(u / year) + u = u % year + } + + result.days = int(u / day) + + return result +} + func (d Duration) String() string { var b strings.Builder b.WriteString("P") diff --git a/internal/types/duration/duration_test.go b/internal/types/duration/duration_test.go index fa963e568d5..1f24743c6c8 100644 --- a/internal/types/duration/duration_test.go +++ b/internal/types/duration/duration_test.go @@ -96,6 +96,53 @@ func TestParse(t *testing.T) { } } +func TestNewFromTimeDuration(t *testing.T) { + t.Parallel() + + const ( + day = 24 * time.Hour + year = 365 * day + ) + + testcases := map[string]struct { + input time.Duration + expected Duration + }{ + // Single + "years only": { + input: 2 * year, + expected: Duration{years: 2}, + }, + "days only": { + input: 21 * day, + expected: Duration{days: 21}, + }, + + // Multiple + "years days": { + input: 1*year + 15*day, + expected: Duration{years: 1, days: 15}, + }, + + "zero": { + input: 0, + expected: Duration{}, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + duration := NewFromTimeDuration(tc.input) + + if !duration.equal(tc.expected) { + t.Errorf("expected %q, got %q", tc.expected, duration) + } + }) + } +} + func TestSub(t *testing.T) { t.Parallel() diff --git a/tools/tfsdk2fw/datasource.gtpl b/tools/tfsdk2fw/datasource.gtpl index 6c891876384..6f1d575d325 100644 --- a/tools/tfsdk2fw/datasource.gtpl +++ b/tools/tfsdk2fw/datasource.gtpl @@ -13,7 +13,7 @@ import ( {{if .ImportProviderFrameworkTypes }}fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types"{{- end}} ) -// @FrameworkDataSource +// @FrameworkDataSource("{{ .TFTypeName }}") func newDataSource{{ .Name }}(context.Context) (datasource.DataSourceWithConfigure, error) { return &dataSource{{ .Name }}{}, nil } diff --git a/tools/tfsdk2fw/go.mod b/tools/tfsdk2fw/go.mod index e7cc2deeebb..c4bd0885f80 100644 --- a/tools/tfsdk2fw/go.mod +++ b/tools/tfsdk2fw/go.mod @@ -203,6 +203,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/pcaconnectorad v1.7.6 // indirect github.com/aws/aws-sdk-go-v2/service/pcs v1.0.2 // indirect github.com/aws/aws-sdk-go-v2/service/pinpoint v1.32.6 // indirect + github.com/aws/aws-sdk-go-v2/service/pinpointsmsvoicev2 v1.12.8 // indirect github.com/aws/aws-sdk-go-v2/service/pipes v1.15.0 // indirect github.com/aws/aws-sdk-go-v2/service/polly v1.43.2 // indirect github.com/aws/aws-sdk-go-v2/service/pricing v1.30.6 // indirect diff --git a/tools/tfsdk2fw/go.sum b/tools/tfsdk2fw/go.sum index 8fdfeea5309..c137367e34b 100644 --- a/tools/tfsdk2fw/go.sum +++ b/tools/tfsdk2fw/go.sum @@ -392,6 +392,8 @@ github.com/aws/aws-sdk-go-v2/service/pcs v1.0.2 h1:+PSbd/wTgCueA9agqNNeSmVoOgcgA github.com/aws/aws-sdk-go-v2/service/pcs v1.0.2/go.mod h1:acm3akB4exauzjZeKNonTwkxCPIdWT1LWLRM09eZP7c= github.com/aws/aws-sdk-go-v2/service/pinpoint v1.32.6 h1:S5SxTH9Ue7cwK9O76RQKkt9xY+zapTJv6dutXEyKOGQ= github.com/aws/aws-sdk-go-v2/service/pinpoint v1.32.6/go.mod h1:2yK6vZtj8t8tmEOk2/XBk/7oC9QggiRIDhwt1rUNkPE= +github.com/aws/aws-sdk-go-v2/service/pinpointsmsvoicev2 v1.12.8 h1:3GiUwkpy6GXMqVdfIfbWkBR86dOsd38obv4sBwyRxZ8= +github.com/aws/aws-sdk-go-v2/service/pinpointsmsvoicev2 v1.12.8/go.mod h1:Ek88Y1SlTvTDgX9L7DWUPfQIYtT++3eqK7cMK0TdW8Q= github.com/aws/aws-sdk-go-v2/service/pipes v1.15.0 h1:2P3Y9TFqZP2V8rJquXMEcXQ3D2Ybdvj+qD9wG9m0Sio= github.com/aws/aws-sdk-go-v2/service/pipes v1.15.0/go.mod h1:JKl45FQijnuqkji3jAlVTH0tRTbYYZSUb00P9HClkRg= github.com/aws/aws-sdk-go-v2/service/polly v1.43.2 h1:AmoLJRNIJQvN4CcXPhLwXPaDOnke2EXAWe9T+MNloEE= diff --git a/website/docs/d/elasticache_reserved_cache_node_offering.html.markdown b/website/docs/d/elasticache_reserved_cache_node_offering.html.markdown new file mode 100644 index 00000000000..d9a9af4fba1 --- /dev/null +++ b/website/docs/d/elasticache_reserved_cache_node_offering.html.markdown @@ -0,0 +1,46 @@ +--- +subcategory: "ElastiCache" +layout: "aws" +page_title: "AWS: aws_elasticache_reserved_cache_node_offering" +description: |- + Information about a single ElastiCache Reserved Cache Node Offering. +--- + +# Data Source: aws_elasticache_reserved_cache_node_offering + +Information about a single ElastiCache Reserved Cache Node Offering. + +## Example Usage + +```terraform +data "aws_elasticache_reserved_cache_node_offering" "example" { + cache_node_type = "cache.t4g.small" + duration = "P1Y" + offering_type = "No Upfront" + product_description = "redis" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `cache_node_type` - (Required) Node type for the reserved cache node. + See AWS documentation for information on [supported node types for Redis](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheNodes.SupportedTypes.html) and [guidance on selecting node types for Redis](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/nodes-select-size.html). + See AWS documentation for information on [supported node types for Memcached](https://docs.aws.amazon.com/AmazonElastiCache/latest/mem-ug/CacheNodes.SupportedTypes.html) and [guidance on selecting node types for Memcached](https://docs.aws.amazon.com/AmazonElastiCache/latest/mem-ug/nodes-select-size.html). +* `duration` - (Required) Duration of the reservation in RFC3339 duration format. + Valid values are `P1Y` (one year) and `P3Y` (three years). +* `offering_type` - (Required) Offering type of this reserved cache node. + For the latest generation of nodes (e.g. M5, R5, T4 and newer) valid values are `No Upfront`, `Partial Upfront`, and `All Upfront`. + For other current generation nodes (i.e. T2, M3, M4, R3, or R4) the only valid value is `Heavy Utilization`. + For previous generation modes (i.e. T1, M1, M2, or C1) valid values are `Heavy Utilization`, `Medium Utilization`, and `Light Utilization`. +* `product_description` - (Required) Engine type for the reserved cache node. + Valid values are `redis` and `memcached`. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - Unique identifier for the reservation. Same as `offering_id`. +* `fixed_price` - Fixed price charged for this reserved cache node. +* `offering_id` - Unique identifier for the reservation. diff --git a/website/docs/r/elasticache_cluster.html.markdown b/website/docs/r/elasticache_cluster.html.markdown index cf39d8ee06b..2068bac4fa2 100644 --- a/website/docs/r/elasticache_cluster.html.markdown +++ b/website/docs/r/elasticache_cluster.html.markdown @@ -141,7 +141,10 @@ The following arguments are required: * `cluster_id` – (Required) Group identifier. ElastiCache converts this name to lowercase. Changing this value will re-create the resource. * `engine` – (Optional, Required if `replication_group_id` is not specified) Name of the cache engine to be used for this cache cluster. Valid values are `memcached` or `redis`. -* `node_type` – (Required unless `replication_group_id` is provided) The instance class used. See AWS documentation for information on [supported node types for Redis](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheNodes.SupportedTypes.html) and [guidance on selecting node types for Redis](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/nodes-select-size.html). See AWS documentation for information on [supported node types for Memcached](https://docs.aws.amazon.com/AmazonElastiCache/latest/mem-ug/CacheNodes.SupportedTypes.html) and [guidance on selecting node types for Memcached](https://docs.aws.amazon.com/AmazonElastiCache/latest/mem-ug/nodes-select-size.html). For Memcached, changing this value will re-create the resource. +* `node_type` – (Required unless `replication_group_id` is provided) The instance class used. + See AWS documentation for information on [supported node types for Redis](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheNodes.SupportedTypes.html) and [guidance on selecting node types for Redis](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/nodes-select-size.html). + See AWS documentation for information on [supported node types for Memcached](https://docs.aws.amazon.com/AmazonElastiCache/latest/mem-ug/CacheNodes.SupportedTypes.html) and [guidance on selecting node types for Memcached](https://docs.aws.amazon.com/AmazonElastiCache/latest/mem-ug/nodes-select-size.html). + For Memcached, changing this value will re-create the resource. * `num_cache_nodes` – (Required unless `replication_group_id` is provided) The initial number of cache nodes that the cache cluster will have. For Redis, this value must be 1. For Memcached, this value must be between 1 and 40. If this number is reduced on subsequent runs, the highest numbered nodes will be removed. * `parameter_group_name` – (Required unless `replication_group_id` is provided) The name of the parameter group to associate with this cache cluster. diff --git a/website/docs/r/elasticache_replication_group.html.markdown b/website/docs/r/elasticache_replication_group.html.markdown index c10a52ed666..47d94a7b4be 100644 --- a/website/docs/r/elasticache_replication_group.html.markdown +++ b/website/docs/r/elasticache_replication_group.html.markdown @@ -220,7 +220,10 @@ The following arguments are optional: If `true`, `automatic_failover_enabled` must also be enabled. Defaults to `false`. * `network_type` - (Optional) The IP versions for cache cluster connections. Valid values are `ipv4`, `ipv6` or `dual_stack`. -* `node_type` - (Optional) Instance class to be used. See AWS documentation for information on [supported node types](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheNodes.SupportedTypes.html) and [guidance on selecting node types](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/nodes-select-size.html). Required unless `global_replication_group_id` is set. Cannot be set if `global_replication_group_id` is set. +* `node_type` - (Optional) Instance class to be used. + See AWS documentation for information on [supported node types](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/CacheNodes.SupportedTypes.html) and [guidance on selecting node types](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/nodes-select-size.html). + Required unless `global_replication_group_id` is set. + Cannot be set if `global_replication_group_id` is set. * `notification_topic_arn` – (Optional) ARN of an SNS topic to send ElastiCache notifications to. Example: `arn:aws:sns:us-east-1:012345678999:my_sns_topic` * `num_cache_clusters` - (Optional) Number of cache clusters (primary and replicas) this replication group will have. If `automatic_failover_enabled` or `multi_az_enabled` are `true`, must be at least 2. diff --git a/website/docs/r/elasticache_reserved_cache_node.html.markdown b/website/docs/r/elasticache_reserved_cache_node.html.markdown new file mode 100644 index 00000000000..3030ad5305b --- /dev/null +++ b/website/docs/r/elasticache_reserved_cache_node.html.markdown @@ -0,0 +1,88 @@ +--- +subcategory: "ElastiCache" +layout: "aws" +page_title: "AWS: aws_elasticache_reserved_cache_node" +description: |- + Manages an ElastiCache Reserved Cache Node +--- + +# Resource: aws_elasticache_reserved_cache_node + +Manages an ElastiCache Reserved Cache Node. + +~> **NOTE:** Once created, a reservation is valid for the `duration` of the provided `offering_id` and cannot be deleted. Performing a `destroy` will only remove the resource from state. For more information see [ElastiCache Reserved Nodes Documentation](https://aws.amazon.com/elasticache/reserved-cache-nodes/) and [PurchaseReservedCacheNodesOffering](https://docs.aws.amazon.com/AmazonElastiCache/latest/APIReference/API_PurchaseReservedCacheNodesOffering.html). + +~> **NOTE:** Due to the expense of testing this resource, we provide it as best effort. If you find it useful, and have the ability to help test or notice issues, consider reaching out to us on [GitHub](https://github.com/hashicorp/terraform-provider-aws). + +## Example Usage + +```terraform +data "aws_elasticache_reserved_cache_node_offering" "example" { + cache_node_type = "cache.t4g.small" + duration = "P1Y" + offering_type = "No Upfront" + product_description = "redis" +} + +resource "aws_elasticache_reserved_cache_node" "example" { + reserved_cache_nodes_offering_id = data.aws_elasticache_reserved_cache_node_offering.example.offering_id + id = "optionalCustomReservationID" + cache_node_count = 3 +} +``` + +## Argument Reference + +The following arguments are required: + +* `reserved_cache_nodes_offering_id` - (Required) ID of the reserved cache node offering to purchase. + To determine an `reserved_cache_nodes_offering_id`, see the `aws_elasticache_reserved_cache_node_offering` data source. + +The following arguments are optional: + +* `instance_count` - (Optional) Number of cache node instances to reserve. + Default value is `1`. +* `id` - (Optional) Customer-specified identifier to track this reservation. + If not specified, AWS will assign a random ID. +* `tags` - (Optional) Map of tags to assign to the reservation. If configured with a provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. + +## Attribute Reference + +This resource exports the following attributes in addition to the arguments above: + +* `arn` - ARN for the reserved cache node. +* `duration` - Duration of the reservation as an RFC3339 duration. +* `fixed_price` – Fixed price charged for this reserved cache node. +* `cache_node_type` - Node type for the reserved cache nodes. +* `offering_type` - Offering type of this reserved cache node. +* `product_description` - Engine type for the reserved cache node. +* `recurring_charges` - Recurring price charged to run this reserved cache node. +* `start_time` - Time the reservation started. +* `state` - State of the reserved cache node. +* `usage_price` - Hourly price charged for this reserved cache node. +* `tags_all` - Map of tags assigned to the resource, including those inherited from the provider [`default_tags` configuration block](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block). + +## Timeouts + +[Configuration options](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts): + +- `create` - (Default `30m`) +- `update` - (Default `10m`) +- `delete` - (Default `1m`) + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import ElastiCache Reserved Cache Nodes using the `id`. For example: + +```terraform +import { + to = aws_elasticache_reserved_cache_node.example + id = "CustomReservationID" +} +``` + +Using `terraform import`, import ElastiCache Reserved Cache Node using the `id`. For example: + +```console +% terraform import aws_elasticache_reserved_cache_node.example CustomReservationID +```