diff --git a/.changelog/35211.txt b/.changelog/35211.txt new file mode 100644 index 00000000000..146dfd088bb --- /dev/null +++ b/.changelog/35211.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_ebs_fast_snapshot_restore +``` diff --git a/internal/service/ec2/ebs_fast_snapshot_restore.go b/internal/service/ec2/ebs_fast_snapshot_restore.go new file mode 100644 index 00000000000..570c3866692 --- /dev/null +++ b/internal/service/ec2/ebs_fast_snapshot_restore.go @@ -0,0 +1,306 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ec2 + +import ( + "context" + "errors" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ec2" + awstypes "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "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/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-provider-aws/internal/create" + "github.com/hashicorp/terraform-provider-aws/internal/enum" + 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" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @FrameworkResource(name="EBS Fast Snapshot Restore") +func newResourceEBSFastSnapshotRestore(_ context.Context) (resource.ResourceWithConfigure, error) { + r := &resourceEBSFastSnapshotRestore{} + r.SetDefaultCreateTimeout(10 * time.Minute) + r.SetDefaultDeleteTimeout(10 * time.Minute) + + return r, nil +} + +const ( + ResNameEBSFastSnapshotRestore = "EBS Fast Snapshot Restore" + + ebsFastSnapshotRestoreIDPartCount = 2 +) + +type resourceEBSFastSnapshotRestore struct { + framework.ResourceWithConfigure + framework.WithTimeouts +} + +func (r *resourceEBSFastSnapshotRestore) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = "aws_ebs_fast_snapshot_restore" +} + +func (r *resourceEBSFastSnapshotRestore) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "availability_zone": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "id": framework.IDAttribute(), + "snapshot_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "state": schema.StringAttribute{ + Computed: true, + }, + }, + Blocks: map[string]schema.Block{ + "timeouts": timeouts.Block(ctx, timeouts.Opts{ + Create: true, + Delete: true, + }), + }, + } +} + +func (r *resourceEBSFastSnapshotRestore) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + conn := r.Meta().EC2Client(ctx) + + var plan resourceEBSFastSnapshotRestoreData + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + availabilityZone := plan.AvailabilityZone.ValueString() + snapshotID := plan.SnapshotID.ValueString() + + idParts := []string{ + availabilityZone, + snapshotID, + } + id, err := intflex.FlattenResourceId(idParts, ebsFastSnapshotRestoreIDPartCount, false) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.EC2, create.ErrActionCreating, ResNameEBSFastSnapshotRestore, plan.SnapshotID.String(), err), + err.Error(), + ) + return + } + plan.ID = types.StringValue(id) + + in := &ec2.EnableFastSnapshotRestoresInput{ + AvailabilityZones: []string{availabilityZone}, + SourceSnapshotIds: []string{snapshotID}, + } + + out, err := conn.EnableFastSnapshotRestores(ctx, in) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.EC2, create.ErrActionCreating, ResNameEBSFastSnapshotRestore, plan.SnapshotID.String(), err), + err.Error(), + ) + return + } + if out == nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.EC2, create.ErrActionCreating, ResNameEBSFastSnapshotRestore, plan.SnapshotID.String(), nil), + errors.New("empty output").Error(), + ) + return + } + if len(out.Unsuccessful) > 0 || len(out.Successful) != 1 { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.EC2, create.ErrActionCreating, ResNameEBSFastSnapshotRestore, plan.SnapshotID.String(), nil), + errors.New("enable fast snapshot restore was unsuccessful").Error(), + ) + return + } + + createTimeout := r.CreateTimeout(ctx, plan.Timeouts) + waitOut, err := waitEBSFastSnapshotRestoreCreated(ctx, conn, plan.ID.ValueString(), createTimeout) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.EC2, create.ErrActionWaitingForCreation, ResNameEBSFastSnapshotRestore, plan.AvailabilityZone.String(), err), + err.Error(), + ) + return + } + + plan.State = flex.StringValueToFramework(ctx, waitOut.State) + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *resourceEBSFastSnapshotRestore) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + conn := r.Meta().EC2Client(ctx) + + var state resourceEBSFastSnapshotRestoreData + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := findEBSFastSnapshotRestoreByID(ctx, conn, state.ID.ValueString()) + if errors.Is(err, tfresource.ErrEmptyResult) || out.State == awstypes.FastSnapshotRestoreStateCodeDisabled { + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.EC2, create.ErrActionSetting, ResNameEBSFastSnapshotRestore, state.ID.String(), err), + err.Error(), + ) + return + } + + state.AvailabilityZone = flex.StringToFramework(ctx, out.AvailabilityZone) + state.SnapshotID = flex.StringToFramework(ctx, out.SnapshotId) + state.State = flex.StringValueToFramework(ctx, out.State) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *resourceEBSFastSnapshotRestore) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // Update is a no-op +} + +func (r *resourceEBSFastSnapshotRestore) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + conn := r.Meta().EC2Client(ctx) + + var state resourceEBSFastSnapshotRestoreData + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + in := &ec2.DisableFastSnapshotRestoresInput{ + AvailabilityZones: []string{state.AvailabilityZone.ValueString()}, + SourceSnapshotIds: []string{state.SnapshotID.ValueString()}, + } + + _, err := conn.DisableFastSnapshotRestores(ctx, in) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.EC2, create.ErrActionDeleting, ResNameEBSFastSnapshotRestore, state.ID.String(), err), + err.Error(), + ) + return + } + + deleteTimeout := r.DeleteTimeout(ctx, state.Timeouts) + _, err = waitEBSFastSnapshotRestoreDeleted(ctx, conn, state.ID.ValueString(), deleteTimeout) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.EC2, create.ErrActionWaitingForDeletion, ResNameEBSFastSnapshotRestore, state.ID.String(), err), + err.Error(), + ) + return + } +} + +func (r *resourceEBSFastSnapshotRestore) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} + +func waitEBSFastSnapshotRestoreCreated(ctx context.Context, conn *ec2.Client, id string, timeout time.Duration) (*awstypes.DescribeFastSnapshotRestoreSuccessItem, error) { + stateConf := &retry.StateChangeConf{ + Pending: enum.Slice(awstypes.FastSnapshotRestoreStateCodeEnabling, awstypes.FastSnapshotRestoreStateCodeOptimizing), + Target: enum.Slice(awstypes.FastSnapshotRestoreStateCodeEnabled), + Refresh: statusEBSFastSnapshotRestore(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if out, ok := outputRaw.(*awstypes.DescribeFastSnapshotRestoreSuccessItem); ok { + return out, err + } + + return nil, err +} + +func waitEBSFastSnapshotRestoreDeleted(ctx context.Context, conn *ec2.Client, id string, timeout time.Duration) (*awstypes.DescribeFastSnapshotRestoreSuccessItem, error) { + stateConf := &retry.StateChangeConf{ + Pending: enum.Slice(awstypes.FastSnapshotRestoreStateCodeDisabling, awstypes.FastSnapshotRestoreStateCodeOptimizing, awstypes.FastSnapshotRestoreStateCodeEnabled), + Target: enum.Slice(awstypes.FastSnapshotRestoreStateCodeDisabled), + Refresh: statusEBSFastSnapshotRestore(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if out, ok := outputRaw.(*awstypes.DescribeFastSnapshotRestoreSuccessItem); ok { + return out, err + } + + return nil, err +} + +func statusEBSFastSnapshotRestore(ctx context.Context, conn *ec2.Client, id string) retry.StateRefreshFunc { + return func() (interface{}, string, error) { + out, err := findEBSFastSnapshotRestoreByID(ctx, conn, id) + if err != nil { + return nil, "", err + } + + return out, string(out.State), nil + } +} + +func findEBSFastSnapshotRestoreByID(ctx context.Context, conn *ec2.Client, id string) (*awstypes.DescribeFastSnapshotRestoreSuccessItem, error) { + parts, err := intflex.ExpandResourceId(id, ebsFastSnapshotRestoreIDPartCount, false) + if err != nil { + return nil, err + } + + in := &ec2.DescribeFastSnapshotRestoresInput{ + Filters: []awstypes.Filter{ + { + Name: aws.String("availability-zone"), + Values: []string{parts[0]}, + }, + { + Name: aws.String("snapshot-id"), + Values: []string{parts[1]}, + }, + }, + } + + out, err := conn.DescribeFastSnapshotRestores(ctx, in) + if err != nil { + return nil, err + } + + if out == nil || len(out.FastSnapshotRestores) == 0 { + return nil, tfresource.NewEmptyResultError(in) + } + if len(out.FastSnapshotRestores) != 1 { + return nil, tfresource.NewTooManyResultsError(len(out.FastSnapshotRestores), in) + } + + return &out.FastSnapshotRestores[0], nil +} + +type resourceEBSFastSnapshotRestoreData struct { + AvailabilityZone types.String `tfsdk:"availability_zone"` + ID types.String `tfsdk:"id"` + SnapshotID types.String `tfsdk:"snapshot_id"` + State types.String `tfsdk:"state"` + Timeouts timeouts.Value `tfsdk:"timeouts"` +} diff --git a/internal/service/ec2/ebs_fast_snapshot_restore_test.go b/internal/service/ec2/ebs_fast_snapshot_restore_test.go new file mode 100644 index 00000000000..b340bd5da29 --- /dev/null +++ b/internal/service/ec2/ebs_fast_snapshot_restore_test.go @@ -0,0 +1,182 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ec2_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/ec2/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" + "github.com/hashicorp/terraform-provider-aws/internal/create" + tfec2 "github.com/hashicorp/terraform-provider-aws/internal/service/ec2" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccEC2EBSFastSnapshotRestore_basic(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ebs_fast_snapshot_restore.test" + snapshotResourceName := "aws_ebs_snapshot.test" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.EC2) + }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckEBSFastSnapshotRestoreDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccEBSFastSnapshotRestoreConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckEBSFastSnapshotRestoreExists(ctx, resourceName), + resource.TestCheckResourceAttrPair(resourceName, "snapshot_id", snapshotResourceName, "id"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccEC2EBSFastSnapshotRestore_disappears(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ebs_fast_snapshot_restore.test" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.EC2) + }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckEBSFastSnapshotRestoreDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccEBSFastSnapshotRestoreConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckEBSFastSnapshotRestoreExists(ctx, resourceName), + acctest.CheckFrameworkResourceDisappears(ctx, acctest.Provider, tfec2.ResourceEBSFastSnapshotRestore, resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccEC2EBSFastSnapshotRestore_disappearsSnapshot(t *testing.T) { + ctx := acctest.Context(t) + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_ebs_fast_snapshot_restore.test" + snapshotResourceName := "aws_ebs_snapshot.test" + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.EC2) + }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckEBSFastSnapshotRestoreDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccEBSFastSnapshotRestoreConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckEBSFastSnapshotRestoreExists(ctx, resourceName), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfec2.ResourceEBSSnapshot(), snapshotResourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func testAccCheckEBSFastSnapshotRestoreDestroy(ctx context.Context) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).EC2Client(ctx) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_ebs_fast_snapshot_restore" { + continue + } + + out, err := tfec2.FindEBSFastSnapshotRestoreByID(ctx, conn, rs.Primary.ID) + if errors.Is(err, tfresource.ErrEmptyResult) || out.State == types.FastSnapshotRestoreStateCodeDisabled { + return nil + } + if err != nil { + return create.Error(names.EC2, create.ErrActionCheckingDestroyed, tfec2.ResNameEBSFastSnapshotRestore, rs.Primary.ID, err) + } + + return create.Error(names.EC2, create.ErrActionCheckingDestroyed, tfec2.ResNameEBSFastSnapshotRestore, rs.Primary.ID, errors.New("not destroyed")) + } + + return nil + } +} + +func testAccCheckEBSFastSnapshotRestoreExists(ctx context.Context, name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return create.Error(names.EC2, create.ErrActionCheckingExistence, tfec2.ResNameEBSFastSnapshotRestore, name, errors.New("not found")) + } + + if rs.Primary.ID == "" { + return create.Error(names.EC2, create.ErrActionCheckingExistence, tfec2.ResNameEBSFastSnapshotRestore, name, errors.New("not set")) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).EC2Client(ctx) + + _, err := tfec2.FindEBSFastSnapshotRestoreByID(ctx, conn, rs.Primary.ID) + if err != nil { + return create.Error(names.EC2, create.ErrActionCheckingExistence, tfec2.ResNameEBSFastSnapshotRestore, rs.Primary.ID, err) + } + + return nil + } +} + +func testAccEBSFastSnapshotRestoreBaseConfig(rName string) string { + return acctest.ConfigCompose( + acctest.ConfigAvailableAZsNoOptIn(), + fmt.Sprintf(` +resource "aws_ebs_volume" "test" { + availability_zone = data.aws_availability_zones.available.names[0] + size = 1 + + tags = { + Name = %[1]q + } +} + +resource "aws_ebs_snapshot" "test" { + volume_id = aws_ebs_volume.test.id +} +`, rName)) +} + +func testAccEBSFastSnapshotRestoreConfig_basic(rName string) string { + return acctest.ConfigCompose( + testAccEBSFastSnapshotRestoreBaseConfig(rName), + ` +resource "aws_ebs_fast_snapshot_restore" "test" { + availability_zone = data.aws_availability_zones.available.names[0] + snapshot_id = aws_ebs_snapshot.test.id +} +`) +} diff --git a/internal/service/ec2/exports_test.go b/internal/service/ec2/exports_test.go index ba1a6538699..fcc1465aa4e 100644 --- a/internal/service/ec2/exports_test.go +++ b/internal/service/ec2/exports_test.go @@ -8,6 +8,9 @@ var ( ResourceInstanceConnectEndpoint = newResourceInstanceConnectEndpoint ResourceSecurityGroupEgressRule = newResourceSecurityGroupEgressRule ResourceSecurityGroupIngressRule = newResourceSecurityGroupIngressRule + ResourceEBSFastSnapshotRestore = newResourceEBSFastSnapshotRestore + + FindEBSFastSnapshotRestoreByID = findEBSFastSnapshotRestoreByID UpdateTags = updateTags UpdateTagsV2 = updateTagsV2 diff --git a/internal/service/ec2/service_package_gen.go b/internal/service/ec2/service_package_gen.go index e7ecab4f6ef..2a6ddfcdb2b 100644 --- a/internal/service/ec2/service_package_gen.go +++ b/internal/service/ec2/service_package_gen.go @@ -30,6 +30,10 @@ func (p *servicePackage) FrameworkDataSources(ctx context.Context) []*types.Serv func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.ServicePackageFrameworkResource { return []*types.ServicePackageFrameworkResource{ + { + Factory: newResourceEBSFastSnapshotRestore, + Name: "EBS Fast Snapshot Restore", + }, { Factory: newResourceInstanceConnectEndpoint, Name: "Instance Connect Endpoint", diff --git a/website/docs/r/ebs_fast_snapshot_restore.html.markdown b/website/docs/r/ebs_fast_snapshot_restore.html.markdown new file mode 100644 index 00000000000..164ff3fa07a --- /dev/null +++ b/website/docs/r/ebs_fast_snapshot_restore.html.markdown @@ -0,0 +1,60 @@ +--- +subcategory: "EBS (EC2)" +layout: "aws" +page_title: "AWS: aws_ebs_fast_snapshot_restore" +description: |- + Terraform resource for managing an EBS (Elastic Block Storage) Fast Snapshot Restore. +--- + +# Resource: aws_ebs_fast_snapshot_restore + +Terraform resource for managing an EBS (Elastic Block Storage) Fast Snapshot Restore. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_ebs_fast_snapshot_restore" "example" { + availability_zone = "us-west-2a" + snapshot_id = aws_ebs_snapshot.example.id +} +``` + +## Argument Reference + +The following arguments are required: + +* `availability_zone` - (Required) Availability zone in which to enable fast snapshot restores. +* `snapshot_id` - (Required) ID of the snapshot. + +## Attribute Reference + +This resource exports the following attributes in addition to the arguments above: + +* `id` - A comma-delimited string concatenating `availability_zone` and `snapshot_id`. +* `state` - State of fast snapshot restores. Valid values are `enabling`, `optimizing`, `enabled`, `disabling`, `disabled`. + +## Timeouts + +[Configuration options](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts): + +* `create` - (Default `10m`) +* `delete` - (Default `10m`) + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import EC2 (Elastic Compute Cloud) EBS Fast Snapshot Restore using the `example_id_arg`. For example: + +```terraform +import { + to = aws_ebs_fast_snapshot_restore.example + id = "us-west-2a,snap-abcdef123456" +} +``` + +Using `terraform import`, import EC2 (Elastic Compute Cloud) EBS Fast Snapshot Restore using the `id`. For example: + +```console +% terraform import aws_ebs_fast_snapshot_restore.example us-west-2a,snap-abcdef123456 +```