diff --git a/.changelog/15885.txt b/.changelog/15885.txt new file mode 100644 index 00000000000..021aa6bc29e --- /dev/null +++ b/.changelog/15885.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_elasticache_global_replication_group +``` diff --git a/aws/aws_sweeper_test.go b/aws/aws_sweeper_test.go index fdece2fff54..36113743231 100644 --- a/aws/aws_sweeper_test.go +++ b/aws/aws_sweeper_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/envvar" ) // sweeperAwsClients is a shared cache of regional AWSClient @@ -24,8 +25,16 @@ func sharedClientForRegion(region string) (interface{}, error) { return client, nil } - if os.Getenv("AWS_PROFILE") == "" && (os.Getenv("AWS_ACCESS_KEY_ID") == "" || os.Getenv("AWS_SECRET_ACCESS_KEY") == "") { - return nil, fmt.Errorf("must provide environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY or environment variable AWS_PROFILE") + _, _, err := envvar.RequireOneOf([]string{envvar.AwsProfile, envvar.AwsAccessKeyId, envvar.AwsContainerCredentialsFullUri}, "credentials for running sweepers") + if err != nil { + return nil, err + } + + if os.Getenv(envvar.AwsAccessKeyId) != "" { + _, err := envvar.Require(envvar.AwsSecretAccessKey, "static credentials value when using "+envvar.AwsAccessKeyId) + if err != nil { + return nil, err + } } conf := &Config{ diff --git a/aws/internal/envvar/funcs.go b/aws/internal/envvar/funcs.go index de23c2e08a7..73f5ac01972 100644 --- a/aws/internal/envvar/funcs.go +++ b/aws/internal/envvar/funcs.go @@ -1,6 +1,7 @@ package envvar import ( + "fmt" "os" ) @@ -14,3 +15,29 @@ func GetWithDefault(variable string, defaultValue string) string { return value } + +// RequireOneOf verifies that at least one environment variable is non-empty or returns an error. +// +// If at lease one environment variable is non-empty, returns the first name and value. +func RequireOneOf(names []string, usageMessage string) (string, string, error) { + for _, variable := range names { + value := os.Getenv(variable) + + if value != "" { + return variable, value, nil + } + } + + return "", "", fmt.Errorf("at least one environment variable of %v must be set. Usage: %s", names, usageMessage) +} + +// Require verifies that an environment variable is non-empty or returns an error. +func Require(name string, usageMessage string) (string, error) { + value := os.Getenv(name) + + if value == "" { + return "", fmt.Errorf("environment variable %s must be set. Usage: %s", name, usageMessage) + } + + return value, nil +} diff --git a/aws/internal/envvar/funcs_test.go b/aws/internal/envvar/funcs_test.go index 1a232627283..d2bc27b6b24 100644 --- a/aws/internal/envvar/funcs_test.go +++ b/aws/internal/envvar/funcs_test.go @@ -48,3 +48,119 @@ func TestGetWithDefault(t *testing.T) { } }) } + +func TestRequireOneOf(t *testing.T) { + envVar1 := "TESTENVVAR_REQUIREONEOF1" + envVar2 := "TESTENVVAR_REQUIREONEOF2" + envVars := []string{envVar1, envVar2} + + t.Run("missing", func(t *testing.T) { + for _, envVar := range envVars { + os.Unsetenv(envVar) + } + + _, _, err := envvar.RequireOneOf(envVars, "usage") + + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("all empty", func(t *testing.T) { + os.Setenv(envVar1, "") + os.Setenv(envVar2, "") + defer unsetEnvVars(envVars) + + _, _, err := envvar.RequireOneOf(envVars, "usage") + + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("some empty", func(t *testing.T) { + wantValue := "pickme" + + os.Setenv(envVar1, "") + os.Setenv(envVar2, wantValue) + defer unsetEnvVars(envVars) + + gotName, gotValue, err := envvar.RequireOneOf(envVars, "usage") + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if gotName != envVar2 { + t.Fatalf("expected name: %s, got: %s", envVar2, gotName) + } + + if gotValue != wantValue { + t.Fatalf("expected value: %s, got: %s", wantValue, gotValue) + } + }) + + t.Run("all not empty", func(t *testing.T) { + wantValue := "pickme" + + os.Setenv(envVar1, wantValue) + os.Setenv(envVar2, "other") + defer unsetEnvVars(envVars) + + gotName, gotValue, err := envvar.RequireOneOf(envVars, "usage") + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if gotName != envVar1 { + t.Fatalf("expected name: %s, got: %s", envVar1, gotName) + } + + if gotValue != wantValue { + t.Fatalf("expected value: %s, got: %s", wantValue, gotValue) + } + }) +} + +func TestRequire(t *testing.T) { + envVar := "TESTENVVAR_REQUIRE" + + t.Run("missing", func(t *testing.T) { + os.Unsetenv(envVar) + + _, err := envvar.Require(envVar, "usage") + + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("empty", func(t *testing.T) { + os.Setenv(envVar, "") + defer os.Unsetenv(envVar) + + _, err := envvar.Require(envVar, "usage") + + if err == nil { + t.Fatal("expected error") + } + }) + + t.Run("not empty", func(t *testing.T) { + want := "notempty" + + os.Setenv(envVar, want) + defer os.Unsetenv(envVar) + + got, err := envvar.Require(envVar, "usage") + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if got != want { + t.Fatalf("expected value: %s, got: %s", want, got) + } + }) +} diff --git a/aws/internal/envvar/testing_funcs.go b/aws/internal/envvar/testing_funcs.go index ff01b950a02..841ec3d0115 100644 --- a/aws/internal/envvar/testing_funcs.go +++ b/aws/internal/envvar/testing_funcs.go @@ -12,17 +12,13 @@ import ( func TestFailIfAllEmpty(t testing.T, names []string, usageMessage string) (string, string) { t.Helper() - for _, variable := range names { - value := os.Getenv(variable) - - if value != "" { - return variable, value - } + name, value, err := RequireOneOf(names, usageMessage) + if err != nil { + t.Fatal(err) + return "", "" } - t.Fatalf("at least one environment variable of %v must be set. Usage: %s", names, usageMessage) - - return "", "" + return name, value } // TestFailIfEmpty verifies that an environment variable is non-empty or fails the test. diff --git a/aws/internal/service/elasticache/finder/finder.go b/aws/internal/service/elasticache/finder/finder.go index 6491028c7d1..c4d37907090 100644 --- a/aws/internal/service/elasticache/finder/finder.go +++ b/aws/internal/service/elasticache/finder/finder.go @@ -1,6 +1,8 @@ package finder import ( + "fmt" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/elasticache" "github.com/hashicorp/aws-sdk-go-base/tfawserr" @@ -12,7 +14,7 @@ func ReplicationGroupByID(conn *elasticache.ElastiCache, id string) (*elasticach input := &elasticache.DescribeReplicationGroupsInput{ ReplicationGroupId: aws.String(id), } - result, err := conn.DescribeReplicationGroups(input) + output, err := conn.DescribeReplicationGroups(input) if tfawserr.ErrCodeEquals(err, elasticache.ErrCodeReplicationGroupNotFoundFault) { return nil, &resource.NotFoundError{ LastError: err, @@ -23,23 +25,21 @@ func ReplicationGroupByID(conn *elasticache.ElastiCache, id string) (*elasticach return nil, err } - if result == nil || len(result.ReplicationGroups) == 0 || result.ReplicationGroups[0] == nil { + if output == nil || len(output.ReplicationGroups) == 0 || output.ReplicationGroups[0] == nil { return nil, &resource.NotFoundError{ Message: "Empty result", LastRequest: input, } } - return result.ReplicationGroups[0], nil + return output.ReplicationGroups[0], nil } // ReplicationGroupMemberClustersByID retrieves all of an ElastiCache Replication Group's MemberClusters by the id of the Replication Group. func ReplicationGroupMemberClustersByID(conn *elasticache.ElastiCache, id string) ([]*elasticache.CacheCluster, error) { - var results []*elasticache.CacheCluster - rg, err := ReplicationGroupByID(conn, id) if err != nil { - return results, err + return nil, err } clusters, err := CacheClustersByID(conn, aws.StringValueSlice(rg.MemberClusters)) @@ -125,3 +125,57 @@ func CacheClustersByID(conn *elasticache.ElastiCache, idList []string) ([]*elast return results, err } + +// GlobalReplicationGroupByID() retrieves an ElastiCache Global Replication Group by id. +func GlobalReplicationGroupByID(conn *elasticache.ElastiCache, id string) (*elasticache.GlobalReplicationGroup, error) { + input := &elasticache.DescribeGlobalReplicationGroupsInput{ + GlobalReplicationGroupId: aws.String(id), + ShowMemberInfo: aws.Bool(true), + } + output, err := conn.DescribeGlobalReplicationGroups(input) + if tfawserr.ErrCodeEquals(err, elasticache.ErrCodeGlobalReplicationGroupNotFoundFault) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + if err != nil { + return nil, err + } + + if output == nil || len(output.GlobalReplicationGroups) == 0 || output.GlobalReplicationGroups[0] == nil { + return nil, &resource.NotFoundError{ + Message: "empty result", + LastRequest: input, + } + } + + return output.GlobalReplicationGroups[0], nil +} + +// GlobalReplicationGroupMemberByID retrieves a member Replication Group by id from a Global Replication Group. +func GlobalReplicationGroupMemberByID(conn *elasticache.ElastiCache, globalReplicationGroupID string, id string) (*elasticache.GlobalReplicationGroupMember, error) { + globalReplicationGroup, err := GlobalReplicationGroupByID(conn, globalReplicationGroupID) + if err != nil { + return nil, &resource.NotFoundError{ + Message: "unable to retrieve enclosing Global Replication Group", + LastError: err, + } + } + + if globalReplicationGroup == nil || len(globalReplicationGroup.Members) == 0 { + return nil, &resource.NotFoundError{ + Message: "empty result", + } + } + + for _, member := range globalReplicationGroup.Members { + if aws.StringValue(member.ReplicationGroupId) == id { + return member, nil + } + } + + return nil, &resource.NotFoundError{ + Message: fmt.Sprintf("Replication Group %q not found in Global Replication Group %q", id, globalReplicationGroupID), + } +} diff --git a/aws/internal/service/elasticache/waiter/status.go b/aws/internal/service/elasticache/waiter/status.go index d9bd1e44258..73af9a4d94b 100644 --- a/aws/internal/service/elasticache/waiter/status.go +++ b/aws/internal/service/elasticache/waiter/status.go @@ -17,7 +17,7 @@ const ( ReplicationGroupStatusSnapshotting = "snapshotting" ) -// ReplicationGroupStatus fetches the ReplicationGroup and its Status +// ReplicationGroupStatus fetches the Replication Group and its Status func ReplicationGroupStatus(conn *elasticache.ElastiCache, replicationGroupID string) resource.StateRefreshFunc { return func() (interface{}, string, error) { rg, err := finder.ReplicationGroupByID(conn, replicationGroupID) @@ -32,7 +32,7 @@ func ReplicationGroupStatus(conn *elasticache.ElastiCache, replicationGroupID st } } -// ReplicationGroupMemberClustersStatus fetches the ReplicationGroup's Member Clusters and either "available" or the first non-"available" status. +// ReplicationGroupMemberClustersStatus fetches the Replication Group's Member Clusters and either "available" or the first non-"available" status. // NOTE: This function assumes that the intended end-state is to have all member clusters in "available" status. func ReplicationGroupMemberClustersStatus(conn *elasticache.ElastiCache, replicationGroupID string) resource.StateRefreshFunc { return func() (interface{}, string, error) { @@ -68,7 +68,7 @@ const ( CacheClusterStatusSnapshotting = "snapshotting" ) -// CacheClusterStatus fetches the CacheCluster and its Status +// CacheClusterStatus fetches the Cache Cluster and its Status func CacheClusterStatus(conn *elasticache.ElastiCache, cacheClusterID string) resource.StateRefreshFunc { return func() (interface{}, string, error) { c, err := finder.CacheClusterByID(conn, cacheClusterID) @@ -82,3 +82,27 @@ func CacheClusterStatus(conn *elasticache.ElastiCache, cacheClusterID string) re return c, aws.StringValue(c.CacheClusterStatus), nil } } + +const ( + GlobalReplicationGroupStatusAvailable = "available" + GlobalReplicationGroupStatusCreating = "creating" + GlobalReplicationGroupStatusModifying = "modifying" + GlobalReplicationGroupStatusPrimaryOnly = "primary-only" + GlobalReplicationGroupStatusDeleting = "deleting" + GlobalReplicationGroupStatusDeleted = "deleted" +) + +// GlobalReplicationGroupStatus fetches the Global Replication Group and its Status +func GlobalReplicationGroupStatus(conn *elasticache.ElastiCache, globalReplicationGroupID string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + grg, err := finder.GlobalReplicationGroupByID(conn, globalReplicationGroupID) + if tfresource.NotFound(err) { + return nil, "", nil + } + if err != nil { + return nil, "", err + } + + return grg, aws.StringValue(grg.Status), nil + } +} diff --git a/aws/internal/service/elasticache/waiter/waiter.go b/aws/internal/service/elasticache/waiter/waiter.go index 83c95788e2d..b1ab13ced6e 100644 --- a/aws/internal/service/elasticache/waiter/waiter.go +++ b/aws/internal/service/elasticache/waiter/waiter.go @@ -97,8 +97,8 @@ const ( cacheClusterDeletedDelay = 30 * time.Second ) -// CacheClusterAvailable waits for a ReplicationGroup to return Available -func CacheClusterAvailable(conn *elasticache.ElastiCache, cacheClusterID string, timeout time.Duration) (*elasticache.ReplicationGroup, error) { +// CacheClusterAvailable waits for a Cache Cluster to return Available +func CacheClusterAvailable(conn *elasticache.ElastiCache, cacheClusterID string, timeout time.Duration) (*elasticache.CacheCluster, error) { stateConf := &resource.StateChangeConf{ Pending: []string{ CacheClusterStatusCreating, @@ -114,14 +114,14 @@ func CacheClusterAvailable(conn *elasticache.ElastiCache, cacheClusterID string, } outputRaw, err := stateConf.WaitForState() - if v, ok := outputRaw.(*elasticache.ReplicationGroup); ok { + if v, ok := outputRaw.(*elasticache.CacheCluster); ok { return v, err } return nil, err } -// CacheClusterDeleted waits for a ReplicationGroup to be deleted -func CacheClusterDeleted(conn *elasticache.ElastiCache, cacheClusterID string, timeout time.Duration) (*elasticache.ReplicationGroup, error) { +// CacheClusterDeleted waits for a Cache Cluster to be deleted +func CacheClusterDeleted(conn *elasticache.ElastiCache, cacheClusterID string, timeout time.Duration) (*elasticache.CacheCluster, error) { stateConf := &resource.StateChangeConf{ Pending: []string{ CacheClusterStatusCreating, @@ -140,7 +140,61 @@ func CacheClusterDeleted(conn *elasticache.ElastiCache, cacheClusterID string, t } outputRaw, err := stateConf.WaitForState() - if v, ok := outputRaw.(*elasticache.ReplicationGroup); ok { + if v, ok := outputRaw.(*elasticache.CacheCluster); ok { + return v, err + } + return nil, err +} + +const ( + GlobalReplicationGroupDefaultCreatedTimeout = 20 * time.Minute + GlobalReplicationGroupDefaultUpdatedTimeout = 40 * time.Minute + GlobalReplicationGroupDefaultDeletedTimeout = 20 * time.Minute + + globalReplicationGroupAvailableMinTimeout = 10 * time.Second + globalReplicationGroupAvailableDelay = 30 * time.Second + + globalReplicationGroupDeletedMinTimeout = 10 * time.Second + globalReplicationGroupDeletedDelay = 30 * time.Second +) + +// GlobalReplicationGroupAvailable waits for a Global Replication Group to be available, +// with status either "available" or "primary-only" +func GlobalReplicationGroupAvailable(conn *elasticache.ElastiCache, globalReplicationGroupID string, timeout time.Duration) (*elasticache.GlobalReplicationGroup, error) { + stateConf := &resource.StateChangeConf{ + Pending: []string{GlobalReplicationGroupStatusCreating, GlobalReplicationGroupStatusModifying}, + Target: []string{GlobalReplicationGroupStatusAvailable, GlobalReplicationGroupStatusPrimaryOnly}, + Refresh: GlobalReplicationGroupStatus(conn, globalReplicationGroupID), + Timeout: timeout, + MinTimeout: globalReplicationGroupAvailableMinTimeout, + Delay: globalReplicationGroupAvailableDelay, + } + + outputRaw, err := stateConf.WaitForState() + if v, ok := outputRaw.(*elasticache.GlobalReplicationGroup); ok { + return v, err + } + return nil, err +} + +// GlobalReplicationGroupDeleted waits for a Global Replication Group to be deleted +func GlobalReplicationGroupDeleted(conn *elasticache.ElastiCache, globalReplicationGroupID string) (*elasticache.GlobalReplicationGroup, error) { + stateConf := &resource.StateChangeConf{ + Pending: []string{ + GlobalReplicationGroupStatusAvailable, + GlobalReplicationGroupStatusPrimaryOnly, + GlobalReplicationGroupStatusModifying, + GlobalReplicationGroupStatusDeleting, + }, + Target: []string{}, + Refresh: GlobalReplicationGroupStatus(conn, globalReplicationGroupID), + Timeout: GlobalReplicationGroupDefaultDeletedTimeout, + MinTimeout: globalReplicationGroupDeletedMinTimeout, + Delay: globalReplicationGroupDeletedDelay, + } + + outputRaw, err := stateConf.WaitForState() + if v, ok := outputRaw.(*elasticache.GlobalReplicationGroup); ok { return v, err } return nil, err diff --git a/aws/provider.go b/aws/provider.go index 29273c51f04..d234981ddbd 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -655,6 +655,7 @@ func Provider() *schema.Provider { "aws_eks_fargate_profile": resourceAwsEksFargateProfile(), "aws_eks_node_group": resourceAwsEksNodeGroup(), "aws_elasticache_cluster": resourceAwsElasticacheCluster(), + "aws_elasticache_global_replication_group": resourceAwsElasticacheGlobalReplicationGroup(), "aws_elasticache_parameter_group": resourceAwsElasticacheParameterGroup(), "aws_elasticache_replication_group": resourceAwsElasticacheReplicationGroup(), "aws_elasticache_security_group": resourceAwsElasticacheSecurityGroup(), diff --git a/aws/resource_aws_elasticache_global_replication_group.go b/aws/resource_aws_elasticache_global_replication_group.go new file mode 100644 index 00000000000..1d8e731f857 --- /dev/null +++ b/aws/resource_aws_elasticache_global_replication_group.go @@ -0,0 +1,307 @@ +package aws + +import ( + "fmt" + "log" + "regexp" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/elasticache/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/elasticache/waiter" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" +) + +const ( + elasticacheEmptyDescription = " " +) + +const ( + elasticacheGlobalReplicationGroupRegionPrefixFormat = "[[:alpha:]]{5}-" +) + +const ( + GlobalReplicationGroupMemberRolePrimary = "PRIMARY" + GlobalReplicationGroupMemberRoleSecondary = "SECONDARY" +) + +func resourceAwsElasticacheGlobalReplicationGroup() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsElasticacheGlobalReplicationGroupCreate, + Read: resourceAwsElasticacheGlobalReplicationGroupRead, + Update: resourceAwsElasticacheGlobalReplicationGroupUpdate, + Delete: resourceAwsElasticacheGlobalReplicationGroupDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + re := regexp.MustCompile("^" + elasticacheGlobalReplicationGroupRegionPrefixFormat) + d.Set("global_replication_group_id_suffix", re.ReplaceAllLiteralString(d.Id(), "")) + + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "arn": { + Type: schema.TypeString, + Computed: true, + }, + "at_rest_encryption_enabled": { + Type: schema.TypeBool, + Computed: true, + }, + "auth_token_enabled": { + Type: schema.TypeBool, + Computed: true, + }, + "cache_node_type": { + Type: schema.TypeString, + Computed: true, + }, + "cluster_enabled": { + Type: schema.TypeBool, + Computed: true, + }, + "engine": { + Type: schema.TypeString, + Computed: true, + }, + // Leaving space for `engine_version` for creation and updating. + // `engine_version` cannot be used for returning the version because, starting with Redis 6, + // version configuration is major-version-only: `engine_version = "6.x"`, while `actual_engine_version` + // will be e.g. `6.0.5` + // See also https://github.com/hashicorp/terraform-provider-aws/issues/15625 + "actual_engine_version": { + Type: schema.TypeString, + Computed: true, + }, + "global_replication_group_id": { + Type: schema.TypeString, + Computed: true, + }, + "global_replication_group_id_suffix": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "global_replication_group_description": { + Type: schema.TypeString, + Optional: true, + DiffSuppressFunc: elasticacheDescriptionDiffSuppress, + StateFunc: elasticacheDescriptionStateFunc, + }, + // global_replication_group_members cannot be correctly implemented because any secondary + // replication groups will be added after this resource completes. + // "global_replication_group_members": { + // Type: schema.TypeSet, + // Computed: true, + // Elem: &schema.Resource{ + // Schema: map[string]*schema.Schema{ + // "replication_group_id": { + // Type: schema.TypeString, + // Computed: true, + // }, + // "replication_group_region": { + // Type: schema.TypeString, + // Computed: true, + // }, + // "role": { + // Type: schema.TypeString, + // Computed: true, + // }, + // }, + // }, + // }, + "primary_replication_group_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "transit_encryption_enabled": { + Type: schema.TypeBool, + Computed: true, + }, + }, + } +} + +func elasticacheDescriptionDiffSuppress(_, old, new string, d *schema.ResourceData) bool { + if (old == elasticacheEmptyDescription && new == "") || (old == "" && new == elasticacheEmptyDescription) { + return true + } + return false +} + +func elasticacheDescriptionStateFunc(v interface{}) string { + s := v.(string) + if s == "" { + return elasticacheEmptyDescription + } + return s +} + +func resourceAwsElasticacheGlobalReplicationGroupCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).elasticacheconn + + input := &elasticache.CreateGlobalReplicationGroupInput{ + GlobalReplicationGroupIdSuffix: aws.String(d.Get("global_replication_group_id_suffix").(string)), + PrimaryReplicationGroupId: aws.String(d.Get("primary_replication_group_id").(string)), + } + + if v, ok := d.GetOk("global_replication_group_description"); ok { + input.GlobalReplicationGroupDescription = aws.String(v.(string)) + } + + output, err := conn.CreateGlobalReplicationGroup(input) + if err != nil { + return fmt.Errorf("error creating ElastiCache Global Replication Group: %w", err) + } + + d.SetId(aws.StringValue(output.GlobalReplicationGroup.GlobalReplicationGroupId)) + + if _, err := waiter.GlobalReplicationGroupAvailable(conn, d.Id(), waiter.GlobalReplicationGroupDefaultCreatedTimeout); err != nil { + return fmt.Errorf("error waiting for ElastiCache Global Replication Group (%s) availability: %w", d.Id(), err) + } + + return resourceAwsElasticacheGlobalReplicationGroupRead(d, meta) +} + +func resourceAwsElasticacheGlobalReplicationGroupRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).elasticacheconn + + globalReplicationGroup, err := finder.GlobalReplicationGroupByID(conn, d.Id()) + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] ElastiCache Global Replication Group (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + if err != nil { + return fmt.Errorf("error reading ElastiCache Replication Group: %w", err) + } + + if aws.StringValue(globalReplicationGroup.Status) == "deleting" || aws.StringValue(globalReplicationGroup.Status) == "deleted" { + log.Printf("[WARN] ElastiCache Global Replication Group (%s) in deleted state (%s), removing from state", d.Id(), aws.StringValue(globalReplicationGroup.Status)) + d.SetId("") + return nil + } + + d.Set("arn", globalReplicationGroup.ARN) + d.Set("at_rest_encryption_enabled", globalReplicationGroup.AtRestEncryptionEnabled) + d.Set("auth_token_enabled", globalReplicationGroup.AuthTokenEnabled) + d.Set("cache_node_type", globalReplicationGroup.CacheNodeType) + d.Set("cluster_enabled", globalReplicationGroup.ClusterEnabled) + d.Set("engine", globalReplicationGroup.Engine) + d.Set("actual_engine_version", globalReplicationGroup.EngineVersion) + d.Set("global_replication_group_description", globalReplicationGroup.GlobalReplicationGroupDescription) + d.Set("global_replication_group_id", globalReplicationGroup.GlobalReplicationGroupId) + d.Set("transit_encryption_enabled", globalReplicationGroup.TransitEncryptionEnabled) + + d.Set("primary_replication_group_id", flattenElasticacheGlobalReplicationGroupPrimaryGroupID(globalReplicationGroup.Members)) + + return nil +} + +func resourceAwsElasticacheGlobalReplicationGroupUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).elasticacheconn + + // Only one field can be changed per request + updaters := map[string]elasticacheGlobalReplicationGroupUpdater{} + if !d.IsNewResource() { + updaters["global_replication_group_description"] = func(input *elasticache.ModifyGlobalReplicationGroupInput) { + input.GlobalReplicationGroupDescription = aws.String(d.Get("global_replication_group_description").(string)) + } + } + + for k, f := range updaters { + if d.HasChange(k) { + if err := updateElasticacheGlobalReplicationGroup(conn, d.Id(), f); err != nil { + return fmt.Errorf("error updating ElastiCache Global Replication Group (%s): %w", d.Id(), err) + } + } + } + + return resourceAwsElasticacheGlobalReplicationGroupRead(d, meta) +} + +type elasticacheGlobalReplicationGroupUpdater func(input *elasticache.ModifyGlobalReplicationGroupInput) + +func updateElasticacheGlobalReplicationGroup(conn *elasticache.ElastiCache, id string, f elasticacheGlobalReplicationGroupUpdater) error { + input := &elasticache.ModifyGlobalReplicationGroupInput{ + ApplyImmediately: aws.Bool(true), + GlobalReplicationGroupId: aws.String(id), + } + f(input) + + if _, err := conn.ModifyGlobalReplicationGroup(input); err != nil { + return err + } + + if _, err := waiter.GlobalReplicationGroupAvailable(conn, id, waiter.GlobalReplicationGroupDefaultUpdatedTimeout); err != nil { + return fmt.Errorf("waiting for completion: %w", err) + } + + return nil +} + +func resourceAwsElasticacheGlobalReplicationGroupDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).elasticacheconn + + err := deleteElasticacheGlobalReplicationGroup(conn, d.Id()) + if err != nil { + return fmt.Errorf("error deleting ElastiCache Global Replication Group: %w", err) + } + + return nil +} + +func deleteElasticacheGlobalReplicationGroup(conn *elasticache.ElastiCache, id string) error { + input := &elasticache.DeleteGlobalReplicationGroupInput{ + GlobalReplicationGroupId: aws.String(id), + RetainPrimaryReplicationGroup: aws.Bool(true), + } + + // Using Update timeout because the Global Replication Group could be in the middle of an update operation + err := resource.Retry(waiter.GlobalReplicationGroupDefaultUpdatedTimeout, func() *resource.RetryError { + _, err := conn.DeleteGlobalReplicationGroup(input) + if tfawserr.ErrCodeEquals(err, elasticache.ErrCodeGlobalReplicationGroupNotFoundFault) { + return resource.NonRetryableError(&resource.NotFoundError{ + LastError: err, + LastRequest: input, + }) + } + if tfawserr.ErrMessageContains(err, elasticache.ErrCodeInvalidGlobalReplicationGroupStateFault, "is not empty") { + return resource.RetryableError(err) + } + if err != nil { + return resource.NonRetryableError(err) + } + + return nil + }) + if tfresource.TimedOut(err) { + _, err = conn.DeleteGlobalReplicationGroup(input) + } + if tfresource.NotFound(err) { + return nil + } + if err != nil { + return err + } + + if _, err := waiter.GlobalReplicationGroupDeleted(conn, id); err != nil { + return fmt.Errorf("waiting for completion: %w", err) + } + + return nil +} + +func flattenElasticacheGlobalReplicationGroupPrimaryGroupID(members []*elasticache.GlobalReplicationGroupMember) string { + for _, member := range members { + if aws.StringValue(member.Role) == GlobalReplicationGroupMemberRolePrimary { + return aws.StringValue(member.ReplicationGroupId) + } + } + return "" +} diff --git a/aws/resource_aws_elasticache_global_replication_group_test.go b/aws/resource_aws_elasticache_global_replication_group_test.go new file mode 100644 index 00000000000..8baf3136fb8 --- /dev/null +++ b/aws/resource_aws_elasticache_global_replication_group_test.go @@ -0,0 +1,275 @@ +package aws + +import ( + "fmt" + "log" + "regexp" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/go-multierror" + "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/terraform-providers/terraform-provider-aws/aws/internal/service/elasticache/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/elasticache/waiter" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" +) + +func init() { + resource.AddTestSweepers("aws_elasticache_global_replication_group", &resource.Sweeper{ + Name: "aws_elasticache_global_replication_group", + F: testSweepElasticacheGlobalReplicationGroups, + }) +} + +func testSweepElasticacheGlobalReplicationGroups(region string) error { + client, err := sharedClientForRegion(region) + if err != nil { + return fmt.Errorf("error getting client: %w", err) + } + conn := client.(*AWSClient).elasticacheconn + + var sweeperErrs *multierror.Error + + input := &elasticache.DescribeGlobalReplicationGroupsInput{} + err = conn.DescribeGlobalReplicationGroupsPages(input, func(page *elasticache.DescribeGlobalReplicationGroupsOutput, lastPage bool) bool { + if page == nil { + return !lastPage + } + + for _, globalReplicationGroup := range page.GlobalReplicationGroups { + id := aws.StringValue(globalReplicationGroup.GlobalReplicationGroupId) + + log.Printf("[INFO] Deleting ElastiCache Global Replication Group: %s", id) + err := deleteElasticacheGlobalReplicationGroup(conn, id) + if err != nil { + sweeperErr := fmt.Errorf("error deleting ElastiCache Global Replication Group (%s): %w", id, err) + log.Printf("[ERROR] %s", sweeperErr) + sweeperErrs = multierror.Append(sweeperErrs, sweeperErr) + continue + } + } + + return !lastPage + }) + + if testSweepSkipSweepError(err) { + log.Printf("[WARN] Skipping ElastiCache Global Replication Group sweep for %q: %s", region, err) + return sweeperErrs.ErrorOrNil() // In case we have completed some pages, but had errors + } + + if err != nil { + sweeperErrs = multierror.Append(sweeperErrs, fmt.Errorf("error listing ElastiCache Global Replication Groups: %w", err)) + } + + return sweeperErrs.ErrorOrNil() +} + +func TestAccAWSElasticacheGlobalReplicationGroup_basic(t *testing.T) { + var globalReplicationGroup elasticache.GlobalReplicationGroup + var primaryReplicationGroup elasticache.ReplicationGroup + + rName := acctest.RandomWithPrefix("tf-acc-test") + primaryReplicationGroupId := acctest.RandomWithPrefix("tf-acc-test") + + resourceName := "aws_elasticache_global_replication_group.test" + primaryReplicationGroupResourceName := "aws_elasticache_replication_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t); testAccPreCheckAWSElasticacheGlobalReplicationGroup(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSElasticacheGlobalReplicationGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheGlobalReplicationGroupConfig_basic(rName, primaryReplicationGroupId), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName, &globalReplicationGroup), + testAccCheckAWSElasticacheReplicationGroupExists(primaryReplicationGroupResourceName, &primaryReplicationGroup), + testAccMatchResourceAttrGlobalARN(resourceName, "arn", "elasticache", regexp.MustCompile(`globalreplicationgroup:`+elasticacheGlobalReplicationGroupRegionPrefixFormat+rName)), + resource.TestCheckResourceAttrPair(resourceName, "at_rest_encryption_enabled", primaryReplicationGroupResourceName, "at_rest_encryption_enabled"), + resource.TestCheckResourceAttr(resourceName, "auth_token_enabled", "false"), + resource.TestCheckResourceAttrPair(resourceName, "cache_node_type", primaryReplicationGroupResourceName, "node_type"), + resource.TestCheckResourceAttrPair(resourceName, "cluster_enabled", primaryReplicationGroupResourceName, "cluster_enabled"), + resource.TestCheckResourceAttrPair(resourceName, "engine", primaryReplicationGroupResourceName, "engine"), + resource.TestCheckResourceAttrPair(resourceName, "actual_engine_version", primaryReplicationGroupResourceName, "engine_version"), + resource.TestCheckResourceAttr(resourceName, "global_replication_group_id_suffix", rName), + resource.TestMatchResourceAttr(resourceName, "global_replication_group_id", regexp.MustCompile(elasticacheGlobalReplicationGroupRegionPrefixFormat+rName)), + resource.TestCheckResourceAttr(resourceName, "global_replication_group_description", elasticacheEmptyDescription), + resource.TestCheckResourceAttr(resourceName, "primary_replication_group_id", primaryReplicationGroupId), + resource.TestCheckResourceAttr(resourceName, "transit_encryption_enabled", "false"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAWSElasticacheGlobalReplicationGroup_Description(t *testing.T) { + var globalReplicationGroup elasticache.GlobalReplicationGroup + rName := acctest.RandomWithPrefix("tf-acc-test") + primaryReplicationGroupId := acctest.RandomWithPrefix("tf-acc-test") + description1 := acctest.RandString(10) + description2 := acctest.RandString(10) + resourceName := "aws_elasticache_global_replication_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t); testAccPreCheckAWSElasticacheGlobalReplicationGroup(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSElasticacheGlobalReplicationGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheGlobalReplicationGroupConfig_description(rName, primaryReplicationGroupId, description1), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName, &globalReplicationGroup), + resource.TestCheckResourceAttr(resourceName, "global_replication_group_description", description1), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccAWSElasticacheGlobalReplicationGroupConfig_description(rName, primaryReplicationGroupId, description2), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName, &globalReplicationGroup), + resource.TestCheckResourceAttr(resourceName, "global_replication_group_description", description2), + ), + }, + }, + }) +} + +func TestAccAWSElasticacheGlobalReplicationGroup_disappears(t *testing.T) { + var globalReplicationGroup elasticache.GlobalReplicationGroup + rName := acctest.RandomWithPrefix("tf-acc-test") + primaryReplicationGroupId := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_elasticache_global_replication_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t); testAccPreCheckAWSElasticacheGlobalReplicationGroup(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSElasticacheGlobalReplicationGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheGlobalReplicationGroupConfig_basic(rName, primaryReplicationGroupId), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName, &globalReplicationGroup), + testAccCheckResourceDisappears(testAccProvider, resourceAwsElasticacheGlobalReplicationGroup(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName string, v *elasticache.GlobalReplicationGroup) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ElastiCache Global Replication Group ID is set") + } + + conn := testAccProvider.Meta().(*AWSClient).elasticacheconn + grg, err := finder.GlobalReplicationGroupByID(conn, rs.Primary.ID) + if err != nil { + return fmt.Errorf("error retrieving ElastiCache Global Replication Group (%s): %w", rs.Primary.ID, err) + } + + if aws.StringValue(grg.Status) != waiter.GlobalReplicationGroupStatusAvailable && aws.StringValue(grg.Status) != waiter.GlobalReplicationGroupStatusPrimaryOnly { + return fmt.Errorf("ElastiCache Global Replication Group (%s) exists, but is in a non-available state: %s", rs.Primary.ID, aws.StringValue(grg.Status)) + } + + *v = *grg + + return nil + } +} + +func testAccCheckAWSElasticacheGlobalReplicationGroupDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).elasticacheconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_elasticache_global_replication_group" { + continue + } + + _, err := finder.GlobalReplicationGroupByID(conn, rs.Primary.ID) + if tfresource.NotFound(err) { + continue + } + if err != nil { + return err + } + return fmt.Errorf("ElastiCache Global Replication Group (%s) still exists", rs.Primary.ID) + } + + return nil +} + +func testAccPreCheckAWSElasticacheGlobalReplicationGroup(t *testing.T) { + conn := testAccProvider.Meta().(*AWSClient).elasticacheconn + + input := &elasticache.DescribeGlobalReplicationGroupsInput{} + _, err := conn.DescribeGlobalReplicationGroups(input) + + if testAccPreCheckSkipError(err) || + tfawserr.ErrMessageContains(err, elasticache.ErrCodeInvalidParameterValueException, "Access Denied to API Version: APIGlobalDatastore") { + t.Skipf("skipping acceptance testing: %s", err) + } + + if err != nil { + t.Fatalf("unexpected PreCheck error: %s", err) + } +} + +func testAccAWSElasticacheGlobalReplicationGroupConfig_basic(rName, primaryReplicationGroupId string) string { + return fmt.Sprintf(` +resource "aws_elasticache_global_replication_group" "test" { + global_replication_group_id_suffix = %[1]q + primary_replication_group_id = aws_elasticache_replication_group.test.id +} + +resource "aws_elasticache_replication_group" "test" { + replication_group_id = %[2]q + replication_group_description = "test" + + engine = "redis" + engine_version = "5.0.6" + node_type = "cache.m5.large" + number_cache_clusters = 1 +} +`, rName, primaryReplicationGroupId) +} + +func testAccAWSElasticacheGlobalReplicationGroupConfig_description(rName, primaryReplicationGroupId, description string) string { + return fmt.Sprintf(` +resource "aws_elasticache_global_replication_group" "test" { + global_replication_group_id_suffix = %[1]q + primary_replication_group_id = aws_elasticache_replication_group.test.id + + global_replication_group_description = %[3]q +} + +resource "aws_elasticache_replication_group" "test" { + replication_group_id = %[2]q + replication_group_description = "test" + + engine = "redis" + engine_version = "5.0.6" + node_type = "cache.m5.large" + number_cache_clusters = 1 +} +`, rName, primaryReplicationGroupId, description) +} diff --git a/aws/resource_aws_elasticache_replication_group_test.go b/aws/resource_aws_elasticache_replication_group_test.go index 5eabccd4490..3817ce359f1 100644 --- a/aws/resource_aws_elasticache_replication_group_test.go +++ b/aws/resource_aws_elasticache_replication_group_test.go @@ -23,6 +23,9 @@ func init() { resource.AddTestSweepers("aws_elasticache_replication_group", &resource.Sweeper{ Name: "aws_elasticache_replication_group", F: testSweepElasticacheReplicationGroups, + Dependencies: []string{ + "aws_elasticache_global_replication_group", + }, }) } diff --git a/website/docs/r/elasticache_cluster.html.markdown b/website/docs/r/elasticache_cluster.html.markdown index 2c721d49c90..8b0dcaaee2d 100644 --- a/website/docs/r/elasticache_cluster.html.markdown +++ b/website/docs/r/elasticache_cluster.html.markdown @@ -87,9 +87,7 @@ in the AWS Documentation center for supported versions on the cache cluster is performed. The format is `ddd:hh24:mi-ddd:hh24:mi` (24H Clock UTC). The minimum maintenance window is a 60 minute period. Example: `sun:05:00-sun:09:00` -* `node_type` – (Required unless `replication_group_id` is provided) The compute and memory capacity of the nodes. See -[Available Cache Node Types](https://aws.amazon.com/elasticache/pricing/#Available_node_types) for -supported node types. 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 diff --git a/website/docs/r/elasticache_global_replication_group.html.markdown b/website/docs/r/elasticache_global_replication_group.html.markdown new file mode 100644 index 00000000000..f578d36f94f --- /dev/null +++ b/website/docs/r/elasticache_global_replication_group.html.markdown @@ -0,0 +1,65 @@ +--- +subcategory: "ElastiCache" +layout: "aws" +page_title: "AWS: aws_elasticache_global_replication_group" +description: |- + Provides an ElastiCache Global Replication Group resource. +--- + +# Resource: aws_elasticache_global_replication_group + +Provides an ElastiCache Global Replication Group resource, which manage a replication between 2 or more redis replication group in different regions. + +## Example Usage + +### Global replication group with a single instance redis replication group + +To create a single shard primary with single read replica: + +```hcl +resource "aws_elasticache_global_replication_group" "replication_group" { + global_replication_group_id_suffix = "example" + primary_replication_group_id = aws_elasticache_replication_group.primary.id +} + +resource "aws_elasticache_replication_group" "primary" { + replication_group_id = "example" + replication_group_description = "test example" + + engine = "redis" + engine_version = "5.0.6" + node_type = "cache.m5.large" + number_cache_clusters = 1 +} +``` + +## Argument Reference + +The following arguments are supported: + +* `global_replication_group_id_suffix` – (Required) The suffix name of a Global Datastore. If `global_replication_group_id_suffix` is changed, creates a new resource. +* `primary_replication_group_id` – (Required) The ID of the primary cluster that accepts writes and will replicate updates to the secondary cluster. If `primary_replication_group_id` is changed, creates a new resource. +* `global_replication_group_description` – (Optional) A user-created description for the global replication group. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The ID of the ElastiCache Global Replication Group. +* `arn` - The ARN of the ElastiCache Global Replication Group. +* `actual_engine_version` - The full version number of the cache engine running on the members of this global replication group. +* `at_rest_encryption_enabled` - A flag that indicate whether the encryption at rest is enabled. +* `auth_token_enabled` - A flag that indicate whether AuthToken (password) is enabled. +* `cache_node_type` - The instance class 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). +* `cluster_enabled` - Indicates whether the Global Datastore is cluster enabled. +* `engine` - The name of the cache engine to be used for the clusters in this global replication group. +* `global_replication_group_id` - The full ID of the global replication group. +* `transit_encryption_enabled` - A flag that indicates whether the encryption in transit is enabled. + +## Import + +ElastiCache Global Replication Groups can be imported using the `global_replication_group_id`, e.g. + +``` +$ terraform import aws_elasticache_global_replication_group.my_global_replication_group okuqm-global-replication-group-1 +``` diff --git a/website/docs/r/elasticache_replication_group.html.markdown b/website/docs/r/elasticache_replication_group.html.markdown index f95268c0b32..996fd6541aa 100644 --- a/website/docs/r/elasticache_replication_group.html.markdown +++ b/website/docs/r/elasticache_replication_group.html.markdown @@ -110,7 +110,7 @@ The following arguments are supported: * `replication_group_id` – (Required) The replication group identifier. This parameter is stored as a lowercase string. * `replication_group_description` – (Required) A user-created description for the replication group. * `number_cache_clusters` - (Optional) The number of cache clusters (primary and replicas) this replication group will have. If Multi-AZ is enabled, the value of this parameter must be at least 2. Updates will occur before other modifications. One of `number_cache_clusters` or `cluster_mode` is required. -* `node_type` - (Required) The compute and memory capacity of the nodes in the node group. +* `node_type` - (Required) The 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). * `automatic_failover_enabled` - (Optional) Specifies whether a read-only replica will be automatically promoted to read/write primary if the existing primary fails. If true, Multi-AZ is enabled for this replication group. If false, Multi-AZ is disabled for this replication group. Must be enabled for Redis (cluster mode enabled) replication groups. Defaults to `false`. * `multi_az_enabled` - (Optional) Specifies whether to enable Multi-AZ Support for the replication group. If `true`, `automatic_failover_enabled` must also be enabled. Defaults to `false`. * `auto_minor_version_upgrade` - (Optional) Specifies whether a minor engine upgrades will be applied automatically to the underlying Cache Cluster instances during the maintenance window. This parameter is currently not supported by the AWS API. Defaults to `true`.