diff --git a/.changelog/40398.txt b/.changelog/40398.txt new file mode 100644 index 00000000000..4a2e24cafef --- /dev/null +++ b/.changelog/40398.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_rds_cluster_snapshot_copy +``` diff --git a/internal/service/rds/cluster_snapshot.go b/internal/service/rds/cluster_snapshot.go index f6c25230ad1..ff228791809 100644 --- a/internal/service/rds/cluster_snapshot.go +++ b/internal/service/rds/cluster_snapshot.go @@ -339,9 +339,9 @@ func statusDBClusterSnapshot(ctx context.Context, conn *rds.Client, id string) r } } -func waitDBClusterSnapshotCreated(ctx context.Context, conn *rds.Client, id string, timeout time.Duration) (*types.DBClusterSnapshot, error) { +func waitDBClusterSnapshotCreated(ctx context.Context, conn *rds.Client, id string, timeout time.Duration) (*types.DBClusterSnapshot, error) { //nolint:unparam stateConf := &retry.StateChangeConf{ - Pending: []string{clusterSnapshotStatusCreating}, + Pending: []string{clusterSnapshotStatusCreating, clusterSnapshotStatusCopying}, Target: []string{clusterSnapshotStatusAvailable}, Refresh: statusDBClusterSnapshot(ctx, conn, id), Timeout: timeout, diff --git a/internal/service/rds/cluster_snapshot_copy.go b/internal/service/rds/cluster_snapshot_copy.go new file mode 100644 index 00000000000..f9044db1ed7 --- /dev/null +++ b/internal/service/rds/cluster_snapshot_copy.go @@ -0,0 +1,419 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package rds + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/YakDriver/regexache" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/rds" + awstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-provider-aws/internal/create" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + intflex "github.com/hashicorp/terraform-provider-aws/internal/flex" + "github.com/hashicorp/terraform-provider-aws/internal/framework" + "github.com/hashicorp/terraform-provider-aws/internal/framework/flex" + tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @FrameworkResource("aws_rds_cluster_snapshot_copy", name="Cluster Snapshot Copy") +// @Tags(identifierAttribute="db_cluster_snapshot_arn") +// @Testing(tagsTest=false) +func newResourceClusterSnapshotCopy(context.Context) (resource.ResourceWithConfigure, error) { + r := &resourceClusterSnapshotCopy{} + + r.SetDefaultCreateTimeout(20 * time.Minute) + + return r, nil +} + +const ( + ResNameClusterSnapshotCopy = "Cluster Snapshot Copy" +) + +type resourceClusterSnapshotCopy struct { + framework.ResourceWithConfigure + framework.WithTimeouts +} + +func (r *resourceClusterSnapshotCopy) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "aws_rds_cluster_snapshot_copy" +} + +func (r *resourceClusterSnapshotCopy) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + names.AttrAllocatedStorage: schema.Int64Attribute{ + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "copy_tags": schema.BoolAttribute{ + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "db_cluster_snapshot_arn": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "destination_region": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + names.AttrEngine: schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + names.AttrEngineVersion: schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + names.AttrID: framework.IDAttribute(), + names.AttrKMSKeyID: schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "license_model": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "presigned_url": schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "shared_accounts": schema.SetAttribute{ + ElementType: types.StringType, + Optional: true, + }, + "snapshot_type": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "source_db_cluster_snapshot_identifier": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + names.AttrStorageEncrypted: schema.BoolAttribute{ + Computed: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.UseStateForUnknown(), + }, + }, + names.AttrStorageType: schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + names.AttrTags: tftags.TagsAttribute(), + names.AttrTagsAll: tftags.TagsAttributeComputedOnly(), + "target_db_cluster_snapshot_identifier": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 255), + stringvalidator.RegexMatches(regexache.MustCompile(`^[0-9A-Za-z][\w-]+`), "must contain only alphanumeric, and hyphen (-) characters"), + }, + }, + names.AttrVPCID: schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + Blocks: map[string]schema.Block{ + names.AttrTimeouts: timeouts.Block(ctx, timeouts.Opts{ + Create: true, + }), + }, + } +} + +func (r *resourceClusterSnapshotCopy) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data resourceClusterSnapshotCopyData + conn := r.Meta().RDSClient(ctx) + + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + in := &rds.CopyDBClusterSnapshotInput{} + resp.Diagnostics.Append(flex.Expand(ctx, data, in)...) + if resp.Diagnostics.HasError() { + return + } + in.Tags = getTagsIn(ctx) + + if !data.DestinationRegion.IsNull() && data.PresignedURL.IsNull() { + output, err := rds.NewPresignClient(conn, func(o *rds.PresignOptions) { + o.ClientOptions = append(o.ClientOptions, func(o *rds.Options) { + o.Region = data.DestinationRegion.ValueString() + }) + }).PresignCopyDBClusterSnapshot(ctx, in) + + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionCreating, ResNameClusterSnapshotCopy, data.TargetDBClusterSnapshotIdentifier.String(), err), + err.Error(), + ) + return + } + + in.PreSignedUrl = aws.String(output.URL) + } + + out, err := conn.CopyDBClusterSnapshot(ctx, in) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionCreating, ResNameClusterSnapshotCopy, data.TargetDBClusterSnapshotIdentifier.String(), err), + err.Error(), + ) + return + } + if out == nil || out.DBClusterSnapshot == nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionCreating, ResNameClusterSnapshotCopy, data.TargetDBClusterSnapshotIdentifier.String(), err), + errors.New("empty output").Error(), + ) + return + } + + resp.Diagnostics.Append(flex.Flatten(ctx, out.DBClusterSnapshot, &data)...) + if resp.Diagnostics.HasError() { + return + } + data.ID = types.StringValue(aws.ToString(out.DBClusterSnapshot.DBClusterSnapshotIdentifier)) + + createTimeout := r.CreateTimeout(ctx, data.Timeouts) + if _, err := waitDBClusterSnapshotCreated(ctx, conn, data.ID.ValueString(), createTimeout); err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionWaitingForCreation, ResNameClusterSnapshotCopy, data.TargetDBClusterSnapshotIdentifier.String(), err), + err.Error(), + ) + return + } + + if !data.SharedAccounts.IsNull() { + toAdd := []string{} + resp.Diagnostics.Append(data.SharedAccounts.ElementsAs(ctx, &toAdd, false)...) + if resp.Diagnostics.HasError() { + return + } + + input := &rds.ModifyDBClusterSnapshotAttributeInput{ + AttributeName: aws.String(dbSnapshotAttributeNameRestore), + DBClusterSnapshotIdentifier: data.TargetDBClusterSnapshotIdentifier.ValueStringPointer(), + ValuesToAdd: toAdd, + } + + if _, err := conn.ModifyDBClusterSnapshotAttribute(ctx, input); err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionCreating, ResNameClusterSnapshotCopy, data.TargetDBClusterSnapshotIdentifier.String(), err), + err.Error(), + ) + return + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *resourceClusterSnapshotCopy) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data resourceClusterSnapshotCopyData + conn := r.Meta().RDSClient(ctx) + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := findDBClusterSnapshotByID(ctx, conn, data.ID.ValueString()) + if tfresource.NotFound(err) { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionReading, ResNameClusterSnapshotCopy, data.ID.String(), err), + err.Error(), + ) + return + } + // Account for variance in naming between the AWS Create and Describe APIs + data.SourceDBClusterSnapshotIdentifier = flex.StringToFramework(ctx, out.SourceDBClusterSnapshotArn) + data.TargetDBClusterSnapshotIdentifier = flex.StringToFramework(ctx, out.DBClusterSnapshotIdentifier) + + resp.Diagnostics.Append(flex.Flatten(ctx, out, &data)...) + if resp.Diagnostics.HasError() { + return + } + + outAttr, err := findDBClusterSnapshotAttributeByTwoPartKey(ctx, conn, data.ID.ValueString(), dbSnapshotAttributeNameRestore) + if err != nil && !tfresource.NotFound(err) { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionReading, ResNameClusterSnapshotCopy, data.ID.String(), err), + err.Error(), + ) + return + } + + if len(outAttr.AttributeValues) > 0 { + resp.Diagnostics.Append(flex.Flatten(ctx, outAttr.AttributeValues, &data.SharedAccounts)...) + if resp.Diagnostics.HasError() { + return + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *resourceClusterSnapshotCopy) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var old, new resourceClusterSnapshotCopyData + conn := r.Meta().RDSClient(ctx) + + resp.Diagnostics.Append(req.State.Get(ctx, &old)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &new)...) + if resp.Diagnostics.HasError() { + return + } + + if !old.SharedAccounts.Equal(new.SharedAccounts) { + var have, want []string + resp.Diagnostics.Append(old.SharedAccounts.ElementsAs(ctx, &have, false)...) + resp.Diagnostics.Append(new.SharedAccounts.ElementsAs(ctx, &want, false)...) + if resp.Diagnostics.HasError() { + return + } + + toAdd, toRemove, _ := intflex.DiffSlices(have, want, func(s1, s2 string) bool { return s1 == s2 }) + + input := &rds.ModifyDBClusterSnapshotAttributeInput{ + AttributeName: aws.String(dbSnapshotAttributeNameRestore), + DBClusterSnapshotIdentifier: new.TargetDBClusterSnapshotIdentifier.ValueStringPointer(), + ValuesToAdd: toAdd, + ValuesToRemove: toRemove, + } + + if _, err := conn.ModifyDBClusterSnapshotAttribute(ctx, input); err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionUpdating, ResNameClusterSnapshotCopy, new.ID.String(), err), + err.Error(), + ) + return + } + } + + // StorageType can be null, and UseStateForUnknown takes no action + // on null state values. Explicitly pass through the null value in + // this case to prevent "invalid result object after apply" errors + if old.StorageType.IsNull() { + new.StorageType = types.StringNull() + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &new)...) +} + +func (r *resourceClusterSnapshotCopy) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data resourceClusterSnapshotCopyData + conn := r.Meta().RDSClient(ctx) + + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tflog.Debug(ctx, fmt.Sprintf("deleting %s", ResNameClusterSnapshotCopy), map[string]interface{}{ + names.AttrID: data.ID.ValueString(), + }) + + _, err := conn.DeleteDBClusterSnapshot(ctx, &rds.DeleteDBClusterSnapshotInput{ + DBClusterSnapshotIdentifier: data.ID.ValueStringPointer(), + }) + if err != nil { + if errs.IsA[*awstypes.DBClusterSnapshotNotFoundFault](err) { + return + } + + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.RDS, create.ErrActionDeleting, ResNameClusterSnapshotCopy, data.ID.String(), err), + err.Error(), + ) + return + } +} + +func (r *resourceClusterSnapshotCopy) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root(names.AttrID), req, resp) +} + +func (r *resourceClusterSnapshotCopy) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + r.SetTagsAll(ctx, req, resp) +} + +type resourceClusterSnapshotCopyData struct { + AllocatedStorage types.Int64 `tfsdk:"allocated_storage"` + CopyTags types.Bool `tfsdk:"copy_tags"` + DBClusterSnapshotARN types.String `tfsdk:"db_cluster_snapshot_arn"` + DestinationRegion types.String `tfsdk:"destination_region"` + Engine types.String `tfsdk:"engine"` + EngineVersion types.String `tfsdk:"engine_version"` + ID types.String `tfsdk:"id"` + KMSKeyID types.String `tfsdk:"kms_key_id"` + LicenseModel types.String `tfsdk:"license_model"` + PresignedURL types.String `tfsdk:"presigned_url"` + SharedAccounts types.Set `tfsdk:"shared_accounts"` + SnapshotType types.String `tfsdk:"snapshot_type"` + SourceDBClusterSnapshotIdentifier types.String `tfsdk:"source_db_cluster_snapshot_identifier"` + StorageEncrypted types.Bool `tfsdk:"storage_encrypted"` + StorageType types.String `tfsdk:"storage_type"` + Tags tftags.Map `tfsdk:"tags"` + TagsAll tftags.Map `tfsdk:"tags_all"` + TargetDBClusterSnapshotIdentifier types.String `tfsdk:"target_db_cluster_snapshot_identifier"` + VPCID types.String `tfsdk:"vpc_id"` + + Timeouts timeouts.Value `tfsdk:"timeouts"` +} diff --git a/internal/service/rds/cluster_snapshot_copy_test.go b/internal/service/rds/cluster_snapshot_copy_test.go new file mode 100644 index 00000000000..7e3a3518d99 --- /dev/null +++ b/internal/service/rds/cluster_snapshot_copy_test.go @@ -0,0 +1,400 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package rds_test + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/rds/types" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/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" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccRDSClusterSnapshotCopy_basic(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v types.DBClusterSnapshot + resourceName := "aws_rds_cluster_snapshot_copy.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.RDSServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckClusterSnapshotCopyDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccClusterSnapshotCopyConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckClusterSnapshotCopyExists(ctx, resourceName, &v), + resource.TestCheckResourceAttr(resourceName, "shared_accounts.#", "0"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "0"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccRDSClusterSnapshotCopy_share(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v types.DBClusterSnapshot + resourceName := "aws_rds_cluster_snapshot_copy.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.RDSServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckClusterSnapshotCopyDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccClusterSnapshotCopyConfig_share(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckClusterSnapshotCopyExists(ctx, resourceName, &v), + resource.TestCheckResourceAttr(resourceName, "shared_accounts.#", "1"), + resource.TestCheckTypeSetElemAttr(resourceName, "shared_accounts.*", "all"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccClusterSnapshotCopyConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckClusterSnapshotCopyExists(ctx, resourceName, &v), + resource.TestCheckResourceAttr(resourceName, "shared_accounts.#", "0"), + ), + }, + }, + }) +} + +func TestAccRDSClusterSnapshotCopy_tags(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v types.DBClusterSnapshot + resourceName := "aws_rds_cluster_snapshot_copy.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.RDSServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckClusterSnapshotCopyDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccClusterSnapshotCopyConfig_tags1(rName, acctest.CtKey1, acctest.CtValue1), + Check: resource.ComposeTestCheckFunc( + testAccCheckClusterSnapshotExists(ctx, resourceName, &v), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsKey1, acctest.CtValue1), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccClusterSnapshotCopyConfig_tags2(rName, acctest.CtKey1, acctest.CtValue1Updated, acctest.CtKey2, acctest.CtValue2), + Check: resource.ComposeTestCheckFunc( + testAccCheckClusterSnapshotExists(ctx, resourceName, &v), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "2"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsKey1, acctest.CtValue1Updated), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsKey2, acctest.CtValue2), + ), + }, + { + Config: testAccClusterSnapshotCopyConfig_tags1(rName, acctest.CtKey2, acctest.CtValue2), + Check: resource.ComposeTestCheckFunc( + testAccCheckClusterSnapshotExists(ctx, resourceName, &v), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsKey2, acctest.CtValue2), + ), + }, + }, + }) +} + +func TestAccRDSClusterSnapshotCopy_disappears(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v types.DBClusterSnapshot + resourceName := "aws_rds_cluster_snapshot_copy.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.RDSServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckClusterSnapshotCopyDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccClusterSnapshotCopyConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckClusterSnapshotCopyExists(ctx, resourceName, &v), + acctest.CheckFrameworkResourceDisappears(ctx, acctest.Provider, tfrds.ResourceClusterSnapshotCopy, resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccRDSClusterSnapshotCopy_destinationRegion(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v types.DBClusterSnapshot + resourceName := "aws_rds_cluster_snapshot_copy.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.RDSServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5FactoriesAlternate(ctx, t), + CheckDestroy: testAccCheckClusterSnapshotCopyDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccClusterSnapshotCopyConfig_destinationRegion(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckClusterSnapshotCopyExists(ctx, resourceName, &v), + resource.TestCheckResourceAttrSet(resourceName, names.AttrAllocatedStorage), + resource.TestCheckResourceAttr(resourceName, "destination_region", acctest.AlternateRegion()), + resource.TestCheckResourceAttr(resourceName, names.AttrStorageEncrypted, acctest.CtFalse), + resource.TestCheckResourceAttrSet(resourceName, names.AttrEngine), + resource.TestCheckResourceAttrSet(resourceName, names.AttrEngineVersion), + resource.TestCheckNoResourceAttr(resourceName, names.AttrKMSKeyID), + resource.TestCheckResourceAttr(resourceName, names.AttrStorageEncrypted, acctest.CtFalse), + resource.TestCheckResourceAttrSet(resourceName, "license_model"), + resource.TestCheckResourceAttrSet(resourceName, "snapshot_type"), + resource.TestCheckResourceAttrSet(resourceName, names.AttrVPCID), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"destination_region"}, + }, + }, + }) +} + +func TestAccRDSClusterSnapshotCopy_kmsKeyID(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v types.DBClusterSnapshot + resourceName := "aws_rds_cluster_snapshot_copy.test" + keyResourceName := "aws_kms_key.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, names.RDSServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckClusterSnapshotCopyDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccClusterSnapshotCopyConfig_kms(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckClusterSnapshotCopyExists(ctx, resourceName, &v), + resource.TestCheckResourceAttrPair(resourceName, names.AttrKMSKeyID, keyResourceName, names.AttrARN), + resource.TestCheckResourceAttr(resourceName, names.AttrStorageEncrypted, acctest.CtTrue), + ), + }, + }, + }) +} + +func testAccCheckClusterSnapshotCopyDestroy(ctx context.Context) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).RDSClient(ctx) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_rds_cluster_snapshot_copy" { + continue + } + + _, err := tfrds.FindDBClusterSnapshotByID(ctx, conn, rs.Primary.ID) + + if tfresource.NotFound(err) { + continue + } + + if err != nil { + return err + } + + return fmt.Errorf("RDS DB Cluster Snapshot Copy %s still exists", rs.Primary.ID) + } + + return nil + } +} + +func testAccCheckClusterSnapshotCopyExists(ctx context.Context, n string, v *types.DBClusterSnapshot) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).RDSClient(ctx) + + output, err := tfrds.FindDBClusterSnapshotByID(ctx, conn, rs.Primary.ID) + if err != nil { + return err + } + + *v = *output + + return nil + } +} + +func testAccClusterSnapshotCopyConfig_base(rName string) string { + return fmt.Sprintf(` +resource "aws_rds_cluster" "test" { + cluster_identifier = %[1]q + database_name = "test" + engine = "aurora-mysql" + master_username = "tfacctest" + master_password = "avoid-plaintext-passwords" + skip_final_snapshot = true +} + +resource "aws_db_cluster_snapshot" "test" { + db_cluster_identifier = aws_rds_cluster.test.cluster_identifier + db_cluster_snapshot_identifier = "%[1]s-source" +}`, rName) +} + +func testAccClusterSnapshotCopyConfig_encryptedBase(rName string) string { + return fmt.Sprintf(` +resource "aws_rds_cluster" "encrypted" { + cluster_identifier = %[1]q + database_name = "test" + engine = "aurora-mysql" + master_username = "tfacctest" + master_password = "avoid-plaintext-passwords" + skip_final_snapshot = true + storage_encrypted = true +} + +resource "aws_db_cluster_snapshot" "encrypted" { + db_cluster_identifier = aws_rds_cluster.encrypted.cluster_identifier + db_cluster_snapshot_identifier = "%[1]s-source" +}`, rName) +} + +func testAccClusterSnapshotCopyConfig_basic(rName string) string { + return acctest.ConfigCompose( + testAccClusterSnapshotCopyConfig_base(rName), + fmt.Sprintf(` +resource "aws_rds_cluster_snapshot_copy" "test" { + source_db_cluster_snapshot_identifier = aws_db_cluster_snapshot.test.db_cluster_snapshot_arn + target_db_cluster_snapshot_identifier = "%[1]s-target" +}`, rName)) +} + +func testAccClusterSnapshotCopyConfig_tags1(rName, tagKey, tagValue string) string { + return acctest.ConfigCompose( + testAccClusterSnapshotCopyConfig_base(rName), + fmt.Sprintf(` +resource "aws_rds_cluster_snapshot_copy" "test" { + source_db_cluster_snapshot_identifier = aws_db_cluster_snapshot.test.db_cluster_snapshot_arn + target_db_cluster_snapshot_identifier = "%[1]s-target" + + tags = { + %[2]q = %[3]q + } +}`, rName, tagKey, tagValue)) +} + +func testAccClusterSnapshotCopyConfig_tags2(rName, tagKey1, tagValue1, tagKey2, tagValue2 string) string { + return acctest.ConfigCompose( + testAccClusterSnapshotCopyConfig_base(rName), + fmt.Sprintf(` +resource "aws_rds_cluster_snapshot_copy" "test" { + source_db_cluster_snapshot_identifier = aws_db_cluster_snapshot.test.db_cluster_snapshot_arn + target_db_cluster_snapshot_identifier = "%[1]s-target" + + tags = { + %[2]q = %[3]q + %[4]q = %[5]q + } +}`, rName, tagKey1, tagValue1, tagKey2, tagValue2)) +} + +func testAccClusterSnapshotCopyConfig_share(rName string) string { + return acctest.ConfigCompose( + testAccClusterSnapshotCopyConfig_base(rName), + fmt.Sprintf(` +resource "aws_rds_cluster_snapshot_copy" "test" { + source_db_cluster_snapshot_identifier = aws_db_cluster_snapshot.test.db_cluster_snapshot_arn + target_db_cluster_snapshot_identifier = "%[1]s-target" + shared_accounts = ["all"] +} +`, rName)) +} + +func testAccClusterSnapshotCopyConfig_destinationRegion(rName string) string { + return acctest.ConfigCompose( + testAccClusterSnapshotCopyConfig_base(rName), + fmt.Sprintf(` +resource "aws_rds_cluster_snapshot_copy" "test" { + source_db_cluster_snapshot_identifier = aws_db_cluster_snapshot.test.db_cluster_snapshot_arn + target_db_cluster_snapshot_identifier = "%[1]s-target" + destination_region = %[2]q +}`, rName, acctest.AlternateRegion())) +} + +func testAccClusterSnapshotCopyConfig_kms(rName string) string { + return acctest.ConfigCompose( + testAccClusterSnapshotCopyConfig_encryptedBase(rName), + fmt.Sprintf(` +resource "aws_kms_key" "test" { + description = "test" +} + +resource "aws_rds_cluster_snapshot_copy" "test" { + source_db_cluster_snapshot_identifier = aws_db_cluster_snapshot.encrypted.db_cluster_snapshot_arn + target_db_cluster_snapshot_identifier = "%[1]s-target" + kms_key_id = aws_kms_key.test.arn +}`, rName)) +} diff --git a/internal/service/rds/consts.go b/internal/service/rds/consts.go index d34aabeca54..9fdd7c5e0ea 100644 --- a/internal/service/rds/consts.go +++ b/internal/service/rds/consts.go @@ -39,6 +39,7 @@ const ( const ( clusterSnapshotStatusAvailable = "available" clusterSnapshotStatusCreating = "creating" + clusterSnapshotStatusCopying = "copying" ) const ( diff --git a/internal/service/rds/exports_test.go b/internal/service/rds/exports_test.go index 044d60e69f4..b5d2678a457 100644 --- a/internal/service/rds/exports_test.go +++ b/internal/service/rds/exports_test.go @@ -13,6 +13,7 @@ var ( ResourceClusterParameterGroup = resourceClusterParameterGroup ResourceClusterRoleAssociation = resourceClusterRoleAssociation ResourceClusterSnapshot = resourceClusterSnapshot + ResourceClusterSnapshotCopy = newResourceClusterSnapshotCopy ResourceCustomDBEngineVersion = resourceCustomDBEngineVersion ResourceEventSubscription = resourceEventSubscription ResourceGlobalCluster = resourceGlobalCluster diff --git a/internal/service/rds/service_package_gen.go b/internal/service/rds/service_package_gen.go index d26523875b9..da1fd7963b7 100644 --- a/internal/service/rds/service_package_gen.go +++ b/internal/service/rds/service_package_gen.go @@ -32,6 +32,13 @@ func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.Servic IdentifierAttribute: names.AttrARN, }, }, + { + Factory: newResourceClusterSnapshotCopy, + Name: "Cluster Snapshot Copy", + Tags: &types.ServicePackageResourceTags{ + IdentifierAttribute: "db_cluster_snapshot_arn", + }, + }, { Factory: newResourceExportTask, }, diff --git a/website/docs/r/rds_cluster_snapshot_copy.html.markdown b/website/docs/r/rds_cluster_snapshot_copy.html.markdown new file mode 100644 index 00000000000..0f70350deb4 --- /dev/null +++ b/website/docs/r/rds_cluster_snapshot_copy.html.markdown @@ -0,0 +1,92 @@ +--- +subcategory: "RDS (Relational Database)" +layout: "aws" +page_title: "AWS: aws_rds_cluster_snapshot_copy" +description: |- + Manages an RDS database cluster snapshot copy. +--- + +# Resource: aws_rds_cluster_snapshot_copy + +Manages an RDS database cluster snapshot copy. For managing RDS database instance snapshot copies, see the [`aws_db_snapshot_copy` resource](/docs/providers/aws/r/db_snapshot_copy.html). + +## Example Usage + +```terraform +resource "aws_rds_cluster" "example" { + cluster_identifier = "aurora-cluster-demo" + database_name = "test" + engine = "aurora-mysql" + master_username = "tfacctest" + master_password = "avoid-plaintext-passwords" + skip_final_snapshot = true +} + +resource "aws_db_cluster_snapshot" "example" { + db_cluster_identifier = aws_rds_cluster.example.cluster_identifier + db_cluster_snapshot_identifier = "example" +} + +resource "aws_rds_cluster_snapshot_copy" "example" { + source_db_cluster_snapshot_identifier = aws_db_cluster_snapshot.example.db_cluster_snapshot_arn + target_db_cluster_snapshot_identifier = "example-copy" +} +``` + +## Argument Reference + +The following arguments are required: + +* `source_db_cluster_snapshot_identifier` - (Required) Identifier of the source snapshot. +* `target_db_cluster_snapshot_identifier` - (Required) Identifier for the snapshot. + +The following arguments are optional: + +* `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. +* `presigned_url` - (Optional) URL that contains a Signature Version 4 signed request. +* `shared_accounts` - (Optional) List of AWS Account IDs to share the snapshot with. Use `all` to make the snapshot public. +* `tags` - (Optional) Key-value map of resource tags. If configured with a provider [`default_tags` configuration block](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block) present, tags with matching keys will overwrite those defined at the provider-level. + +## Attribute Reference + +This resource exports the following attributes in addition to the arguments above: + +* `allocated_storage` - Specifies the allocated storage size in gigabytes (GB). +* `availability_zones` - Specifies the the Availability Zones the DB cluster was located in at the time of the DB snapshot. +* `db_cluster_snapshot_arn` - The Amazon Resource Name (ARN) for the DB cluster snapshot. +* `engine` - Specifies the name of the database engine. +* `engine_version` - Specifies the version of the database engine. +* `id` - Cluster snapshot identifier. +* `kms_key_id` - ARN for the KMS encryption key. +* `license_model` - License model information for the restored DB instance. +* `shared_accounts` - (Optional) List of AWS Account IDs to share the snapshot with. Use `all` to make the snapshot public. +* `source_db_cluster_snapshot_identifier` - DB snapshot ARN that the DB cluster snapshot was copied from. It only has value in case of cross customer or cross region copy. +* `storage_encrypted` - Specifies whether the DB cluster snapshot is encrypted. +* `storage_type` - Specifies the storage type associated with DB cluster snapshot. +* `tags_all` - A map of tags assigned to the resource, including those inherited from the provider [`default_tags` configuration block](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#default_tags-configuration-block). +* `vpc_id` - Provides the VPC ID associated with the DB cluster snapshot. + +## Timeouts + +[Configuration options](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts): + +- `create` - (Default `20m`) + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import `aws_rds_cluster_snapshot_copy` using the snapshot identifier. For example: + +```terraform +import { + to = aws_rds_cluster_snapshot_copy.example + id = "my-snapshot" +} +``` + +Using `terraform import`, import `aws_rds_cluster_snapshot_copy` using the `id`. For example: + +```console +% terraform import aws_rds_cluster_snapshot_copy.example my-snapshot +```