|
| 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