Skip to content

Commit dc4be2c

Browse files
authored
Merge pull request #43700 from hashicorp/f-ec2-instance-action-2
New action: `aws_ec2_stop_instance`
2 parents 1f4e8b1 + da1e3f5 commit dc4be2c

File tree

5 files changed

+712
-0
lines changed

5 files changed

+712
-0
lines changed

.changelog/43700.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:new-action
2+
aws_ec2_stop_instance
3+
```
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: MPL-2.0
3+
4+
package ec2
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"slices"
10+
"time"
11+
12+
"github.com/YakDriver/regexache"
13+
"github.com/aws/aws-sdk-go-v2/aws"
14+
"github.com/aws/aws-sdk-go-v2/service/ec2"
15+
awstypes "github.com/aws/aws-sdk-go-v2/service/ec2/types"
16+
"github.com/hashicorp/aws-sdk-go-base/v2/tfawserr"
17+
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
18+
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
19+
"github.com/hashicorp/terraform-plugin-framework/action"
20+
"github.com/hashicorp/terraform-plugin-framework/action/schema"
21+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
22+
"github.com/hashicorp/terraform-plugin-framework/types"
23+
"github.com/hashicorp/terraform-plugin-log/tflog"
24+
"github.com/hashicorp/terraform-provider-aws/internal/framework"
25+
"github.com/hashicorp/terraform-provider-aws/names"
26+
)
27+
28+
// @Action(aws_ec2_stop_instance, name="Stop Instance")
29+
func newStopInstanceAction(_ context.Context) (action.ActionWithConfigure, error) {
30+
return &stopInstanceAction{}, nil
31+
}
32+
33+
var (
34+
_ action.Action = (*stopInstanceAction)(nil)
35+
)
36+
37+
type stopInstanceAction struct {
38+
framework.ActionWithModel[stopInstanceModel]
39+
}
40+
41+
type stopInstanceModel struct {
42+
framework.WithRegionModel
43+
InstanceID types.String `tfsdk:"instance_id"`
44+
Force types.Bool `tfsdk:"force"`
45+
Timeout types.Int64 `tfsdk:"timeout"`
46+
}
47+
48+
func (a *stopInstanceAction) Schema(ctx context.Context, req action.SchemaRequest, resp *action.SchemaResponse) {
49+
resp.Schema = schema.Schema{
50+
Description: "Stops an EC2 instance. This action will gracefully stop the instance and wait for it to reach the stopped state.",
51+
Attributes: map[string]schema.Attribute{
52+
names.AttrInstanceID: schema.StringAttribute{
53+
Description: "The ID of the EC2 instance to stop",
54+
Required: true,
55+
Validators: []validator.String{
56+
stringvalidator.RegexMatches(
57+
regexache.MustCompile(`^i-[0-9a-f]{8,17}$`),
58+
"must be a valid EC2 instance ID (e.g., i-1234567890abcdef0)",
59+
),
60+
},
61+
},
62+
"force": schema.BoolAttribute{
63+
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.",
64+
Optional: true,
65+
},
66+
names.AttrTimeout: schema.Int64Attribute{
67+
Description: "Timeout in seconds to wait for the instance to stop (default: 600)",
68+
Optional: true,
69+
Validators: []validator.Int64{
70+
int64validator.AtLeast(30),
71+
int64validator.AtMost(3600),
72+
},
73+
},
74+
},
75+
}
76+
}
77+
78+
func (a *stopInstanceAction) Invoke(ctx context.Context, req action.InvokeRequest, resp *action.InvokeResponse) {
79+
var config stopInstanceModel
80+
81+
// Parse configuration
82+
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
83+
if resp.Diagnostics.HasError() {
84+
return
85+
}
86+
87+
// Get AWS client
88+
conn := a.Meta().EC2Client(ctx)
89+
90+
instanceID := config.InstanceID.ValueString()
91+
force := config.Force.ValueBool()
92+
93+
// Set default timeout if not provided
94+
timeout := 600 * time.Second
95+
if !config.Timeout.IsNull() {
96+
timeout = time.Duration(config.Timeout.ValueInt64()) * time.Second
97+
}
98+
99+
tflog.Info(ctx, "Starting EC2 stop instance action", map[string]any{
100+
names.AttrInstanceID: instanceID,
101+
"force": force,
102+
names.AttrTimeout: timeout.String(),
103+
})
104+
105+
// Send initial progress update
106+
resp.SendProgress(action.InvokeProgressEvent{
107+
Message: fmt.Sprintf("Starting stop operation for EC2 instance %s...", instanceID),
108+
})
109+
110+
// Check current instance state first
111+
instance, err := findInstanceByID(ctx, conn, instanceID)
112+
if err != nil {
113+
if tfawserr.ErrCodeEquals(err, errCodeInvalidInstanceIDNotFound) {
114+
resp.Diagnostics.AddError(
115+
"Instance Not Found",
116+
fmt.Sprintf("EC2 instance %s was not found", instanceID),
117+
)
118+
return
119+
}
120+
resp.Diagnostics.AddError(
121+
"Failed to Describe Instance",
122+
fmt.Sprintf("Could not describe EC2 instance %s: %s", instanceID, err),
123+
)
124+
return
125+
}
126+
127+
currentState := string(instance.State.Name)
128+
tflog.Debug(ctx, "Current instance state", map[string]any{
129+
names.AttrInstanceID: instanceID,
130+
names.AttrState: currentState,
131+
})
132+
133+
// Check if instance is already stopped
134+
if instance.State.Name == awstypes.InstanceStateNameStopped {
135+
resp.SendProgress(action.InvokeProgressEvent{
136+
Message: fmt.Sprintf("EC2 instance %s is already stopped", instanceID),
137+
})
138+
tflog.Info(ctx, "Instance already stopped", map[string]any{
139+
names.AttrInstanceID: instanceID,
140+
})
141+
return
142+
}
143+
144+
// Check if instance is in a state that can be stopped
145+
if !canStopInstance(instance.State.Name) {
146+
resp.Diagnostics.AddError(
147+
"Cannot Stop Instance",
148+
fmt.Sprintf("EC2 instance %s is in state '%s' and cannot be stopped. Instance must be in 'running' or 'stopping' state.", instanceID, currentState),
149+
)
150+
return
151+
}
152+
153+
// If instance is already stopping, just wait for it
154+
if instance.State.Name == awstypes.InstanceStateNameStopping {
155+
resp.SendProgress(action.InvokeProgressEvent{
156+
Message: fmt.Sprintf("EC2 instance %s is already stopping, waiting for completion...", instanceID),
157+
})
158+
} else {
159+
// Stop the instance
160+
resp.SendProgress(action.InvokeProgressEvent{
161+
Message: fmt.Sprintf("Sending stop command to EC2 instance %s...", instanceID),
162+
})
163+
164+
input := ec2.StopInstancesInput{
165+
Force: aws.Bool(force),
166+
InstanceIds: []string{instanceID},
167+
}
168+
169+
_, err = conn.StopInstances(ctx, &input)
170+
if err != nil {
171+
resp.Diagnostics.AddError(
172+
"Failed to Stop Instance",
173+
fmt.Sprintf("Could not stop EC2 instance %s: %s", instanceID, err),
174+
)
175+
return
176+
}
177+
178+
resp.SendProgress(action.InvokeProgressEvent{
179+
Message: fmt.Sprintf("Stop command sent to EC2 instance %s, waiting for instance to stop...", instanceID),
180+
})
181+
}
182+
183+
// Wait for instance to stop with periodic progress updates
184+
err = a.waitForInstanceStopped(ctx, conn, instanceID, timeout, resp)
185+
if err != nil {
186+
resp.Diagnostics.AddError(
187+
"Timeout Waiting for Instance to Stop",
188+
fmt.Sprintf("EC2 instance %s did not stop within %s: %s", instanceID, timeout, err),
189+
)
190+
return
191+
}
192+
193+
// Final success message
194+
resp.SendProgress(action.InvokeProgressEvent{
195+
Message: fmt.Sprintf("EC2 instance %s has been successfully stopped", instanceID),
196+
})
197+
198+
tflog.Info(ctx, "EC2 stop instance action completed successfully", map[string]any{
199+
names.AttrInstanceID: instanceID,
200+
})
201+
}
202+
203+
// canStopInstance checks if an instance can be stopped based on its current state
204+
func canStopInstance(state awstypes.InstanceStateName) bool {
205+
switch state {
206+
case awstypes.InstanceStateNameRunning, awstypes.InstanceStateNameStopping:
207+
return true
208+
default:
209+
return false
210+
}
211+
}
212+
213+
// waitForInstanceStopped waits for an instance to reach the stopped state with progress updates
214+
func (a *stopInstanceAction) waitForInstanceStopped(ctx context.Context, conn *ec2.Client, instanceID string, timeout time.Duration, resp *action.InvokeResponse) error {
215+
const (
216+
pollInterval = 10 * time.Second
217+
progressInterval = 30 * time.Second
218+
)
219+
220+
deadline := time.Now().Add(timeout)
221+
lastProgressUpdate := time.Now()
222+
223+
for {
224+
select {
225+
case <-ctx.Done():
226+
return ctx.Err()
227+
default:
228+
}
229+
230+
// Check if we've exceeded the timeout
231+
if time.Now().After(deadline) {
232+
return fmt.Errorf("timeout after %s", timeout)
233+
}
234+
235+
// Get current instance state
236+
instance, err := findInstanceByID(ctx, conn, instanceID)
237+
if err != nil {
238+
return fmt.Errorf("describing instance: %w", err)
239+
}
240+
241+
currentState := string(instance.State.Name)
242+
243+
// Send progress update every 30 seconds
244+
if time.Since(lastProgressUpdate) >= progressInterval {
245+
resp.SendProgress(action.InvokeProgressEvent{
246+
Message: fmt.Sprintf("EC2 instance %s is currently in state '%s', continuing to wait for 'stopped'...", instanceID, currentState),
247+
})
248+
lastProgressUpdate = time.Now()
249+
}
250+
251+
// Check if we've reached the target state
252+
if instance.State.Name == awstypes.InstanceStateNameStopped {
253+
return nil
254+
}
255+
256+
// Check if we're in an unexpected state
257+
validStates := []awstypes.InstanceStateName{
258+
awstypes.InstanceStateNameRunning,
259+
awstypes.InstanceStateNameStopping,
260+
awstypes.InstanceStateNameShuttingDown,
261+
}
262+
if !slices.Contains(validStates, instance.State.Name) {
263+
return fmt.Errorf("instance entered unexpected state: %s", currentState)
264+
}
265+
266+
// Wait before next poll
267+
time.Sleep(pollInterval)
268+
}
269+
}

0 commit comments

Comments
 (0)