diff --git a/.changelog/17725.txt b/.changelog/17725.txt new file mode 100644 index 00000000000..3256fcaa2bd --- /dev/null +++ b/.changelog/17725.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/aws_elasticache_replication_group: Allows creating a Replication Group as part of a Global Replication Group +``` diff --git a/.semgrep.yml b/.semgrep.yml index e77b7ce3b43..c16413504bc 100644 --- a/.semgrep.yml +++ b/.semgrep.yml @@ -212,6 +212,12 @@ rules: var $CAST *resource.NotFoundError ... errors.As($ERR, &$CAST) + - pattern-not-inside: | + var $CAST *resource.NotFoundError + ... + errors.As($ERR, &$CAST) + ... + $CAST.$FIELD - patterns: - pattern: | $X, $Y := $ERR.(*resource.NotFoundError) diff --git a/GNUmakefile b/GNUmakefile index 6dc5be1472e..61a0304aa75 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -3,7 +3,7 @@ TEST?=./... SWEEP_DIR?=./aws PKG_NAME=aws TEST_COUNT?=1 -ACCTEST_TIMEOUT?=120m +ACCTEST_TIMEOUT?=180m ACCTEST_PARALLELISM?=20 default: build diff --git a/aws/data_source_aws_elasticache_replication_group.go b/aws/data_source_aws_elasticache_replication_group.go index f748325ca57..a8a2d4a8fe0 100644 --- a/aws/data_source_aws_elasticache_replication_group.go +++ b/aws/data_source_aws_elasticache_replication_group.go @@ -16,8 +16,9 @@ func dataSourceAwsElasticacheReplicationGroup() *schema.Resource { Read: dataSourceAwsElasticacheReplicationGroupRead, Schema: map[string]*schema.Schema{ "replication_group_id": { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + Required: true, + ValidateFunc: validateReplicationGroupID, }, "replication_group_description": { Type: schema.TypeString, diff --git a/aws/internal/service/elasticache/finder/finder.go b/aws/internal/service/elasticache/finder/finder.go index c4d37907090..a0171bc90ea 100644 --- a/aws/internal/service/elasticache/finder/finder.go +++ b/aws/internal/service/elasticache/finder/finder.go @@ -27,7 +27,7 @@ func ReplicationGroupByID(conn *elasticache.ElastiCache, id string) (*elasticach if output == nil || len(output.ReplicationGroups) == 0 || output.ReplicationGroups[0] == nil { return nil, &resource.NotFoundError{ - Message: "Empty result", + Message: "empty result", LastRequest: input, } } @@ -48,7 +48,7 @@ func ReplicationGroupMemberClustersByID(conn *elasticache.ElastiCache, id string } if len(clusters) == 0 { return clusters, &resource.NotFoundError{ - Message: "No Member Clusters found", + Message: fmt.Sprintf("No Member Clusters found in Replication Group (%s)", id), } } @@ -87,7 +87,7 @@ func CacheCluster(conn *elasticache.ElastiCache, input *elasticache.DescribeCach if result == nil || len(result.CacheClusters) == 0 || result.CacheClusters[0] == nil { return nil, &resource.NotFoundError{ - Message: "Empty result", + Message: "empty result", LastRequest: input, } } @@ -176,6 +176,6 @@ func GlobalReplicationGroupMemberByID(conn *elasticache.ElastiCache, globalRepli } return nil, &resource.NotFoundError{ - Message: fmt.Sprintf("Replication Group %q not found in Global Replication Group %q", id, globalReplicationGroupID), + Message: fmt.Sprintf("Replication Group (%s) not found in Global Replication Group (%s)", id, globalReplicationGroupID), } } diff --git a/aws/internal/service/elasticache/waiter/status.go b/aws/internal/service/elasticache/waiter/status.go index 73af9a4d94b..9fb4fbd58dd 100644 --- a/aws/internal/service/elasticache/waiter/status.go +++ b/aws/internal/service/elasticache/waiter/status.go @@ -106,3 +106,22 @@ func GlobalReplicationGroupStatus(conn *elasticache.ElastiCache, globalReplicati return grg, aws.StringValue(grg.Status), nil } } + +const ( + GlobalReplicationGroupMemberStatusAssociated = "associated" +) + +// GlobalReplicationGroupStatus fetches the Global Replication Group and its Status +func GlobalReplicationGroupMemberStatus(conn *elasticache.ElastiCache, globalReplicationGroupID, id string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + member, err := finder.GlobalReplicationGroupMemberByID(conn, globalReplicationGroupID, id) + if tfresource.NotFound(err) { + return nil, "", nil + } + if err != nil { + return nil, "", err + } + + return member, aws.StringValue(member.Status), nil + } +} diff --git a/aws/internal/service/elasticache/waiter/waiter.go b/aws/internal/service/elasticache/waiter/waiter.go index b1ab13ced6e..4012e5c77d8 100644 --- a/aws/internal/service/elasticache/waiter/waiter.go +++ b/aws/internal/service/elasticache/waiter/waiter.go @@ -199,3 +199,31 @@ func GlobalReplicationGroupDeleted(conn *elasticache.ElastiCache, globalReplicat } return nil, err } + +const ( + GlobalReplicationGroupDisassociationRetryTimeout = 45 * time.Minute + + globalReplicationGroupDisassociationTimeout = 20 * time.Minute + + globalReplicationGroupDisassociationMinTimeout = 10 * time.Second + globalReplicationGroupDisassociationDelay = 30 * time.Second +) + +func GlobalReplicationGroupMemberDetached(conn *elasticache.ElastiCache, globalReplicationGroupID, id string) (*elasticache.GlobalReplicationGroupMember, error) { + stateConf := &resource.StateChangeConf{ + Pending: []string{ + GlobalReplicationGroupMemberStatusAssociated, + }, + Target: []string{}, + Refresh: GlobalReplicationGroupMemberStatus(conn, globalReplicationGroupID, id), + Timeout: globalReplicationGroupDisassociationTimeout, + MinTimeout: globalReplicationGroupDisassociationMinTimeout, + Delay: globalReplicationGroupDisassociationDelay, + } + + outputRaw, err := stateConf.WaitForState() + if v, ok := outputRaw.(*elasticache.GlobalReplicationGroupMember); ok { + return v, err + } + return nil, err +} diff --git a/aws/resource_aws_elasticache_cluster.go b/aws/resource_aws_elasticache_cluster.go index c9347a0d038..2c21551107e 100644 --- a/aws/resource_aws_elasticache_cluster.go +++ b/aws/resource_aws_elasticache_cluster.go @@ -174,9 +174,10 @@ func resourceAwsElasticacheCluster() *schema.Resource { Elem: &schema.Schema{Type: schema.TypeString}, }, "replication_group_id": { - Type: schema.TypeString, - Optional: true, - ForceNew: true, + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validateReplicationGroupID, ConflictsWith: []string{ "az_mode", "engine_version", diff --git a/aws/resource_aws_elasticache_global_replication_group.go b/aws/resource_aws_elasticache_global_replication_group.go index 1d8e731f857..d7816b3d2a5 100644 --- a/aws/resource_aws_elasticache_global_replication_group.go +++ b/aws/resource_aws_elasticache_global_replication_group.go @@ -115,9 +115,10 @@ func resourceAwsElasticacheGlobalReplicationGroup() *schema.Resource { // }, // }, "primary_replication_group_id": { - Type: schema.TypeString, - Required: true, - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateReplicationGroupID, }, "transit_encryption_enabled": { Type: schema.TypeBool, diff --git a/aws/resource_aws_elasticache_global_replication_group_test.go b/aws/resource_aws_elasticache_global_replication_group_test.go index 8baf3136fb8..304343cd981 100644 --- a/aws/resource_aws_elasticache_global_replication_group_test.go +++ b/aws/resource_aws_elasticache_global_replication_group_test.go @@ -12,6 +12,7 @@ import ( "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/helper/schema" "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" @@ -170,6 +171,60 @@ func TestAccAWSElasticacheGlobalReplicationGroup_disappears(t *testing.T) { }) } +func TestAccAWSElasticacheGlobalReplicationGroup_MultipleSecondaries(t *testing.T) { + var providers []*schema.Provider + var globalReplcationGroup elasticache.GlobalReplicationGroup + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_elasticache_global_replication_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccMultipleRegionPreCheck(t, 3) + }, + ProviderFactories: testAccProviderFactoriesMultipleRegion(&providers, 3), + CheckDestroy: testAccCheckAWSElasticacheReplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheGlobalReplicationGroupConfig_MultipleSecondaries(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName, &globalReplcationGroup), + ), + }, + }, + }) +} + +func TestAccAWSElasticacheGlobalReplicationGroup_ReplaceSecondary_DifferentRegion(t *testing.T) { + var providers []*schema.Provider + var globalReplcationGroup elasticache.GlobalReplicationGroup + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_elasticache_global_replication_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccMultipleRegionPreCheck(t, 3) + }, + ProviderFactories: testAccProviderFactoriesMultipleRegion(&providers, 3), + CheckDestroy: testAccCheckAWSElasticacheReplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheReplicationGroupConfig_ReplaceSecondary_DifferentRegion_Setup(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName, &globalReplcationGroup), + ), + }, + { + Config: testAccAWSElasticacheReplicationGroupConfig_ReplaceSecondary_DifferentRegion_Move(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName, &globalReplcationGroup), + ), + }, + }, + }) +} + func testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName string, v *elasticache.GlobalReplicationGroup) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[resourceName] @@ -187,7 +242,7 @@ func testAccCheckAWSElasticacheGlobalReplicationGroupExists(resourceName string, 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 { + if aws.StringValue(grg.Status) == waiter.GlobalReplicationGroupStatusDeleting || aws.StringValue(grg.Status) == waiter.GlobalReplicationGroupStatusDeleted { return fmt.Errorf("ElastiCache Global Replication Group (%s) exists, but is in a non-available state: %s", rs.Primary.ID, aws.StringValue(grg.Status)) } @@ -273,3 +328,195 @@ resource "aws_elasticache_replication_group" "test" { } `, rName, primaryReplicationGroupId, description) } + +func testAccAWSElasticacheGlobalReplicationGroupConfig_MultipleSecondaries(rName string) string { + return composeConfig( + testAccMultipleRegionProviderConfig(3), + testAccElasticacheVpcBaseWithProvider(rName, "primary", ProviderNameAws), + testAccElasticacheVpcBaseWithProvider(rName, "alternate", ProviderNameAwsAlternate), + testAccElasticacheVpcBaseWithProvider(rName, "third", ProviderNameAwsThird), + fmt.Sprintf(` +resource "aws_elasticache_global_replication_group" "test" { + provider = aws + + global_replication_group_id_suffix = %[1]q + primary_replication_group_id = aws_elasticache_replication_group.primary.id +} + +resource "aws_elasticache_replication_group" "primary" { + provider = aws + + replication_group_id = "%[1]s-p" + replication_group_description = "primary" + + subnet_group_name = aws_elasticache_subnet_group.primary.name + + node_type = "cache.m5.large" + + engine = "redis" + engine_version = "5.0.6" + number_cache_clusters = 1 +} + +resource "aws_elasticache_replication_group" "alternate" { + provider = awsalternate + + replication_group_id = "%[1]s-a" + replication_group_description = "alternate" + global_replication_group_id = aws_elasticache_global_replication_group.test.global_replication_group_id + + subnet_group_name = aws_elasticache_subnet_group.alternate.name + + number_cache_clusters = 1 +} + +resource "aws_elasticache_replication_group" "third" { + provider = awsthird + + replication_group_id = "%[1]s-t" + replication_group_description = "third" + global_replication_group_id = aws_elasticache_global_replication_group.test.global_replication_group_id + + subnet_group_name = aws_elasticache_subnet_group.third.name + + number_cache_clusters = 1 +} +`, rName)) +} + +func testAccAWSElasticacheReplicationGroupConfig_ReplaceSecondary_DifferentRegion_Setup(rName string) string { + return composeConfig( + testAccMultipleRegionProviderConfig(3), + testAccElasticacheVpcBaseWithProvider(rName, "primary", ProviderNameAws), + testAccElasticacheVpcBaseWithProvider(rName, "secondary", ProviderNameAwsAlternate), + testAccElasticacheVpcBaseWithProvider(rName, "third", ProviderNameAwsThird), + fmt.Sprintf(` +resource "aws_elasticache_global_replication_group" "test" { + provider = aws + + global_replication_group_id_suffix = %[1]q + primary_replication_group_id = aws_elasticache_replication_group.primary.id +} + +resource "aws_elasticache_replication_group" "primary" { + provider = aws + + replication_group_id = "%[1]s-p" + replication_group_description = "primary" + + subnet_group_name = aws_elasticache_subnet_group.primary.name + + node_type = "cache.m5.large" + + engine = "redis" + engine_version = "5.0.6" + number_cache_clusters = 1 +} + +resource "aws_elasticache_replication_group" "secondary" { + provider = awsalternate + + replication_group_id = "%[1]s-a" + replication_group_description = "alternate" + global_replication_group_id = aws_elasticache_global_replication_group.test.global_replication_group_id + + subnet_group_name = aws_elasticache_subnet_group.secondary.name + + number_cache_clusters = 1 +} +`, rName)) +} + +func testAccAWSElasticacheReplicationGroupConfig_ReplaceSecondary_DifferentRegion_Move(rName string) string { + return composeConfig( + testAccMultipleRegionProviderConfig(3), + testAccElasticacheVpcBaseWithProvider(rName, "primary", ProviderNameAws), + testAccElasticacheVpcBaseWithProvider(rName, "secondary", ProviderNameAwsAlternate), + testAccElasticacheVpcBaseWithProvider(rName, "third", ProviderNameAwsThird), + fmt.Sprintf(` +resource "aws_elasticache_global_replication_group" "test" { + provider = aws + + global_replication_group_id_suffix = %[1]q + primary_replication_group_id = aws_elasticache_replication_group.primary.id +} + +resource "aws_elasticache_replication_group" "primary" { + provider = aws + + replication_group_id = "%[1]s-p" + replication_group_description = "primary" + + subnet_group_name = aws_elasticache_subnet_group.primary.name + + node_type = "cache.m5.large" + + engine = "redis" + engine_version = "5.0.6" + number_cache_clusters = 1 +} + +resource "aws_elasticache_replication_group" "third" { + provider = awsthird + + replication_group_id = "%[1]s-t" + replication_group_description = "third" + global_replication_group_id = aws_elasticache_global_replication_group.test.global_replication_group_id + + subnet_group_name = aws_elasticache_subnet_group.third.name + + number_cache_clusters = 1 +} +`, rName)) +} + +func testAccElasticacheVpcBaseWithProvider(rName, name, provider string) string { + return composeConfig( + testAccAvailableAZsNoOptInConfigWithProvider(name, provider), + fmt.Sprintf(` +resource "aws_vpc" "%[1]s" { + provider = %[2]s + + cidr_block = "192.168.0.0/16" +} + +resource "aws_subnet" "%[1]s" { + provider = %[2]s + + vpc_id = aws_vpc.%[1]s.id + cidr_block = "192.168.0.0/20" + availability_zone = data.aws_availability_zones.%[1]s.names[0] + + tags = { + Name = "tf-acc-elasticache-replication-group-at-rest-encryption" + } +} + +resource "aws_elasticache_subnet_group" "%[1]s" { + provider = %[2]s + + name = %[3]q + description = "tf-test-cache-subnet-group-descr" + + subnet_ids = [ + aws_subnet.%[1]s.id, + ] +} +`, name, provider, rName), + ) +} + +func testAccAvailableAZsNoOptInConfigWithProvider(name, provider string) string { + return fmt.Sprintf(` +data "aws_availability_zones" "%[1]s" { + provider = %[2]s + + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} +`, name, provider) +} diff --git a/aws/resource_aws_elasticache_replication_group.go b/aws/resource_aws_elasticache_replication_group.go index e6474ac569b..79d3d7b23ce 100644 --- a/aws/resource_aws_elasticache_replication_group.go +++ b/aws/resource_aws_elasticache_replication_group.go @@ -12,6 +12,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/aws-sdk-go/service/elasticache" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -113,6 +114,25 @@ func resourceAwsElasticacheReplicationGroup() *schema.Resource { Optional: true, Computed: true, }, + "global_replication_group_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Computed: true, + ConflictsWith: []string{ + "automatic_failover_enabled", + "cluster_mode", // should/will be "num_node_groups" + "parameter_group_name", + "engine", + "engine_version", + "node_type", + "security_group_names", + "transit_encryption_enabled", + "at_rest_encryption_enabled", + "snapshot_arns", + "snapshot_name", + }, + }, "maintenance_window": { Type: schema.TypeString, Optional: true, @@ -180,16 +200,10 @@ func resourceAwsElasticacheReplicationGroup() *schema.Resource { Required: true, }, "replication_group_id": { - Type: schema.TypeString, - Required: true, - ForceNew: true, - ValidateFunc: validation.All( - validation.StringLenBetween(1, 40), - validation.StringMatch(regexp.MustCompile(`^[0-9a-zA-Z-]+$`), "must contain only alphanumeric characters and hyphens"), - validation.StringMatch(regexp.MustCompile(`^[a-zA-Z]`), "must begin with a letter"), - validation.StringDoesNotMatch(regexp.MustCompile(`--`), "cannot contain two consecutive hyphens"), - validation.StringDoesNotMatch(regexp.MustCompile(`-$`), "cannot end with a hyphen"), - ), + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validateReplicationGroupID, StateFunc: func(val interface{}) string { return strings.ToLower(val.(string)) }, @@ -324,13 +338,23 @@ func resourceAwsElasticacheReplicationGroupCreate(d *schema.ResourceData, meta i params := &elasticache.CreateReplicationGroupInput{ ReplicationGroupId: aws.String(d.Get("replication_group_id").(string)), ReplicationGroupDescription: aws.String(d.Get("replication_group_description").(string)), - AutomaticFailoverEnabled: aws.Bool(d.Get("automatic_failover_enabled").(bool)), AutoMinorVersionUpgrade: aws.Bool(d.Get("auto_minor_version_upgrade").(bool)), - CacheNodeType: aws.String(d.Get("node_type").(string)), - Engine: aws.String(d.Get("engine").(string)), Tags: tags, } + if v, ok := d.GetOk("global_replication_group_id"); ok { + params.GlobalReplicationGroupId = aws.String(v.(string)) + } else { + // This cannot be handled at plan-time + nodeType := d.Get("node_type").(string) + if nodeType == "" { + return errors.New(`"node_type" is required unless "global_replication_group_id" is set.`) + } + params.AutomaticFailoverEnabled = aws.Bool(d.Get("automatic_failover_enabled").(bool)) + params.CacheNodeType = aws.String(nodeType) + params.Engine = aws.String(d.Get("engine").(string)) + } + if v, ok := d.GetOk("engine_version"); ok { params.EngineVersion = aws.String(v.(string)) } @@ -419,17 +443,16 @@ func resourceAwsElasticacheReplicationGroupCreate(d *schema.ResourceData, meta i if cacheClusters, ok := d.GetOk("number_cache_clusters"); ok { params.NumCacheClusters = aws.Int64(int64(cacheClusters.(int))) } - resp, err := conn.CreateReplicationGroup(params) if err != nil { - return fmt.Errorf("Error creating ElastiCache Replication Group: %w", err) + return fmt.Errorf("Error creating ElastiCache Replication Group (%s): %w", d.Get("replication_group_id").(string), err) } d.SetId(aws.StringValue(resp.ReplicationGroup.ReplicationGroupId)) _, err = waiter.ReplicationGroupAvailable(conn, d.Id(), d.Timeout(schema.TimeoutCreate)) if err != nil { - return fmt.Errorf("error waiting for ElastiCache Replication Group (%s) to be created: %w", d.Id(), err) + return fmt.Errorf("error creating ElastiCache Replication Group (%s): waiting for completion: %w", d.Id(), err) } return resourceAwsElasticacheReplicationGroupRead(d, meta) @@ -449,12 +472,16 @@ func resourceAwsElasticacheReplicationGroupRead(d *schema.ResourceData, meta int return err } - if aws.StringValue(rgp.Status) == "deleting" { + if aws.StringValue(rgp.Status) == waiter.ReplicationGroupStatusDeleting { log.Printf("[WARN] ElastiCache Replication Group (%s) is currently in the `deleting` status, removing from state", d.Id()) d.SetId("") return nil } + if rgp.GlobalReplicationGroupInfo != nil && rgp.GlobalReplicationGroupInfo.GlobalReplicationGroupId != nil { + d.Set("global_replication_group_id", rgp.GlobalReplicationGroupInfo.GlobalReplicationGroupId) + } + if rgp.AutomaticFailover != nil { switch strings.ToLower(aws.StringValue(rgp.AutomaticFailover)) { case elasticache.AutomaticFailoverStatusDisabled, elasticache.AutomaticFailoverStatusDisabling: @@ -693,6 +720,13 @@ func resourceAwsElasticacheReplicationGroupUpdate(d *schema.ResourceData, meta i func resourceAwsElasticacheReplicationGroupDelete(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).elasticacheconn + if globalReplicationGroupID, ok := d.GetOk("global_replication_group_id"); ok { + err := disassociateElasticacheReplicationGroup(conn, globalReplicationGroupID.(string), d.Id(), meta.(*AWSClient).region) + if err != nil { + return fmt.Errorf("error disassociating ElastiCache Replication Group (%s) from Global Replication Group (%s): %w", d.Id(), globalReplicationGroupID, err) + } + } + var finalSnapshotID = d.Get("final_snapshot_identifier").(string) err := deleteElasticacheReplicationGroup(d.Id(), conn, finalSnapshotID, d.Timeout(schema.TimeoutDelete)) if err != nil { @@ -702,6 +736,49 @@ func resourceAwsElasticacheReplicationGroupDelete(d *schema.ResourceData, meta i return nil } +func disassociateElasticacheReplicationGroup(conn *elasticache.ElastiCache, globalReplicationGroupID, id, region string) error { + input := &elasticache.DisassociateGlobalReplicationGroupInput{ + GlobalReplicationGroupId: aws.String(globalReplicationGroupID), + ReplicationGroupId: aws.String(id), + ReplicationGroupRegion: aws.String(region), + } + err := resource.Retry(waiter.GlobalReplicationGroupDisassociationRetryTimeout, func() *resource.RetryError { + _, err := conn.DisassociateGlobalReplicationGroup(input) + if tfawserr.ErrCodeEquals(err, elasticache.ErrCodeGlobalReplicationGroupNotFoundFault) { + return nil + } + if tfawserr.ErrCodeEquals(err, elasticache.ErrCodeInvalidGlobalReplicationGroupStateFault) { + return resource.RetryableError(err) + } + if err != nil { + return resource.NonRetryableError(err) + } + + return nil + }) + if isResourceTimeoutError(err) { + _, err = conn.DisassociateGlobalReplicationGroup(input) + } + if tfawserr.ErrMessageContains(err, elasticache.ErrCodeInvalidParameterValueException, "is not associated with Global Replication Group") { + return nil + } + if tfawserr.ErrCodeEquals(err, elasticache.ErrCodeInvalidGlobalReplicationGroupStateFault) { + return fmt.Errorf("tried for %s: %w", waiter.GlobalReplicationGroupDisassociationRetryTimeout.String(), err) + } + + if err != nil { + return err + } + + _, err = waiter.GlobalReplicationGroupMemberDetached(conn, globalReplicationGroupID, id) + if err != nil { + return fmt.Errorf("waiting for completion: %w", err) + } + + return nil + +} + func deleteElasticacheReplicationGroup(replicationGroupID string, conn *elasticache.ElastiCache, finalSnapshotID string, timeout time.Duration) error { input := &elasticache.DeleteReplicationGroupInput{ ReplicationGroupId: aws.String(replicationGroupID), @@ -913,3 +990,11 @@ func resourceAwsElasticacheReplicationGroupModify(conn *elasticache.ElastiCache, } return nil } + +var validateReplicationGroupID schema.SchemaValidateFunc = validation.All( + validation.StringLenBetween(1, 40), + validation.StringMatch(regexp.MustCompile(`^[0-9a-zA-Z-]+$`), "must contain only alphanumeric characters and hyphens"), + validation.StringMatch(regexp.MustCompile(`^[a-zA-Z]`), "must begin with a letter"), + validation.StringDoesNotMatch(regexp.MustCompile(`--`), "cannot contain two consecutive hyphens"), + validation.StringDoesNotMatch(regexp.MustCompile(`-$`), "cannot end with a hyphen"), +) diff --git a/aws/resource_aws_elasticache_replication_group_test.go b/aws/resource_aws_elasticache_replication_group_test.go index 3817ce359f1..b855ae7dd62 100644 --- a/aws/resource_aws_elasticache_replication_group_test.go +++ b/aws/resource_aws_elasticache_replication_group_test.go @@ -13,6 +13,7 @@ import ( "github.com/aws/aws-sdk-go/service/elasticache" "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/helper/schema" "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" @@ -129,6 +130,28 @@ func TestAccAWSElasticacheReplicationGroup_Uppercase(t *testing.T) { }) } +func TestAccAWSElasticacheReplicationGroup_disappears(t *testing.T) { + var rg elasticache.ReplicationGroup + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_elasticache_replication_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSElasticacheReplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheReplicationGroupConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheReplicationGroupExists(resourceName, &rg), + testAccCheckResourceDisappears(testAccProvider, resourceAwsElasticacheReplicationGroup(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + func TestAccAWSElasticacheReplicationGroup_updateDescription(t *testing.T) { var rg elasticache.ReplicationGroup rName := acctest.RandomWithPrefix("tf-acc-test") @@ -785,8 +808,7 @@ func TestAccAWSElasticacheReplicationGroup_enableSnapshotting(t *testing.T) { Config: testAccAWSElasticacheReplicationGroupConfig(rName), Check: resource.ComposeTestCheckFunc( testAccCheckAWSElasticacheReplicationGroupExists(resourceName, &rg), - resource.TestCheckResourceAttr( - resourceName, "snapshot_retention_limit", "0"), + resource.TestCheckResourceAttr(resourceName, "snapshot_retention_limit", "0"), ), }, { @@ -799,8 +821,7 @@ func TestAccAWSElasticacheReplicationGroup_enableSnapshotting(t *testing.T) { Config: testAccAWSElasticacheReplicationGroupConfigEnableSnapshotting(rName), Check: resource.ComposeTestCheckFunc( testAccCheckAWSElasticacheReplicationGroupExists(resourceName, &rg), - resource.TestCheckResourceAttr( - resourceName, "snapshot_retention_limit", "2"), + resource.TestCheckResourceAttr(resourceName, "snapshot_retention_limit", "2"), ), }, }, @@ -847,8 +868,7 @@ func TestAccAWSElasticacheReplicationGroup_enableAtRestEncryption(t *testing.T) Config: testAccAWSElasticacheReplicationGroup_EnableAtRestEncryptionConfig(acctest.RandInt(), acctest.RandString(10)), Check: resource.ComposeTestCheckFunc( testAccCheckAWSElasticacheReplicationGroupExists(resourceName, &rg), - resource.TestCheckResourceAttr( - resourceName, "at_rest_encryption_enabled", "true"), + resource.TestCheckResourceAttr(resourceName, "at_rest_encryption_enabled", "true"), ), }, { @@ -1341,6 +1361,110 @@ func TestAccAWSElasticacheReplicationGroup_FinalSnapshot(t *testing.T) { }) } +func TestAccAWSElasticacheReplicationGroup_Validation_NoNodeType(t *testing.T) { + var providers []*schema.Provider + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccMultipleRegionPreCheck(t, 2) + }, + ProviderFactories: testAccProviderFactoriesMultipleRegion(&providers, 2), + CheckDestroy: testAccCheckAWSElasticacheReplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheReplicationGroupConfig_Validation_NoNodeType(rName), + ExpectError: regexp.MustCompile(`"node_type" is required unless "global_replication_group_id" is set.`), + }, + }, + }) +} + +func TestAccAWSElasticacheReplicationGroup_Validation_GlobalReplicationGroupIdAndNodeType(t *testing.T) { + var providers []*schema.Provider + rName := acctest.RandomWithPrefix("tf-acc-test") + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccMultipleRegionPreCheck(t, 2) + }, + ProviderFactories: testAccProviderFactoriesMultipleRegion(&providers, 2), + CheckDestroy: testAccCheckAWSElasticacheReplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheReplicationGroupConfig_Validation_GlobalReplicationGroupIdAndNodeType(rName), + ExpectError: regexp.MustCompile(`"global_replication_group_id": conflicts with node_type`), + }, + }, + }) +} + +func TestAccAWSElasticacheReplicationGroup_GlobalReplicationGroupId_Basic(t *testing.T) { + var providers []*schema.Provider + var rg elasticache.ReplicationGroup + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_elasticache_replication_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccMultipleRegionPreCheck(t, 2) + }, + ProviderFactories: testAccProviderFactoriesMultipleRegion(&providers, 2), + CheckDestroy: testAccCheckAWSElasticacheReplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheReplicationGroupConfig_GlobalReplicationGroupId_Basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheReplicationGroupExists(resourceName, &rg), + resource.TestCheckResourceAttrPair(resourceName, "global_replication_group_id", "aws_elasticache_global_replication_group.test", "global_replication_group_id"), + resource.TestCheckResourceAttrPair(resourceName, "node_type", "aws_elasticache_replication_group.primary", "node_type"), + resource.TestCheckResourceAttrPair(resourceName, "engine", "aws_elasticache_replication_group.primary", "engine"), + resource.TestCheckResourceAttrPair(resourceName, "engine_version", "aws_elasticache_replication_group.primary", "engine_version"), + resource.TestCheckResourceAttrPair(resourceName, "parameter_group_name", "aws_elasticache_replication_group.primary", "parameter_group_name"), + ), + }, + { + Config: testAccAWSElasticacheReplicationGroupConfig_GlobalReplicationGroupId_Basic(rName), + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"apply_immediately"}, + }, + }, + }) +} + +// Test for out-of-band deletion +// Naming to allow grouping all TestAccAWSElasticacheReplicationGroup_GlobalReplicationGroupId_* tests +func TestAccAWSElasticacheReplicationGroup_GlobalReplicationGroupId_disappears(t *testing.T) { // nosemgrep: acceptance-test-naming-parent-disappears + var providers []*schema.Provider + var rg elasticache.ReplicationGroup + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_elasticache_replication_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccMultipleRegionPreCheck(t, 2) + }, + ProviderFactories: testAccProviderFactoriesMultipleRegion(&providers, 2), + CheckDestroy: testAccCheckAWSElasticacheReplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSElasticacheReplicationGroupConfig_GlobalReplicationGroupId_Basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSElasticacheReplicationGroupExists(resourceName, &rg), + testAccCheckResourceDisappears(testAccProvider, resourceAwsElasticacheReplicationGroup(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + func testAccCheckAWSElasticacheReplicationGroupExists(n string, v *elasticache.ReplicationGroup) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] @@ -2364,6 +2488,100 @@ resource "aws_elasticache_replication_group" "test" { `, rName) } +func testAccAWSElasticacheReplicationGroupConfig_Validation_NoNodeType(rName string) string { + return fmt.Sprintf(` +resource "aws_elasticache_replication_group" "test" { + replication_group_id = %[1]q + replication_group_description = "test description" + number_cache_clusters = 1 +} +`, rName) +} + +func testAccAWSElasticacheReplicationGroupConfig_Validation_GlobalReplicationGroupIdAndNodeType(rName string) string { + return composeConfig( + testAccMultipleRegionProviderConfig(2), + testAccElasticacheVpcBaseWithProvider(rName, "test", ProviderNameAws), + testAccElasticacheVpcBaseWithProvider(rName, "primary", ProviderNameAwsAlternate), + fmt.Sprintf(` +resource "aws_elasticache_replication_group" "test" { + provider = aws + + replication_group_id = "%[1]s-s" + replication_group_description = "secondary" + global_replication_group_id = aws_elasticache_global_replication_group.test.global_replication_group_id + + subnet_group_name = aws_elasticache_subnet_group.test.name + + node_type = "cache.m5.large" + + number_cache_clusters = 1 +} + +resource "aws_elasticache_global_replication_group" "test" { + provider = awsalternate + + global_replication_group_id_suffix = %[1]q + primary_replication_group_id = aws_elasticache_replication_group.primary.id +} + +resource "aws_elasticache_replication_group" "primary" { + provider = awsalternate + + replication_group_id = "%[1]s-p" + replication_group_description = "primary" + + subnet_group_name = aws_elasticache_subnet_group.primary.name + + node_type = "cache.m5.large" + + engine = "redis" + engine_version = "5.0.6" + number_cache_clusters = 1 +} +`, rName)) +} + +func testAccAWSElasticacheReplicationGroupConfig_GlobalReplicationGroupId_Basic(rName string) string { + return composeConfig( + testAccMultipleRegionProviderConfig(2), + testAccElasticacheVpcBaseWithProvider(rName, "test", ProviderNameAws), + testAccElasticacheVpcBaseWithProvider(rName, "primary", ProviderNameAwsAlternate), + fmt.Sprintf(` +resource "aws_elasticache_replication_group" "test" { + replication_group_id = "%[1]s-s" + replication_group_description = "secondary" + global_replication_group_id = aws_elasticache_global_replication_group.test.global_replication_group_id + + subnet_group_name = aws_elasticache_subnet_group.test.name + + number_cache_clusters = 1 +} + +resource "aws_elasticache_global_replication_group" "test" { + provider = awsalternate + + global_replication_group_id_suffix = %[1]q + primary_replication_group_id = aws_elasticache_replication_group.primary.id +} + +resource "aws_elasticache_replication_group" "primary" { + provider = awsalternate + + replication_group_id = "%[1]s-p" + replication_group_description = "primary" + + subnet_group_name = aws_elasticache_subnet_group.primary.name + + node_type = "cache.m5.large" + + engine = "redis" + engine_version = "5.0.6" + number_cache_clusters = 1 +} +`, rName)) +} + func resourceAwsElasticacheReplicationGroupDisableAutomaticFailover(conn *elasticache.ElastiCache, replicationGroupID string, timeout time.Duration) error { return resourceAwsElasticacheReplicationGroupModify(conn, timeout, &elasticache.ModifyReplicationGroupInput{ ReplicationGroupId: aws.String(replicationGroupID), diff --git a/website/docs/r/elasticache_global_replication_group.html.markdown b/website/docs/r/elasticache_global_replication_group.html.markdown index 46f5a806483..39faedad4aa 100644 --- a/website/docs/r/elasticache_global_replication_group.html.markdown +++ b/website/docs/r/elasticache_global_replication_group.html.markdown @@ -8,27 +8,38 @@ description: |- # 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. For more information, see the [ElastiCache User Guide](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/Redis-Global-Datastore.html). +Provides an ElastiCache Global Replication Group resource, which manages replication between two or more Replication Groups in different regions. For more information, see the [ElastiCache User Guide](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/Redis-Global-Datastore.html). ## Example Usage -### Global replication group with a single instance redis replication group +### Global replication group with one secondary replication group -To create a single shard primary with single read replica: +The global replication group depends on the primary group existing. Secondary replication groups depend on the global replication group. Terraform dependency management will handle this transparently using resource value references. ```hcl -resource "aws_elasticache_global_replication_group" "replication_group" { +resource "aws_elasticache_global_replication_group" "example" { 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" + replication_group_id = "example-primary" + replication_group_description = "primary replication group" + + engine = "redis" + engine_version = "5.0.6" + node_type = "cache.m5.large" + + number_cache_clusters = 1 +} + +resource "aws_elasticache_replication_group" "secondary" { + provider = aws.other_region + + replication_group_id = "example-secondary" + replication_group_description = "secondary replication group" + global_replication_group_id = aws_elasticache_global_replication_group.example.global_replication_group_id - engine = "redis" - engine_version = "5.0.6" - node_type = "cache.m5.large" number_cache_clusters = 1 } ``` diff --git a/website/docs/r/elasticache_replication_group.html.markdown b/website/docs/r/elasticache_replication_group.html.markdown index 996fd6541aa..334557e5b3d 100644 --- a/website/docs/r/elasticache_replication_group.html.markdown +++ b/website/docs/r/elasticache_replication_group.html.markdown @@ -103,14 +103,49 @@ resource "aws_elasticache_replication_group" "baz" { and unavailable on T1 node types. For T2 node types, it is only available on Redis version 3.2.4 or later with cluster mode enabled. See the [High Availability Using Replication Groups](https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/Replication.html) guide for full details on using Replication Groups. +### Creating a secondary replication group for a global replication group + +A Global Replication Group can have one one two secondary Replication Groups in different regions. These are added to an existing Global Replication Group. + +```hcl +resource "aws_elasticache_replication_group" "secondary" { + replication_group_id = "example-secondary" + replication_group_description = "secondary replication group" + global_replication_group_id = aws_elasticache_global_replication_group.example.global_replication_group_id + + number_cache_clusters = 1 +} + +resource "aws_elasticache_global_replication_group" "example" { + provider = aws.other_region + + global_replication_group_id_suffix = "example" + primary_replication_group_id = aws_elasticache_replication_group.primary.id +} + +resource "aws_elasticache_replication_group" "primary" { + provider = aws.other_region + + replication_group_id = "example-primary" + replication_group_description = "primary replication group" + + engine = "redis" + engine_version = "5.0.6" + node_type = "cache.m5.large" + + number_cache_clusters = 1 +} +``` + ## Argument Reference 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. +* ``global_replication_group_id` - (Optional) The ID of the global replication group to which this replication group should belong. If this parameter is specified, the replication group is added to the specified global replication group as a secondary replication group; otherwise, the replication group is not part of any global 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 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). +* `node_type` - (Optional) 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). Required unless `global_replication_group_id` is set. Cannot be set if `global_replication_group_id` is set. * `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`.