diff --git a/.changelog/43700.txt b/.changelog/43700.txt new file mode 100644 index 000000000000..55fe35d8e024 --- /dev/null +++ b/.changelog/43700.txt @@ -0,0 +1,3 @@ +```release-note:new-action +aws_ec2_stop_instance +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 026dbb81080e..ae13c0637878 100644 --- a/go.mod +++ b/go.mod @@ -289,7 +289,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 - github.com/hashicorp/hcl/v2 v2.23.0 + github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/terraform-json v0.27.2 github.com/hashicorp/terraform-plugin-framework v1.16.0 github.com/hashicorp/terraform-plugin-framework-jsontypes v0.2.0 @@ -300,7 +300,7 @@ require ( github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-mux v0.21.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 - github.com/hashicorp/terraform-plugin-testing v1.13.3 + github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247 github.com/jaswdr/faker/v2 v2.8.0 github.com/jmespath/go-jmespath v0.4.0 github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 @@ -349,7 +349,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/hc-install v0.9.2 // indirect github.com/hashicorp/logutils v1.0.0 // indirect - github.com/hashicorp/terraform-exec v0.23.0 // indirect + github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2 // indirect github.com/hashicorp/terraform-registry-address v0.4.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect @@ -368,7 +368,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect - github.com/zclconf/go-cty v1.16.4 // indirect + github.com/zclconf/go-cty v1.17.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/github.com/aws/aws-sdk-go-v2/otelaws v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/go.sum b/go.sum index 45428faddc78..fa03a69bd1c7 100644 --- a/go.sum +++ b/go.sum @@ -661,12 +661,12 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= -github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= -github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE= +github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/terraform-exec v0.23.0 h1:MUiBM1s0CNlRFsCLJuM5wXZrzA3MnPYEsiXmzATMW/I= -github.com/hashicorp/terraform-exec v0.23.0/go.mod h1:mA+qnx1R8eePycfwKkCRk3Wy65mwInvlpAeOwmA7vlY= +github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2 h1:90fcAqw0Qmv4vY7zL4jEKgKarHmOnNN6SjTY68eLKGA= +github.com/hashicorp/terraform-exec v0.23.1-0.20250717072919-061a850a52d2/go.mod h1:8D3RLLpzAZdhT9jvALYz1KHyGU4OvI73I1o0+01QJxA= github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= github.com/hashicorp/terraform-plugin-framework v1.16.0 h1:tP0f+yJg0Z672e7levixDe5EpWwrTrNryPM9kDMYIpE= @@ -685,8 +685,8 @@ github.com/hashicorp/terraform-plugin-mux v0.21.0 h1:QsEYnzSD2c3zT8zUrUGqaFGhV/Z github.com/hashicorp/terraform-plugin-mux v0.21.0/go.mod h1:Qpt8+6AD7NmL0DS7ASkN0EXpDQ2J/FnnIgeUr1tzr5A= github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 h1:NFPMacTrY/IdcIcnUB+7hsore1ZaRWU9cnB6jFoBnIM= github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0/go.mod h1:QYmYnLfsosrxjCnGY1p9c7Zj6n9thnEE+7RObeYs3fA= -github.com/hashicorp/terraform-plugin-testing v1.13.3 h1:QLi/khB8Z0a5L54AfPrHukFpnwsGL8cwwswj4RZduCo= -github.com/hashicorp/terraform-plugin-testing v1.13.3/go.mod h1:WHQ9FDdiLoneey2/QHpGM/6SAYf4A7AZazVg7230pLE= +github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247 h1:lA6ofPwmCXAX7J7kVP9t/WMU5+eA4e9YvJUiRLPdENw= +github.com/hashicorp/terraform-plugin-testing v1.13.3-0.20250909115916-1a2eeae85247/go.mod h1:4r/7cxl1mpskfALcq58Iyu5aPiTSco8SVrKkcLyP5g4= github.com/hashicorp/terraform-registry-address v0.4.0 h1:S1yCGomj30Sao4l5BMPjTGZmCNzuv7/GDTDX99E9gTk= github.com/hashicorp/terraform-registry-address v0.4.0/go.mod h1:LRS1Ay0+mAiRkUyltGT+UHWkIqTFvigGn/LbMshfflE= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= @@ -790,8 +790,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1: github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zclconf/go-cty v1.16.4 h1:QGXaag7/7dCzb+odlGrgr+YmYZFaOCMW6DEpS+UD1eE= -github.com/zclconf/go-cty v1.16.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= +github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= +github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= diff --git a/internal/service/ec2/ec2_stop_instance_action.go b/internal/service/ec2/ec2_stop_instance_action.go new file mode 100644 index 000000000000..a9e9a84146a8 --- /dev/null +++ b/internal/service/ec2/ec2_stop_instance_action.go @@ -0,0 +1,269 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ec2 + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/YakDriver/regexache" + "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/aws-sdk-go-base/v2/tfawserr" + "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/action" + "github.com/hashicorp/terraform-plugin-framework/action/schema" + "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/framework" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @Action(aws_ec2_stop_instance, name="Stop Instance") +func newStopInstanceAction(_ context.Context) (action.ActionWithConfigure, error) { + return &stopInstanceAction{}, nil +} + +var ( + _ action.Action = (*stopInstanceAction)(nil) +) + +type stopInstanceAction struct { + framework.ActionWithModel[stopInstanceModel] +} + +type stopInstanceModel struct { + framework.WithRegionModel + InstanceID types.String `tfsdk:"instance_id"` + Force types.Bool `tfsdk:"force"` + Timeout types.Int64 `tfsdk:"timeout"` +} + +func (a *stopInstanceAction) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Stops an EC2 instance. This action will gracefully stop the instance and wait for it to reach the stopped state.", + Attributes: map[string]schema.Attribute{ + names.AttrInstanceID: schema.StringAttribute{ + Description: "The ID of the EC2 instance to stop", + Required: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexache.MustCompile(`^i-[0-9a-f]{8,17}$`), + "must be a valid EC2 instance ID (e.g., i-1234567890abcdef0)", + ), + }, + }, + "force": schema.BoolAttribute{ + Description: "Forces the instance to stop. The instance does not have an opportunity to flush file system caches or file system metadata. If you use this option, you must perform file system check and repair procedures. This option is not recommended for Windows instances.", + Optional: true, + }, + names.AttrTimeout: schema.Int64Attribute{ + Description: "Timeout in seconds to wait for the instance to stop (default: 600)", + Optional: true, + Validators: []validator.Int64{ + int64validator.AtLeast(30), + int64validator.AtMost(3600), + }, + }, + }, + } +} + +func (a *stopInstanceAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) { + var config stopInstanceModel + + // Parse configuration + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + // Get AWS client + conn := a.Meta().EC2Client(ctx) + + instanceID := config.InstanceID.ValueString() + force := config.Force.ValueBool() + + // Set default timeout if not provided + timeout := 600 * time.Second + if !config.Timeout.IsNull() { + timeout = time.Duration(config.Timeout.ValueInt64()) * time.Second + } + + tflog.Info(ctx, "Starting EC2 stop instance action", map[string]any{ + names.AttrInstanceID: instanceID, + "force": force, + names.AttrTimeout: timeout.String(), + }) + + // Send initial progress update + resp.SendProgress(action.InvokeProgressEvent{ + Message: fmt.Sprintf("Starting stop operation for EC2 instance %s...", instanceID), + }) + + // Check current instance state first + instance, err := findInstanceByID(ctx, conn, instanceID) + if err != nil { + if tfawserr.ErrCodeEquals(err, errCodeInvalidInstanceIDNotFound) { + resp.Diagnostics.AddError( + "Instance Not Found", + fmt.Sprintf("EC2 instance %s was not found", instanceID), + ) + return + } + resp.Diagnostics.AddError( + "Failed to Describe Instance", + fmt.Sprintf("Could not describe EC2 instance %s: %s", instanceID, err), + ) + return + } + + currentState := string(instance.State.Name) + tflog.Debug(ctx, "Current instance state", map[string]any{ + names.AttrInstanceID: instanceID, + names.AttrState: currentState, + }) + + // Check if instance is already stopped + if instance.State.Name == awstypes.InstanceStateNameStopped { + resp.SendProgress(action.InvokeProgressEvent{ + Message: fmt.Sprintf("EC2 instance %s is already stopped", instanceID), + }) + tflog.Info(ctx, "Instance already stopped", map[string]any{ + names.AttrInstanceID: instanceID, + }) + return + } + + // Check if instance is in a state that can be stopped + if !canStopInstance(instance.State.Name) { + resp.Diagnostics.AddError( + "Cannot Stop Instance", + fmt.Sprintf("EC2 instance %s is in state '%s' and cannot be stopped. Instance must be in 'running' or 'stopping' state.", instanceID, currentState), + ) + return + } + + // If instance is already stopping, just wait for it + if instance.State.Name == awstypes.InstanceStateNameStopping { + resp.SendProgress(action.InvokeProgressEvent{ + Message: fmt.Sprintf("EC2 instance %s is already stopping, waiting for completion...", instanceID), + }) + } else { + // Stop the instance + resp.SendProgress(action.InvokeProgressEvent{ + Message: fmt.Sprintf("Sending stop command to EC2 instance %s...", instanceID), + }) + + input := ec2.StopInstancesInput{ + Force: aws.Bool(force), + InstanceIds: []string{instanceID}, + } + + _, err = conn.StopInstances(ctx, &input) + if err != nil { + resp.Diagnostics.AddError( + "Failed to Stop Instance", + fmt.Sprintf("Could not stop EC2 instance %s: %s", instanceID, err), + ) + return + } + + resp.SendProgress(action.InvokeProgressEvent{ + Message: fmt.Sprintf("Stop command sent to EC2 instance %s, waiting for instance to stop...", instanceID), + }) + } + + // Wait for instance to stop with periodic progress updates + err = a.waitForInstanceStopped(ctx, conn, instanceID, timeout, resp) + if err != nil { + resp.Diagnostics.AddError( + "Timeout Waiting for Instance to Stop", + fmt.Sprintf("EC2 instance %s did not stop within %s: %s", instanceID, timeout, err), + ) + return + } + + // Final success message + resp.SendProgress(action.InvokeProgressEvent{ + Message: fmt.Sprintf("EC2 instance %s has been successfully stopped", instanceID), + }) + + tflog.Info(ctx, "EC2 stop instance action completed successfully", map[string]any{ + names.AttrInstanceID: instanceID, + }) +} + +// canStopInstance checks if an instance can be stopped based on its current state +func canStopInstance(state awstypes.InstanceStateName) bool { + switch state { + case awstypes.InstanceStateNameRunning, awstypes.InstanceStateNameStopping: + return true + default: + return false + } +} + +// waitForInstanceStopped waits for an instance to reach the stopped state with progress updates +func (a *stopInstanceAction) waitForInstanceStopped(ctx context.Context, conn *ec2.Client, instanceID string, timeout time.Duration, resp *action.InvokeResponse) error { + const ( + pollInterval = 10 * time.Second + progressInterval = 30 * time.Second + ) + + deadline := time.Now().Add(timeout) + lastProgressUpdate := time.Now() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Check if we've exceeded the timeout + if time.Now().After(deadline) { + return fmt.Errorf("timeout after %s", timeout) + } + + // Get current instance state + instance, err := findInstanceByID(ctx, conn, instanceID) + if err != nil { + return fmt.Errorf("describing instance: %w", err) + } + + currentState := string(instance.State.Name) + + // Send progress update every 30 seconds + if time.Since(lastProgressUpdate) >= progressInterval { + resp.SendProgress(action.InvokeProgressEvent{ + Message: fmt.Sprintf("EC2 instance %s is currently in state '%s', continuing to wait for 'stopped'...", instanceID, currentState), + }) + lastProgressUpdate = time.Now() + } + + // Check if we've reached the target state + if instance.State.Name == awstypes.InstanceStateNameStopped { + return nil + } + + // Check if we're in an unexpected state + validStates := []awstypes.InstanceStateName{ + awstypes.InstanceStateNameRunning, + awstypes.InstanceStateNameStopping, + awstypes.InstanceStateNameShuttingDown, + } + if !slices.Contains(validStates, instance.State.Name) { + return fmt.Errorf("instance entered unexpected state: %s", currentState) + } + + // Wait before next poll + time.Sleep(pollInterval) + } +} diff --git a/internal/service/ec2/ec2_stop_instance_action_test.go b/internal/service/ec2/ec2_stop_instance_action_test.go new file mode 100644 index 000000000000..b1d345f7d1be --- /dev/null +++ b/internal/service/ec2/ec2_stop_instance_action_test.go @@ -0,0 +1,338 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package ec2_test + +import ( + "context" + "fmt" + "testing" + + awstypes "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfec2 "github.com/hashicorp/terraform-provider-aws/internal/service/ec2" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccEC2StopInstanceAction_basic(t *testing.T) { + ctx := acctest.Context(t) + var v awstypes.Instance + resourceName := "aws_instance.test" + rName := acctest.RandomWithPrefix(t, acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.EC2) + }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + Steps: []resource.TestStep{ + { + Config: testAccStopInstanceActionConfig_force(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckInstanceExistsLocal(ctx, resourceName, &v), + testAccCheckInstanceState(ctx, resourceName, awstypes.InstanceStateNameRunning), + ), + }, + { + PreConfig: func() { + if v.InstanceId == nil { + t.Fatal("Instance ID is nil") + } + + if err := invokeStopInstanceAction(ctx, t, *v.InstanceId, true); err != nil { + t.Fatalf("Failed to invoke stop instance action: %v", err) + } + }, + Config: testAccStopInstanceActionConfig_force(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckInstanceState(ctx, resourceName, awstypes.InstanceStateNameStopped), + ), + }, + }, + }) +} + +func TestAccEC2StopInstanceAction_trigger(t *testing.T) { + ctx := acctest.Context(t) + var v awstypes.Instance + resourceName := "aws_instance.test" + rName := acctest.RandomWithPrefix(t, acctest.ResourcePrefix) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.EC2) + }, + ErrorCheck: acctest.ErrorCheck(t, names.EC2ServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_14_0), + }, + Steps: []resource.TestStep{ + { + Config: testAccStopInstanceActionConfig_trigger(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckInstanceExistsLocal(ctx, resourceName, &v), + testAccCheckInstanceState(ctx, resourceName, awstypes.InstanceStateNameStopped), + ), + }, + }, + }) +} + +func testAccCheckInstanceExistsLocal(ctx context.Context, n string, v *awstypes.Instance) 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 EC2 Instance ID is set") + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).EC2Client(ctx) + + instance, err := tfec2.FindInstanceByID(ctx, conn, rs.Primary.ID) + if err != nil { + return err + } + + *v = *instance + + return nil + } +} + +func testAccCheckInstanceState(ctx context.Context, n string, expectedState awstypes.InstanceStateName) 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 EC2 Instance ID is set") + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).EC2Client(ctx) + + instance, err := tfec2.FindInstanceByID(ctx, conn, rs.Primary.ID) + if err != nil { + return err + } + + if instance.State.Name != expectedState { + return fmt.Errorf("Expected instance state %s, got %s", expectedState, instance.State.Name) + } + + return nil + } +} + +func testAccStopInstanceActionConfig_force(rName string) string { + return acctest.ConfigCompose( + acctest.ConfigLatestAmazonLinux2HVMEBSX8664AMI(), + acctest.ConfigAvailableAZsNoOptIn(), + acctest.AvailableEC2InstanceTypeForAvailabilityZone("data.aws_availability_zones.available.names[0]", "t3.micro", "t2.micro"), + fmt.Sprintf(` +resource "aws_instance" "test" { + ami = data.aws_ami.amzn2-ami-minimal-hvm-ebs-x86_64.id + instance_type = data.aws_ec2_instance_type_offering.available.instance_type + + tags = { + Name = %[1]q + } +} + +action "aws_ec2_stop_instance" "test" { + config { + instance_id = aws_instance.test.id + force = true + } +} +`, rName)) +} + +func testAccStopInstanceActionConfig_trigger(rName string) string { + return acctest.ConfigCompose( + acctest.ConfigLatestAmazonLinux2HVMEBSX8664AMI(), + acctest.ConfigAvailableAZsNoOptIn(), + acctest.AvailableEC2InstanceTypeForAvailabilityZone("data.aws_availability_zones.available.names[0]", "t3.micro", "t2.micro"), + fmt.Sprintf(` +resource "aws_instance" "test" { + ami = data.aws_ami.amzn2-ami-minimal-hvm-ebs-x86_64.id + instance_type = data.aws_ec2_instance_type_offering.available.instance_type + + tags = { + Name = %[1]q + } +} + +action "aws_ec2_stop_instance" "test" { + config { + instance_id = aws_instance.test.id + force = true + } +} + +resource "terraform_data" "trigger" { + input = "trigger" + lifecycle { + action_trigger { + events = [before_create, before_update] + actions = [action.aws_ec2_stop_instance.test] + } + } +} +`, rName)) +} + +// Step 1: Get the AWS provider as a ProviderServerWithActions +func providerWithActions(ctx context.Context, t *testing.T) tfprotov5.ProviderServerWithActions { //nolint:staticcheck // SA1019: Working in alpha situation + t.Helper() + + factories := acctest.ProtoV5ProviderFactories + providerFactory, exists := factories["aws"] + if !exists { + t.Fatal("AWS provider factory not found in ProtoV5ProviderFactories") + } + + providerServer, err := providerFactory() + if err != nil { + t.Fatalf("Failed to create provider server: %v", err) + } + + providerWithActions, ok := providerServer.(tfprotov5.ProviderServerWithActions) //nolint:staticcheck // SA1019: Working in alpha situation + if !ok { + t.Fatal("Provider does not implement ProviderServerWithActions") + } + + schemaResp, err := providerWithActions.GetProviderSchema(ctx, &tfprotov5.GetProviderSchemaRequest{}) + if err != nil { + t.Fatalf("Failed to get provider schema: %v", err) + } + + if len(schemaResp.ActionSchemas) == 0 { + t.Fatal("Expected to find action schemas but didn't find any!") + } + + providerConfigValue, err := buildProviderConfiguration(t, schemaResp.Provider) + if err != nil { + t.Fatalf("Failed to build provider configuration: %v", err) + } + + configureResp, err := providerWithActions.ConfigureProvider(ctx, &tfprotov5.ConfigureProviderRequest{ + TerraformVersion: "1.0.0", + Config: providerConfigValue, + }) + if err != nil { + t.Fatalf("Failed to configure provider: %v", err) + } + + if len(configureResp.Diagnostics) > 0 { + var diagMessages []string + for _, diag := range configureResp.Diagnostics { + diagMessages = append(diagMessages, fmt.Sprintf("Severity: %s, Summary: %s, Detail: %s", diag.Severity, diag.Summary, diag.Detail)) + } + t.Fatalf("Provider configuration failed: %v", diagMessages) + } + + return providerWithActions +} + +// buildProviderConfiguration creates a minimal provider configuration from the schema +func buildProviderConfiguration(t *testing.T, providerSchema *tfprotov5.Schema) (*tfprotov5.DynamicValue, error) { + t.Helper() + + providerType := providerSchema.Block.ValueType() + configMap := make(map[string]tftypes.Value) + + if objType, ok := providerType.(tftypes.Object); ok { + for attrName, attrType := range objType.AttributeTypes { + configMap[attrName] = tftypes.NewValue(attrType, nil) + } + } + + configValue, err := tfprotov5.NewDynamicValue( + providerType, + tftypes.NewValue(providerType, configMap), + ) + if err != nil { + return nil, fmt.Errorf("failed to create config: %w", err) + } + + return &configValue, nil +} + +// Step 2: Build action configuration +func buildStopInstanceActionConfig(instanceID string, force bool) (tftypes.Type, map[string]tftypes.Value) { + configType := tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + names.AttrInstanceID: tftypes.String, + "force": tftypes.Bool, + names.AttrTimeout: tftypes.Number, + names.AttrRegion: tftypes.String, + }, + } + + config := map[string]tftypes.Value{ + names.AttrInstanceID: tftypes.NewValue(tftypes.String, instanceID), + "force": tftypes.NewValue(tftypes.Bool, force), + names.AttrTimeout: tftypes.NewValue(tftypes.Number, nil), + names.AttrRegion: tftypes.NewValue(tftypes.String, nil), + } + + return configType, config +} + +// Step 3: Programmatic action invocation +func invokeStopInstanceAction(ctx context.Context, t *testing.T, instanceID string, force bool) error { + t.Helper() + + p := providerWithActions(ctx, t) + configType, configMap := buildStopInstanceActionConfig(instanceID, force) + actionTypeName := "aws_ec2_stop_instance" + + testConfig, err := tfprotov5.NewDynamicValue( + configType, + tftypes.NewValue(configType, configMap), + ) + if err != nil { + return fmt.Errorf("failed to create config: %w", err) + } + + invokeResp, err := p.InvokeAction(ctx, &tfprotov5.InvokeActionRequest{ + ActionType: actionTypeName, + Config: &testConfig, + }) + if err != nil { + return fmt.Errorf("invoke failed: %w", err) + } + + // Process events and check for completion + for event := range invokeResp.Events { + switch eventType := event.Type.(type) { + case tfprotov5.ProgressInvokeActionEventType: + t.Logf("Progress: %s", eventType.Message) + case tfprotov5.CompletedInvokeActionEventType: + return nil + default: + // Handle any other event types or errors + t.Logf("Received event type: %T", eventType) + } + } + + return nil +} diff --git a/internal/service/ec2/service_package_gen.go b/internal/service/ec2/service_package_gen.go index 525aaad6f819..2903541c326e 100644 --- a/internal/service/ec2/service_package_gen.go +++ b/internal/service/ec2/service_package_gen.go @@ -17,6 +17,17 @@ import ( type servicePackage struct{} +func (p *servicePackage) Actions(ctx context.Context) []*inttypes.ServicePackageAction { + return []*inttypes.ServicePackageAction{ + { + Factory: newStopInstanceAction, + TypeName: "aws_ec2_stop_instance", + Name: "Stop Instance", + Region: unique.Make(inttypes.ResourceRegionDefault()), + }, + } +} + func (p *servicePackage) FrameworkDataSources(ctx context.Context) []*inttypes.ServicePackageFrameworkDataSource { return []*inttypes.ServicePackageFrameworkDataSource{ { diff --git a/website/docs/actions/ec2_stop_instance.html.markdown b/website/docs/actions/ec2_stop_instance.html.markdown new file mode 100644 index 000000000000..7d627c98ea3c --- /dev/null +++ b/website/docs/actions/ec2_stop_instance.html.markdown @@ -0,0 +1,91 @@ +--- +subcategory: "EC2 (Elastic Compute Cloud)" +layout: "aws" +page_title: "AWS: aws_ec2_stop_instance" +description: |- + Stops an EC2 instance. +--- + +# Action: aws_ec2_stop_instance + +~> **Note:** `aws_ec2_stop_instance` is in alpha. Its interface and behavior may change as the feature evolves, and breaking changes are possible. It is offered as a technical preview without compatibility guarantees until Terraform 1.14 is generally available. + +Stops an EC2 instance. This action will gracefully stop the instance and wait for it to reach the stopped state. + +For information about Amazon EC2, see the [Amazon EC2 User Guide](https://docs.aws.amazon.com/ec2/latest/userguide/). For specific information about stopping instances, see the [StopInstances](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_StopInstances.html) page in the Amazon EC2 API Reference. + +~> **Note:** This action directly stops EC2 instances which will interrupt running workloads. Ensure proper coordination with your applications before using this action. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_instance" "example" { + ami = data.aws_ami.amazon_linux.id + instance_type = "t3.micro" + + tags = { + Name = "example-instance" + } +} + +action "aws_ec2_stop_instance" "example" { + config { + instance_id = aws_instance.example.id + } +} +``` + +### Force Stop + +```terraform +action "aws_ec2_stop_instance" "force_stop" { + config { + instance_id = aws_instance.example.id + force = true + timeout = 300 + } +} +``` + +### Maintenance Window + +```terraform +resource "aws_instance" "web_server" { + ami = data.aws_ami.amazon_linux.id + instance_type = "t3.micro" + + tags = { + Name = "web-server" + } +} + +action "aws_ec2_stop_instance" "maintenance" { + config { + instance_id = aws_instance.web_server.id + timeout = 900 + } +} + +resource "terraform_data" "maintenance_trigger" { + input = var.maintenance_window + + lifecycle { + action_trigger { + events = [before_create, before_update] + actions = [action.aws_ec2_stop_instance.maintenance] + } + } + + depends_on = [aws_instance.web_server] +} +``` + +## Argument Reference + +This action supports the following arguments: + +* `instance_id` - (Required) ID of the EC2 instance to stop. Must be a valid EC2 instance ID (e.g., i-1234567890abcdef0). +* `force` - (Optional) Forces the instance to stop. The instance does not have an opportunity to flush file system caches or file system metadata. If you use this option, you must perform file system check and repair procedures. This option is not recommended for Windows instances. Default: `false`. +* `timeout` - (Optional) Timeout in seconds to wait for the instance to stop. Must be between 30 and 3600 seconds. Default: `600`.