diff --git a/internal/service/rds/enum.go b/internal/service/rds/enum.go index c31b4aa776e2..3940194dc1d5 100644 --- a/internal/service/rds/enum.go +++ b/internal/service/rds/enum.go @@ -20,6 +20,8 @@ func StorageType_Values() []string { } } +const DefaultKmsKeyAlias = "alias/aws/rds" + // https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/accessing-monitoring.html#Overview.DBInstance.Status. const ( InstanceStatusAvailable = "available" diff --git a/internal/service/rds/instance.go b/internal/service/rds/instance.go index 8f369fb44843..32be46fbcd6c 100644 --- a/internal/service/rds/instance.go +++ b/internal/service/rds/instance.go @@ -9,7 +9,9 @@ import ( "time" "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/kms" "github.com/aws/aws-sdk-go/service/rds" + awsbase "github.com/hashicorp/aws-sdk-go-base/v2" "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -18,6 +20,7 @@ import ( "github.com/hashicorp/terraform-provider-aws/internal/create" "github.com/hashicorp/terraform-provider-aws/internal/flex" tfiam "github.com/hashicorp/terraform-provider-aws/internal/service/iam" + tfkms "github.com/hashicorp/terraform-provider-aws/internal/service/kms" tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" "github.com/hashicorp/terraform-provider-aws/internal/tfresource" "github.com/hashicorp/terraform-provider-aws/internal/verify" @@ -108,6 +111,29 @@ func ResourceInstance() *schema.Resource { Computed: true, ForceNew: true, }, + "backup_replication": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "backup_retention_period": { + Type: schema.TypeInt, + Optional: true, + }, + "destination_region": { + Type: schema.TypeString, + Required: true, + }, + "kms_key_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ValidateFunc: verify.ValidARN, + }, + }, + }, + }, "backup_retention_period": { Type: schema.TypeInt, Optional: true, @@ -1421,6 +1447,57 @@ func resourceInstanceCreate(d *schema.ResourceData, meta interface{}) error { } } + if b, ok := d.GetOk("backup_replication"); ok { + v, err := FindDBInstanceByID(conn, d.Id()) + + if err != nil { + return fmt.Errorf("Unable to find database by id: %w", err) + } + + backup_replication := b.([]interface{})[0].(map[string]interface{}) + + destinationRegion := backup_replication["destination_region"].(string) + + if err := awsbase.ValidateRegion(destinationRegion); err != nil { + return fmt.Errorf("Invalid region for backup replication: %w", err) + } + + opts := rds.StartDBInstanceAutomatedBackupsReplicationInput{ + SourceDBInstanceArn: v.DBInstanceArn, + } + + // Replication is initiated in the destination region. + session, err := conns.NewSessionForRegion(&conn.Config, destinationRegion, meta.(*conns.AWSClient).TerraformVersion) + + if v, ok := backup_replication["kms_key_id"].(string); ok && v != "" { + opts.KmsKeyId = aws.String(backup_replication["kms_key_id"].(string)) + } else { + if _, ok := d.GetOk("storage_encrypted"); ok { + kmsConn := kms.New(session) + keyMetadata, err := tfkms.FindKeyByID(kmsConn, DefaultKmsKeyAlias) + if err != nil { + return fmt.Errorf("Failed to describe default RDS KMS key (%s): %s", DefaultKmsKeyAlias, err) + } + opts.KmsKeyId = keyMetadata.Arn + } + } + + if v, ok := backup_replication["backup_retention_period"].(string); ok && v != "" { + opts.BackupRetentionPeriod = aws.Int64(int64(backup_replication["backup_retention_period"].(int))) + } + + if err != nil { + return fmt.Errorf("error creating AWS session: %w", err) + } + + replicateConn := rds.New(session) + + _, err = replicateConn.StartDBInstanceAutomatedBackupsReplication(&opts) + if err != nil { + return fmt.Errorf("Error enabling database snapshot replication: %w", err) + } + } + return resourceInstanceRead(d, meta) } @@ -1860,6 +1937,118 @@ func resourceInstanceUpdate(d *schema.ResourceData, meta interface{}) error { } } + // separate connection required to enable replication of snapshots + if d.HasChange("backup_replication") { + + b, ok := d.GetOk("backup_replication") + + enableReplication := ok + + var destinationRegion string + + if enableReplication { + destinationRegion = b.([]interface{})[0].(map[string]interface{})["destination_region"].(string) + if err := awsbase.ValidateRegion(destinationRegion); err != nil { + return fmt.Errorf("Invalid region for backup replication: %w", err) + } + } + + v, err := FindDBInstanceByID(conn, d.Id()) + + if err != nil { + return fmt.Errorf("Unable to find database by id: %w", err) + } + + // Find existing region that is replicating and stop replication, + // to avoid errors starting a new replication + for _, r := range v.DBInstanceAutomatedBackupsReplications { + if arnParts := strings.Split(*r.DBInstanceAutomatedBackupsArn, ":"); len(arnParts) >= 4 { + region := arnParts[3] + + log.Printf("[INFO] Stopping backup replication in region: %s", region) + + session, err := conns.NewSessionForRegion(&conn.Config, region, meta.(*conns.AWSClient).TerraformVersion) + + if err != nil { + return fmt.Errorf("error creating AWS session: %w", err) + } + + replicateConn := rds.New(session) + + stopOpts := rds.StopDBInstanceAutomatedBackupsReplicationInput{ + SourceDBInstanceArn: v.DBInstanceArn, + } + + _, err = replicateConn.StopDBInstanceAutomatedBackupsReplication(&stopOpts) + + if err != nil && !tfawserr.ErrMessageContains(err, "InvalidDBInstanceState", "is not replicating to the current region") { + return fmt.Errorf("Error disabling database snapshot replication: %w", err) + } + } + } + + if enableReplication { + + if err != nil { + return fmt.Errorf("error creating AWS session: %w", err) + } + + log.Printf("[INFO] Starting backup replication in region: %s", destinationRegion) + + // Replication is initiated in the destination region. + session, err := conns.NewSessionForRegion(&conn.Config, destinationRegion, meta.(*conns.AWSClient).TerraformVersion) + replicateConn := rds.New(session) + + backup_replication := b.([]interface{})[0].(map[string]interface{}) + + startOpts := rds.StartDBInstanceAutomatedBackupsReplicationInput{ + SourceDBInstanceArn: v.DBInstanceArn, + } + + if v, ok := backup_replication["kms_key_id"].(string); ok && v != "" { + startOpts.KmsKeyId = aws.String(backup_replication["kms_key_id"].(string)) + } else { + if _, ok := d.GetOk("storage_encrypted"); ok { + kmsConn := kms.New(session) + keyMetadata, err := tfkms.FindKeyByID(kmsConn, DefaultKmsKeyAlias) + if err != nil { + return fmt.Errorf("Failed to describe default RDS KMS key (%s): %s", DefaultKmsKeyAlias, err) + } + startOpts.KmsKeyId = keyMetadata.Arn + log.Printf("[INFO] kms_key_id not provided for backup_replication, using: %s", *startOpts.KmsKeyId) + } + } + + if v, ok := backup_replication["backup_retention_period"].(string); ok && v != "" { + startOpts.BackupRetentionPeriod = aws.Int64(int64(backup_replication["backup_retention_period"].(int))) + } + + err = resource.Retry(5*time.Minute, func() *resource.RetryError { + _, err = replicateConn.StartDBInstanceAutomatedBackupsReplication(&startOpts) + if err != nil { + log.Printf("[INFO] Error starting backup replication: %s", err) + if tfawserr.ErrMessageContains(err, "InvalidDBInstanceAutomatedBackupState", "is already replicating") { + return resource.RetryableError(err) + } + if tfawserr.ErrMessageContains(err, "InvalidDBInstanceState", "DB Instance is in an invalid state") { + return resource.RetryableError(err) + } + if tfawserr.ErrMessageContains(err, "InvalidDBInstanceState", "DB instance is already replicating backups to this region") { + return resource.RetryableError(err) + } + return resource.NonRetryableError(err) + } + return nil + }) + if tfresource.TimedOut(err) { + _, err = replicateConn.StartDBInstanceAutomatedBackupsReplication(&startOpts) + } + if err != nil { + return fmt.Errorf("Error starting backup replication: %w", err) + } + } + } + if d.HasChange("tags_all") { o, n := d.GetChange("tags_all") diff --git a/internal/service/rds/instance_test.go b/internal/service/rds/instance_test.go index 2f1cc378d614..865f33154a3d 100644 --- a/internal/service/rds/instance_test.go +++ b/internal/service/rds/instance_test.go @@ -1704,6 +1704,32 @@ func TestAccRDSInstance_s3Import_nameGenerated(t *testing.T) { }) } +func TestAccRDSInstance_backupReplication_basic(t *testing.T) { + var dbInstance rds.DBInstance + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_db_instance.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, rds.EndpointsID), + Providers: acctest.Providers, + CheckDestroy: testAccCheckInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccInstanceConfig_backupReplication_basic(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckInstanceExists(resourceName, &dbInstance), + resource.TestCheckResourceAttr(resourceName, "backup_retention_period", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "backup_replication.*", map[string]string{ + "destination_region": "us-east-2", + }), + ), + }, + }, + }) +} + func TestAccRDSInstance_SnapshotIdentifier_basic(t *testing.T) { var dbInstance, sourceDbInstance rds.DBInstance var dbSnapshot rds.DBSnapshot @@ -4618,6 +4644,27 @@ resource "aws_db_instance" "test" { `, bucketPrefix)) } +func testAccInstanceConfig_backupReplication_basic(rName string) string { + return acctest.ConfigCompose( + testAccInstanceConfig_orderableClass("postgres", "12.2", "postgresql-license"), + fmt.Sprintf(` +resource "aws_db_instance" "test" { + allocated_storage = 5 + backup_retention_period = 1 + engine = data.aws_rds_orderable_db_instance.test.engine + identifier = "%[1]s" + instance_class = data.aws_rds_orderable_db_instance.test.instance_class + password = "avoid-plaintext-passwords" + username = "tfacctest" + skip_final_snapshot = true + + backup_replication { + destination_region = "us-east-2" + } +} +`, rName)) +} + func testAccInstanceConfig_FinalSnapshotIdentifier(rInt int) string { return acctest.ConfigCompose(testAccInstanceConfig_orderableClassMySQL(), fmt.Sprintf(` resource "aws_db_instance" "snapshot" { diff --git a/website/docs/r/db_instance.html.markdown b/website/docs/r/db_instance.html.markdown index 973204826314..28697db79967 100644 --- a/website/docs/r/db_instance.html.markdown +++ b/website/docs/r/db_instance.html.markdown @@ -88,6 +88,7 @@ Defaults to true. * `availability_zone` - (Optional) The AZ for the RDS instance. * `backup_retention_period` - (Optional) The days to retain backups for. Must be between `0` and `35`. Must be greater than `0` if the database is used as a source for a Read Replica. [See Read Replica][1]. +* `backup_replication` - (Optional) Store automated snapshots in another region. When specifying `backup_replication`, `backup_retention_period` needs to be set to true. See [Replicating automated backups to another AWS Region](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ReplicateBackups.html#AutomatedBackups.Replicating.Enable). * `backup_window` - (Optional) The daily time range (in UTC) during which automated backups are created if they are enabled. Example: "09:46-10:16". Must not overlap with `maintenance_window`. @@ -253,6 +254,16 @@ resource "aws_db_instance" "db" { This will not recreate the resource if the S3 object changes in some way. It's only used to initialize the database +### Backup Replication Options + +You can configure the database to store the automated snapshots in a different region. Full details in the API Docs: [StartDBInstanceAutomatedBackupsReplication](https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_StartDBInstanceAutomatedBackupsReplication.html). + +The `backup_replication` block supports the following arguments: + +* `destination_region` - (Required) The region to replicate the backups to (must be a different region than the database resides in) +* `backup_retention_period` - (Optional) How many days to to store backups for in the destination region +* `kms_key_id` - (Optional) If `storage_encrypted` is enabled and this is not provided, the default aws kms key for rds is used + ### Timeouts `aws_db_instance` provides the following