Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/43700.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:new-action
aws_ec2_stop_instance
```
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
269 changes: 269 additions & 0 deletions internal/service/ec2/ec2_stop_instance_action.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading