diff --git a/.changelog/9886.txt b/.changelog/9886.txt new file mode 100644 index 00000000000..cdbd156e1d2 --- /dev/null +++ b/.changelog/9886.txt @@ -0,0 +1,3 @@ +```release-note:new-resource + aws_db_snapshot_copy + ``` \ No newline at end of file diff --git a/internal/provider/provider.go b/internal/provider/provider.go index feabe6a09b8..65484224db4 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1748,6 +1748,7 @@ func Provider() *schema.Provider { "aws_db_proxy_target": rds.ResourceProxyTarget(), "aws_db_security_group": rds.ResourceSecurityGroup(), "aws_db_snapshot": rds.ResourceSnapshot(), + "aws_db_snapshot_copy": rds.ResourceSnapshotCopy(), "aws_db_subnet_group": rds.ResourceSubnetGroup(), "aws_rds_cluster": rds.ResourceCluster(), "aws_rds_cluster_activity_stream": rds.ResourceClusterActivityStream(), diff --git a/internal/service/rds/snapshot_copy.go b/internal/service/rds/snapshot_copy.go new file mode 100644 index 00000000000..40b7a5fea27 --- /dev/null +++ b/internal/service/rds/snapshot_copy.go @@ -0,0 +1,314 @@ +package rds + +import ( + "context" + "log" + "regexp" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/rds" + "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "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/helper/validation" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + 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" +) + +func ResourceSnapshotCopy() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceSnapshotCopyCreate, + ReadWithoutTimeout: resourceSnapshotCopyRead, + UpdateWithoutTimeout: resourceSnapshotCopyUpdate, + DeleteWithoutTimeout: resourceSnapshotCopyDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(20 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "allocated_storage": { + Type: schema.TypeInt, + Computed: true, + }, + "availability_zone": { + Type: schema.TypeString, + Computed: true, + }, + "copy_tags": { + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + }, + "db_snapshot_arn": { + Type: schema.TypeString, + Computed: true, + }, + "destination_region": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "encrypted": { + Type: schema.TypeBool, + Computed: true, + }, + "engine": { + Type: schema.TypeString, + Computed: true, + }, + "engine_version": { + Type: schema.TypeString, + Computed: true, + }, + "iops": { + Type: schema.TypeInt, + Computed: true, + }, + "kms_key_id": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "license_model": { + Type: schema.TypeString, + Computed: true, + }, + "option_group_name": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + "port": { + Type: schema.TypeInt, + Computed: true, + }, + "presigned_url": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "source_db_snapshot_identifier": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "source_region": { + Type: schema.TypeString, + Computed: true, + }, + "snapshot_type": { + Type: schema.TypeString, + Computed: true, + }, + "storage_type": { + Type: schema.TypeString, + Computed: true, + }, + "tags": tftags.TagsSchema(), + "tags_all": tftags.TagsSchemaComputed(), + "target_custom_availability_zone": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + }, + "target_db_snapshot_identifier": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.All( + validation.StringLenBetween(1, 255), + validation.StringMatch(regexp.MustCompile(`^[a-zA-Z0-9][\w-]+`), "must contain only alphanumeric, and hyphen (-) characters"), + ), + }, + "vpc_id": { + Type: schema.TypeString, + Computed: true, + }, + }, + + CustomizeDiff: verify.SetTagsDiff, + } +} + +func resourceSnapshotCopyCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).RDSConn + defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig + tags := defaultTagsConfig.MergeTags(tftags.New(d.Get("tags").(map[string]interface{}))) + + in := &rds.CopyDBSnapshotInput{ + SourceDBSnapshotIdentifier: aws.String(d.Get("source_db_snapshot_identifier").(string)), + TargetDBSnapshotIdentifier: aws.String(d.Get("target_db_snapshot_identifier").(string)), + Tags: Tags(tags.IgnoreAWS()), + } + + if v, ok := d.GetOk("copy_tags"); ok { + in.CopyTags = aws.Bool(v.(bool)) + } + if v, ok := d.GetOk("kms_key_id"); ok { + in.KmsKeyId = aws.String(v.(string)) + } + if v, ok := d.GetOk("option_group_name"); ok { + in.OptionGroupName = aws.String(v.(string)) + } + if v, ok := d.GetOk("destination_region"); ok { + in.DestinationRegion = aws.String(v.(string)) + } + if v, ok := d.GetOk("presigned_url"); ok { + in.PreSignedUrl = aws.String(v.(string)) + } + + out, err := conn.CopyDBSnapshotWithContext(ctx, in) + if err != nil { + return diag.Errorf("error creating RDS DB Snapshot Copy %s", err) + } + + d.SetId(aws.StringValue(out.DBSnapshot.DBSnapshotIdentifier)) + + err = waitSnapshotCopyAvailable(ctx, d, meta) + if err != nil { + return diag.FromErr(err) + } + + return resourceSnapshotCopyRead(ctx, d, meta) +} + +func resourceSnapshotCopyRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).RDSConn + defaultTagsConfig := meta.(*conns.AWSClient).DefaultTagsConfig + ignoreTagsConfig := meta.(*conns.AWSClient).IgnoreTagsConfig + + snapshot, err := FindSnapshot(ctx, conn, d.Id()) + + if tfresource.NotFound(err) { + log.Printf("[WARN] RDS DB Snapshot (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return diag.Errorf("error describing RDS DB snapshot (%s): %s", d.Id(), err) + } + + arn := aws.StringValue(snapshot.DBSnapshotArn) + + d.Set("allocated_storage", snapshot.AllocatedStorage) + d.Set("availability_zone", snapshot.AvailabilityZone) + d.Set("db_snapshot_arn", snapshot.DBSnapshotArn) + d.Set("encrypted", snapshot.Encrypted) + d.Set("engine", snapshot.Engine) + d.Set("engine_version", snapshot.EngineVersion) + d.Set("iops", snapshot.Iops) + d.Set("kms_key_id", snapshot.KmsKeyId) + d.Set("license_model", snapshot.LicenseModel) + d.Set("option_group_name", snapshot.OptionGroupName) + d.Set("port", snapshot.Port) + d.Set("snapshot_type", snapshot.SnapshotType) + d.Set("source_db_snapshot_identifier", snapshot.SourceDBSnapshotIdentifier) + d.Set("source_region", snapshot.SourceRegion) + d.Set("storage_type", snapshot.StorageType) + d.Set("target_db_snapshot_identifier", snapshot.DBSnapshotIdentifier) + d.Set("vpc_id", snapshot.VpcId) + + tags, err := ListTags(conn, arn) + + if err != nil { + return diag.Errorf("error listing tags for RDS DB Snapshot (%s): %s", arn, err) + } + + tags = tags.IgnoreAWS().IgnoreConfig(ignoreTagsConfig) + + //lintignore:AWSR002 + if err := d.Set("tags", tags.RemoveDefaultConfig(defaultTagsConfig).Map()); err != nil { + return diag.Errorf("error setting tags: %s", err) + } + + if err := d.Set("tags_all", tags.Map()); err != nil { + return diag.Errorf("error setting tags_all: %s", err) + } + + return nil +} + +func resourceSnapshotCopyUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).RDSConn + + if d.HasChange("tags_all") { + o, n := d.GetChange("tags_all") + + if err := UpdateTags(conn, d.Get("db_snapshot_arn").(string), o, n); err != nil { + return diag.Errorf("error updating RDS DB Snapshot (%s) tags: %s", d.Get("db_snapshot_arn").(string), err) + } + } + + return resourceSnapshotCopyRead(ctx, d, meta) +} + +func resourceSnapshotCopyDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).RDSConn + + log.Printf("[INFO] Deleting RDS DB Snapshot %s", d.Id()) + + in := &rds.DeleteDBSnapshotInput{ + DBSnapshotIdentifier: aws.String(d.Id()), + } + + _, err := conn.DeleteDBSnapshotWithContext(ctx, in) + if tfawserr.ErrCodeEquals(err, rds.ErrCodeDBSnapshotNotFoundFault) { + return nil + } + + if err != nil { + return diag.Errorf("error deleting RDS DB Snapshot (%s): %s", d.Id(), err) + } + + return nil +} + +func FindSnapshot(ctx context.Context, conn *rds.RDS, id string) (*rds.DBSnapshot, error) { + in := &rds.DescribeDBSnapshotsInput{ + DBSnapshotIdentifier: aws.String(id), + } + out, err := conn.DescribeDBSnapshotsWithContext(ctx, in) + + if tfawserr.ErrCodeEquals(err, rds.ErrCodeDBSnapshotNotFoundFault) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: in, + } + } + + if out == nil || len(out.DBSnapshots) == 0 || out.DBSnapshots[0] == nil { + return nil, tfresource.NewEmptyResultError(in) + } + + return out.DBSnapshots[0], nil +} +func waitSnapshotCopyAvailable(ctx context.Context, d *schema.ResourceData, meta interface{}) error { + log.Printf("[DEBUG] Waiting for Snapshot %s to become available...", d.Id()) + + stateConf := &resource.StateChangeConf{ + Pending: []string{"creating"}, + Target: []string{"available"}, + Refresh: resourceSnapshotStateRefreshFunc(d, meta), + Timeout: d.Timeout(schema.TimeoutCreate), + MinTimeout: 10 * time.Second, + Delay: 30 * time.Second, // Wait 30 secs before starting + } + + // Wait, catching any errors + _, err := stateConf.WaitForStateContext(ctx) + if err != nil { + return err + } + + return nil +} diff --git a/internal/service/rds/snapshot_copy_test.go b/internal/service/rds/snapshot_copy_test.go new file mode 100644 index 00000000000..f09b61147c1 --- /dev/null +++ b/internal/service/rds/snapshot_copy_test.go @@ -0,0 +1,242 @@ +package rds_test + +import ( + "context" + "fmt" + "log" + "testing" + + "github.com/aws/aws-sdk-go/service/rds" + sdkacctest "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/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfrds "github.com/hashicorp/terraform-provider-aws/internal/service/rds" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" +) + +func TestAccSnapshotCopy_basic(t *testing.T) { + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v rds.DBSnapshot + resourceName := "aws_db_snapshot_copy.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, rds.EndpointsID), + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckSnapshotCopyDestroy, + Steps: []resource.TestStep{ + { + Config: testAccSnapshotCopyConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckSnapshotCopyExists(resourceName, &v), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccSnapshotCopy_tags(t *testing.T) { + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v rds.DBSnapshot + resourceName := "aws_db_snapshot_copy.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, rds.EndpointsID), + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckSnapshotCopyDestroy, + Steps: []resource.TestStep{ + { + Config: testAccSnapshotCopyTagsConfig1(rName, "key1", "value1"), + Check: resource.ComposeTestCheckFunc( + testAccCheckDbSnapshotExists(resourceName, &v), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccSnapshotCopyTagsConfig2(rName, "key1", "value1updated", "key2", "value2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckDbSnapshotExists(resourceName, &v), + resource.TestCheckResourceAttr(resourceName, "tags.%", "2"), + resource.TestCheckResourceAttr(resourceName, "tags.key1", "value1updated"), + resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"), + ), + }, + { + Config: testAccSnapshotCopyTagsConfig1(rName, "key2", "value2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckDbSnapshotExists(resourceName, &v), + resource.TestCheckResourceAttr(resourceName, "tags.%", "1"), + resource.TestCheckResourceAttr(resourceName, "tags.key2", "value2"), + ), + }, + }, + }) +} + +func TestAccSnapshotCopy_disappears(t *testing.T) { + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v rds.DBSnapshot + resourceName := "aws_db_snapshot_copy.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, rds.EndpointsID), + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckSnapshotCopyDestroy, + Steps: []resource.TestStep{ + { + Config: testAccSnapshotCopyConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckSnapshotCopyExists(resourceName, &v), + acctest.CheckResourceDisappears(acctest.Provider, tfrds.ResourceSnapshotCopy(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckSnapshotCopyDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).RDSConn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_db_snapshot_copy" { + continue + } + + log.Printf("[DEBUG] Checking if RDS DB Snapshot %s exists", rs.Primary.ID) + + _, err := tfrds.FindSnapshot(context.Background(), conn, rs.Primary.ID) + + // verify error is what we want + if tfresource.NotFound(err) { + continue + } + + return err + } + + return nil +} + +func testAccCheckSnapshotCopyExists(n string, ci *rds.DBSnapshot) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no RDS DB Snapshot ID is set") + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).RDSConn + + out, err := tfrds.FindSnapshot(context.Background(), conn, rs.Primary.ID) + if err != nil { + return err + } + + ci = out + + return nil + } +} + +func testAccSnapshotCopyBaseConfig(rName string) string { + return fmt.Sprintf(` +data "aws_rds_engine_version" "default" { + engine = "mysql" +} + +data "aws_rds_orderable_db_instance" "test" { + engine = data.aws_rds_engine_version.default.engine + engine_version = data.aws_rds_engine_version.default.version + preferred_instance_classes = ["db.t3.small", "db.t2.small", "db.t2.medium"] +} + +resource "aws_db_instance" "test" { + allocated_storage = 10 + engine = data.aws_rds_engine_version.default.engine + engine_version = data.aws_rds_engine_version.default.version + instance_class = data.aws_rds_orderable_db_instance.test.instance_class + name = "baz" + identifier = %[1]q + password = "barbarbarbar" + username = "foo" + maintenance_window = "Fri:09:00-Fri:09:30" + backup_retention_period = 0 + parameter_group_name = "default.${data.aws_rds_engine_version.default.parameter_group_family}" + skip_final_snapshot = true +} + +resource "aws_db_snapshot" "test" { + db_instance_identifier = aws_db_instance.test.id + db_snapshot_identifier = "%[1]s-source" +}`, rName) +} + +func testAccSnapshotCopyConfig(rName string) string { + return acctest.ConfigCompose( + testAccSnapshotCopyBaseConfig(rName), + fmt.Sprintf(` +resource "aws_db_snapshot_copy" "test" { + source_db_snapshot_identifier = aws_db_snapshot.test.db_snapshot_arn + target_db_snapshot_identifier = "%[1]s-target" +}`, rName)) +} + +func testAccSnapshotCopyTagsConfig1(rName, tagKey, tagValue string) string { + return acctest.ConfigCompose( + testAccSnapshotCopyBaseConfig(rName), + fmt.Sprintf(` +resource "aws_db_snapshot_copy" "test" { + source_db_snapshot_identifier = aws_db_snapshot.test.db_snapshot_arn + target_db_snapshot_identifier = "%[1]s-target" + + tags = { + %[2]q = %[3]q + } +}`, rName, tagKey, tagValue)) +} + +func testAccSnapshotCopyTagsConfig2(rName, tagKey1, tagValue1, tagKey2, tagValue2 string) string { + return acctest.ConfigCompose( + testAccSnapshotCopyBaseConfig(rName), + fmt.Sprintf(` +resource "aws_db_snapshot_copy" "test" { + source_db_snapshot_identifier = aws_db_snapshot.test.db_snapshot_arn + target_db_snapshot_identifier = "%[1]s-target" + + tags = { + %[2]q = %[3]q + %[4]q = %[5]q + } +}`, rName, tagKey1, tagValue1, tagKey2, tagValue2)) +} diff --git a/website/docs/r/db_snapshot_copy.html.markdown b/website/docs/r/db_snapshot_copy.html.markdown new file mode 100644 index 00000000000..142042ed773 --- /dev/null +++ b/website/docs/r/db_snapshot_copy.html.markdown @@ -0,0 +1,89 @@ +--- +subcategory: "RDS (Relational Database)" +layout: "aws" +page_title: "AWS: aws_db_snapshot_copy" +description: |- + Manages an RDS database instance snapshot copy. +--- + +# Resource: aws_db_snapshot_copy + +Manages an RDS database instance snapshot copy. For managing RDS database cluster snapshots, see the [`aws_db_cluster_snapshot` resource](/docs/providers/aws/r/db_cluster_snapshot.html). + +## Example Usage + +```terraform +resource "aws_db_instance" "example" { + allocated_storage = 10 + engine = "mysql" + engine_version = "5.6.21" + instance_class = "db.t2.micro" + name = "baz" + password = "barbarbarbar" + username = "foo" + + maintenance_window = "Fri:09:00-Fri:09:30" + backup_retention_period = 0 + parameter_group_name = "default.mysql5.6" +} + +resource "aws_db_snapshot" "example" { + db_instance_identifier = aws_db_instance.example.id + db_snapshot_identifier = "testsnapshot1234" +} + +resource "aws_db_snapshot_copy" "example" { + source_db_snapshot_identifier = aws_db_snapshot.example.db_snapshot_arn + target_db_snapshot_identifier = "testsnapshot1234-copy" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `copy_tags` - (Optional) Whether to copy existing tags. Defaults to `false`. +* `destination_region` - (Optional) The Destination region to place snapshot copy. +* `kms_key_id` - (Optional) KMS key ID. +* `option_group_name`- (Optional) The name of an option group to associate with the copy of the snapshot. +* `presigned_url` - (Optional) he URL that contains a Signature Version 4 signed request. +* `source_db_snapshot_identifier` - (Required) Snapshot identifier of the source snapshot. +* `target_custom_availability_zone` - (Optional) The external custom Availability Zone. +* `target_db_snapshot_identifier` - (Required) The Identifier for the snapshot. +* `tags` - (Optional) Key-value map of resource tags. If configured with a provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. + + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - Snapshot Identifier. +* `allocated_storage` - Specifies the allocated storage size in gigabytes (GB). +* `availability_zone` - Specifies the name of the Availability Zone the DB instance was located in at the time of the DB snapshot. +* `db_snapshot_arn` - The Amazon Resource Name (ARN) for the DB snapshot. +* `encrypted` - Specifies whether the DB snapshot is encrypted. +* `engine` - Specifies the name of the database engine. +* `engine_version` - Specifies the version of the database engine. +* `iops` - Specifies the Provisioned IOPS (I/O operations per second) value of the DB instance at the time of the snapshot. +* `kms_key_id` - The ARN for the KMS encryption key. +* `license_model` - License model information for the restored DB instance. +* `option_group_name` - Provides the option group name for the DB snapshot. +* `source_db_snapshot_identifier` - The DB snapshot Arn that the DB snapshot was copied from. It only has value in case of cross customer or cross region copy. +* `source_region` - The region that the DB snapshot was created in or copied from. +* `storage_type` - Specifies the storage type associated with DB snapshot. +* `tags_all` - A map of tags assigned to the resource, including those inherited from the provider [`default_tags` configuration block](/docs/providers/aws/index.html#default_tags-configuration-block). +* `vpc_id` - Provides the VPC ID associated with the DB snapshot. + +## Timeouts + +`aws_db_snapshot_copy` provides the following [Timeouts](https://www.terraform.io/docs/configuration/blocks/resources/syntax.html#operation-timeouts) configuration options: + +- `create` - (Default `20 minutes`) Length of time to wait for the snapshot to become available + +## Import + +`aws_db_snapshot_copy` can be imported by using the snapshot identifier, e.g., + +``` +$ terraform import aws_db_snapshot_copy.example my-snapshot +```