diff --git a/aws/internal/experimental/nullable/int.go b/aws/internal/experimental/nullable/int.go new file mode 100644 index 00000000000..53981603485 --- /dev/null +++ b/aws/internal/experimental/nullable/int.go @@ -0,0 +1,78 @@ +package nullable + +import ( + "fmt" + "strconv" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ( + TypeNullableInt = schema.TypeString +) + +type Int string + +func (i Int) IsNull() bool { + return i == "" +} + +func (i Int) Value() (int64, bool, error) { + if i.IsNull() { + return 0, true, nil + } + + value, err := strconv.ParseInt(string(i), 10, 64) + if err != nil { + return 0, false, err + } + return value, false, nil +} + +// ValidateTypeStringNullableInt provides custom error messaging for TypeString ints +// Some arguments require an int value or unspecified, empty field. +func ValidateTypeStringNullableInt(v interface{}, k string) (ws []string, es []error) { + value, ok := v.(string) + if !ok { + es = append(es, fmt.Errorf("expected type of %s to be string", k)) + return + } + + if value == "" { + return + } + + if _, err := strconv.ParseInt(value, 10, 64); err != nil { + es = append(es, fmt.Errorf("%s: cannot parse '%s' as int: %w", k, value, err)) + } + + return +} + +// ValidateTypeStringNullableIntAtLeast provides custom error messaging for TypeString ints +// Some arguments require an int value or unspecified, empty field. +func ValidateTypeStringNullableIntAtLeast(min int) schema.SchemaValidateFunc { + return func(i interface{}, k string) (ws []string, es []error) { + value, ok := i.(string) + if !ok { + es = append(es, fmt.Errorf("expected type of %s to be string", k)) + return + } + + if value == "" { + return + } + + v, err := strconv.ParseInt(value, 10, 64) + if err != nil { + es = append(es, fmt.Errorf("%s: cannot parse '%s' as int: %w", k, value, err)) + return + } + + if v < int64(min) { + es = append(es, fmt.Errorf("expected %s to be at least (%d), got %d", k, min, v)) + } + + return + } +} diff --git a/aws/internal/experimental/nullable/int_test.go b/aws/internal/experimental/nullable/int_test.go new file mode 100644 index 00000000000..cc1c61e554e --- /dev/null +++ b/aws/internal/experimental/nullable/int_test.go @@ -0,0 +1,100 @@ +package nullable + +import ( + "errors" + "regexp" + "strconv" + "testing" +) + +func TestNullableInt(t *testing.T) { + cases := []struct { + val string + expectedNull bool + expectedValue int64 + expectedErr error + }{ + { + val: "1", + expectedNull: false, + expectedValue: 1, + }, + { + val: "", + expectedNull: true, + expectedValue: 0, + }, + { + val: "A", + expectedNull: false, + expectedValue: 0, + expectedErr: strconv.ErrSyntax, + }, + } + + for i, tc := range cases { + v := Int(tc.val) + + if null := v.IsNull(); null != tc.expectedNull { + t.Fatalf("expected test case %d IsNull to return %t, got %t", i, null, tc.expectedNull) + } + + value, null, err := v.Value() + if value != tc.expectedValue { + t.Fatalf("expected test case %d Value to be %d, got %d", i, tc.expectedValue, value) + } + if null != tc.expectedNull { + t.Fatalf("expected test case %d Value null flag to be %t, got %t", i, tc.expectedNull, null) + } + if tc.expectedErr == nil && err != nil { + t.Fatalf("expected test case %d to succeed, got error %s", i, err) + } + if tc.expectedErr != nil { + if !errors.Is(err, tc.expectedErr) { + t.Fatalf("expected test case %d to have error matching \"%s\", got %s", i, tc.expectedErr, err) + } + } + } +} + +func TestValidationInt(t *testing.T) { + runTestCases(t, []testCase{ + { + val: "1", + f: ValidateTypeStringNullableInt, + }, + { + val: "A", + f: ValidateTypeStringNullableInt, + expectedErr: regexp.MustCompile(`[\w]+: cannot parse 'A' as int: .*`), + }, + { + val: 1, + f: ValidateTypeStringNullableInt, + expectedErr: regexp.MustCompile(`expected type of [\w]+ to be string`), + }, + }) +} + +func TestValidationIntAtLeast(t *testing.T) { + runTestCases(t, []testCase{ + { + val: "1", + f: ValidateTypeStringNullableIntAtLeast(1), + }, + { + val: "1", + f: ValidateTypeStringNullableIntAtLeast(0), + }, + { + val: "1", + f: ValidateTypeStringNullableIntAtLeast(2), + expectedErr: regexp.MustCompile(`expected [\w]+ to be at least \(2\), got 1`), + }, + { + val: 1, + f: ValidateTypeStringNullableIntAtLeast(2), + expectedErr: regexp.MustCompile(`expected type of [\w]+ to be string`), + }, + }) +} diff --git a/aws/internal/experimental/nullable/testing.go b/aws/internal/experimental/nullable/testing.go new file mode 100644 index 00000000000..9921ac5375f --- /dev/null +++ b/aws/internal/experimental/nullable/testing.go @@ -0,0 +1,45 @@ +package nullable + +import ( + "regexp" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + testing "github.com/mitchellh/go-testing-interface" +) + +type testCase struct { + val interface{} + f schema.SchemaValidateFunc + expectedErr *regexp.Regexp +} + +func runTestCases(t testing.T, cases []testCase) { + t.Helper() + + matchErr := func(errs []error, r *regexp.Regexp) bool { + // err must match one provided + for _, err := range errs { + if r.MatchString(err.Error()) { + return true + } + } + + return false + } + + for i, tc := range cases { + _, errs := tc.f(tc.val, "test_property") + + if len(errs) == 0 && tc.expectedErr == nil { + continue + } + + if len(errs) != 0 && tc.expectedErr == nil { + t.Fatalf("expected test case %d to produce no errors, got %v", i, errs) + } + + if !matchErr(errs, tc.expectedErr) { + t.Fatalf("expected test case %d to produce error matching \"%s\", got %v", i, tc.expectedErr, errs) + } + } +} diff --git a/aws/internal/service/autoscaling/waiter/status.go b/aws/internal/service/autoscaling/waiter/status.go new file mode 100644 index 00000000000..a7bd32d56e0 --- /dev/null +++ b/aws/internal/service/autoscaling/waiter/status.go @@ -0,0 +1,28 @@ +package waiter + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/autoscaling" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func InstanceRefreshStatus(conn *autoscaling.AutoScaling, asgName, instanceRefreshId string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + input := autoscaling.DescribeInstanceRefreshesInput{ + AutoScalingGroupName: aws.String(asgName), + InstanceRefreshIds: []*string{aws.String(instanceRefreshId)}, + } + output, err := conn.DescribeInstanceRefreshes(&input) + if err != nil { + return nil, "", err + } + + if output == nil || len(output.InstanceRefreshes) == 0 || output.InstanceRefreshes[0] == nil { + return nil, "", nil + } + + instanceRefresh := output.InstanceRefreshes[0] + + return instanceRefresh, aws.StringValue(instanceRefresh.Status), nil + } +} diff --git a/aws/internal/service/autoscaling/waiter/waiter.go b/aws/internal/service/autoscaling/waiter/waiter.go new file mode 100644 index 00000000000..00aed168296 --- /dev/null +++ b/aws/internal/service/autoscaling/waiter/waiter.go @@ -0,0 +1,44 @@ +package waiter + +import ( + "time" + + "github.com/aws/aws-sdk-go/service/autoscaling" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +const ( + // Maximum amount of time to wait for an InstanceRefresh to be started + // Must be at least as long as InstanceRefreshCancelledTimeout, since we try to cancel any + // existing Instance Refreshes when starting. + InstanceRefreshStartedTimeout = InstanceRefreshCancelledTimeout + + // Maximum amount of time to wait for an Instance Refresh to be Cancelled + InstanceRefreshCancelledTimeout = 15 * time.Minute +) + +func InstanceRefreshCancelled(conn *autoscaling.AutoScaling, asgName, instanceRefreshId string) (*autoscaling.InstanceRefresh, error) { + stateConf := &resource.StateChangeConf{ + Pending: []string{ + autoscaling.InstanceRefreshStatusPending, + autoscaling.InstanceRefreshStatusInProgress, + autoscaling.InstanceRefreshStatusCancelling, + }, + Target: []string{ + autoscaling.InstanceRefreshStatusCancelled, + // Failed and Successful are also acceptable end-states + autoscaling.InstanceRefreshStatusFailed, + autoscaling.InstanceRefreshStatusSuccessful, + }, + Refresh: InstanceRefreshStatus(conn, asgName, instanceRefreshId), + Timeout: InstanceRefreshCancelledTimeout, + } + + outputRaw, err := stateConf.WaitForState() + + if v, ok := outputRaw.(*autoscaling.InstanceRefresh); ok { + return v, err + } + + return nil, err +} diff --git a/aws/resource_aws_autoscaling_group.go b/aws/resource_aws_autoscaling_group.go index 136715e6aad..c92d698178c 100644 --- a/aws/resource_aws_autoscaling_group.go +++ b/aws/resource_aws_autoscaling_group.go @@ -15,12 +15,17 @@ import ( "github.com/aws/aws-sdk-go/service/autoscaling" "github.com/aws/aws-sdk-go/service/elb" "github.com/aws/aws-sdk-go/service/elbv2" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/experimental/nullable" "github.com/terraform-providers/terraform-provider-aws/aws/internal/hashcode" "github.com/terraform-providers/terraform-provider-aws/aws/internal/keyvaluetags" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/autoscaling/waiter" ) const ( @@ -477,6 +482,50 @@ func resourceAwsAutoscalingGroup() *schema.Resource { Optional: true, Computed: true, }, + + "instance_refresh": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "strategy": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice(autoscaling.RefreshStrategy_Values(), false), + }, + "preferences": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "instance_warmup": { + Type: nullable.TypeNullableInt, + Optional: true, + ValidateFunc: nullable.ValidateTypeStringNullableIntAtLeast(0), + }, + "min_healthy_percentage": { + Type: schema.TypeInt, + Optional: true, + Default: 90, + ValidateFunc: validation.IntBetween(0, 100), + }, + }, + }, + }, + "triggers": { + Type: schema.TypeSet, + Optional: true, + Set: schema.HashString, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateDiagFunc: validateAutoScalingGroupInstanceRefreshTriggerFields, + }, + }, + }, + }, + }, }, CustomizeDiff: customdiff.Sequence( @@ -584,7 +633,7 @@ func resourceAwsAutoscalingGroupCreate(d *schema.ResourceData, meta interface{}) launchTemplateValue, launchTemplateOk := d.GetOk("launch_template") if createOpts.MixedInstancesPolicy == nil && !launchConfigurationOk && !launchTemplateOk { - return fmt.Errorf("One of `launch_configuration`, `launch_template`, or `mixed_instances_policy` must be set for an autoscaling group") + return fmt.Errorf("One of `launch_configuration`, `launch_template`, or `mixed_instances_policy` must be set for an Auto Scaling Group") } if launchConfigurationOk { @@ -659,7 +708,7 @@ func resourceAwsAutoscalingGroupCreate(d *schema.ResourceData, meta interface{}) createOpts.MaxInstanceLifetime = aws.Int64(int64(v.(int))) } - log.Printf("[DEBUG] AutoScaling Group create configuration: %#v", createOpts) + log.Printf("[DEBUG] Auto Scaling Group create configuration: %#v", createOpts) // Retry for IAM eventual consistency err := resource.Retry(1*time.Minute, func() *resource.RetryError { @@ -680,11 +729,11 @@ func resourceAwsAutoscalingGroupCreate(d *schema.ResourceData, meta interface{}) _, err = conn.CreateAutoScalingGroup(&createOpts) } if err != nil { - return fmt.Errorf("Error creating AutoScaling Group: %s", err) + return fmt.Errorf("Error creating Auto Scaling Group: %s", err) } d.SetId(d.Get("name").(string)) - log.Printf("[INFO] AutoScaling Group ID: %s", d.Id()) + log.Printf("[INFO] Auto Scaling Group ID: %s", d.Id()) if twoPhases { for _, hook := range generatePutLifecycleHookInputs(asgName, initialLifecycleHooks) { @@ -695,7 +744,7 @@ func resourceAwsAutoscalingGroupCreate(d *schema.ResourceData, meta interface{}) _, err = conn.UpdateAutoScalingGroup(&updateOpts) if err != nil { - return fmt.Errorf("Error setting AutoScaling Group initial capacity: %s", err) + return fmt.Errorf("Error setting Auto Scaling Group initial capacity: %s", err) } } @@ -720,6 +769,7 @@ func resourceAwsAutoscalingGroupCreate(d *schema.ResourceData, meta interface{}) return resourceAwsAutoscalingGroupRead(d, meta) } +// TODO: wrap all top-level error returns func resourceAwsAutoscalingGroupRead(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).autoscalingconn ignoreTagsConfig := meta.(*AWSClient).IgnoreTagsConfig @@ -729,7 +779,7 @@ func resourceAwsAutoscalingGroupRead(d *schema.ResourceData, meta interface{}) e return err } if g == nil { - log.Printf("[WARN] Autoscaling Group (%s) not found, removing from state", d.Id()) + log.Printf("[WARN] Auto Scaling Group (%s) not found, removing from state", d.Id()) d.SetId("") return nil } @@ -910,6 +960,7 @@ func waitUntilAutoscalingGroupLoadBalancerTargetGroupsAdded(conn *autoscaling.Au func resourceAwsAutoscalingGroupUpdate(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).autoscalingconn shouldWaitForCapacity := false + shouldRefreshInstances := false opts := autoscaling.UpdateAutoScalingGroupInput{ AutoScalingGroupName: aws.String(d.Id()), @@ -940,16 +991,19 @@ func resourceAwsAutoscalingGroupUpdate(d *schema.ResourceData, meta interface{}) if v, ok := d.GetOk("launch_configuration"); ok { opts.LaunchConfigurationName = aws.String(v.(string)) } + shouldRefreshInstances = true } if d.HasChange("launch_template") { if v, ok := d.GetOk("launch_template"); ok && len(v.([]interface{})) > 0 { opts.LaunchTemplate, _ = expandLaunchTemplateSpecification(v.([]interface{})) } + shouldRefreshInstances = true } if d.HasChange("mixed_instances_policy") { opts.MixedInstancesPolicy = expandAutoScalingMixedInstancesPolicy(d.Get("mixed_instances_policy").([]interface{})) + shouldRefreshInstances = true } if d.HasChange("min_size") { @@ -1020,10 +1074,10 @@ func resourceAwsAutoscalingGroupUpdate(d *schema.ResourceData, meta interface{}) } } - log.Printf("[DEBUG] AutoScaling Group update configuration: %#v", opts) + log.Printf("[DEBUG] Auto Scaling Group update configuration: %#v", opts) _, err := conn.UpdateAutoScalingGroup(&opts) if err != nil { - return fmt.Errorf("Error updating Autoscaling group: %s", err) + return fmt.Errorf("Error updating Auto Scaling Group: %s", err) } if d.HasChange("load_balancers") { @@ -1059,11 +1113,11 @@ func resourceAwsAutoscalingGroupUpdate(d *schema.ResourceData, meta interface{}) }) if err != nil { - return fmt.Errorf("error detaching AutoScaling Group (%s) Load Balancers: %s", d.Id(), err) + return fmt.Errorf("error detaching Auto Scaling Group (%s) Load Balancers: %s", d.Id(), err) } if err := waitUntilAutoscalingGroupLoadBalancersRemoved(conn, d.Id()); err != nil { - return fmt.Errorf("error describing AutoScaling Group (%s) Load Balancers being removed: %s", d.Id(), err) + return fmt.Errorf("error describing Auto Scaling Group (%s) Load Balancers being removed: %s", d.Id(), err) } } } @@ -1086,11 +1140,11 @@ func resourceAwsAutoscalingGroupUpdate(d *schema.ResourceData, meta interface{}) }) if err != nil { - return fmt.Errorf("error attaching AutoScaling Group (%s) Load Balancers: %s", d.Id(), err) + return fmt.Errorf("error attaching Auto Scaling Group (%s) Load Balancers: %s", d.Id(), err) } if err := waitUntilAutoscalingGroupLoadBalancersAdded(conn, d.Id()); err != nil { - return fmt.Errorf("error describing AutoScaling Group (%s) Load Balancers being added: %s", d.Id(), err) + return fmt.Errorf("error describing Auto Scaling Group (%s) Load Balancers being added: %s", d.Id(), err) } } } @@ -1128,11 +1182,11 @@ func resourceAwsAutoscalingGroupUpdate(d *schema.ResourceData, meta interface{}) TargetGroupARNs: batch, }) if err != nil { - return fmt.Errorf("Error updating Load Balancers Target Groups for AutoScaling Group (%s), error: %s", d.Id(), err) + return fmt.Errorf("Error updating Load Balancers Target Groups for Auto Scaling Group (%s), error: %s", d.Id(), err) } if err := waitUntilAutoscalingGroupLoadBalancerTargetGroupsRemoved(conn, d.Id()); err != nil { - return fmt.Errorf("error describing AutoScaling Group (%s) Load Balancer Target Groups being removed: %s", d.Id(), err) + return fmt.Errorf("error describing Auto Scaling Group (%s) Load Balancer Target Groups being removed: %s", d.Id(), err) } } @@ -1155,31 +1209,57 @@ func resourceAwsAutoscalingGroupUpdate(d *schema.ResourceData, meta interface{}) }) if err != nil { - return fmt.Errorf("Error updating Load Balancers Target Groups for AutoScaling Group (%s), error: %s", d.Id(), err) + return fmt.Errorf("Error updating Load Balancers Target Groups for Auto Scaling Group (%s), error: %s", d.Id(), err) } if err := waitUntilAutoscalingGroupLoadBalancerTargetGroupsAdded(conn, d.Id()); err != nil { - return fmt.Errorf("error describing AutoScaling Group (%s) Load Balancer Target Groups being added: %s", d.Id(), err) + return fmt.Errorf("error describing Auto Scaling Group (%s) Load Balancer Target Groups being added: %s", d.Id(), err) } } } } + if instanceRefreshRaw, ok := d.GetOk("instance_refresh"); ok { + instanceRefresh := instanceRefreshRaw.([]interface{}) + if !shouldRefreshInstances { + if len(instanceRefresh) > 0 && instanceRefresh[0] != nil { + m := instanceRefresh[0].(map[string]interface{}) + attrsSet := m["triggers"].(*schema.Set) + attrs := attrsSet.List() + strs := make([]string, len(attrs)) + for i, a := range attrs { + strs[i] = a.(string) + } + if attrsSet.Contains("tag") && !attrsSet.Contains("tags") { + strs = append(strs, "tags") + } else if !attrsSet.Contains("tag") && attrsSet.Contains("tags") { + strs = append(strs, "tag") + } + shouldRefreshInstances = d.HasChanges(strs...) + } + } + if shouldRefreshInstances { + if err := autoScalingGroupRefreshInstances(conn, d.Id(), instanceRefresh); err != nil { + return fmt.Errorf("failed to start instance refresh of Auto Scaling Group %s: %w", d.Id(), err) + } + } + } + if shouldWaitForCapacity { if err := waitForASGCapacity(d, meta, capacitySatisfiedUpdate); err != nil { - return fmt.Errorf("Error waiting for AutoScaling Group Capacity: %s", err) + return fmt.Errorf("error waiting for Auto Scaling Group Capacity: %w", err) } } if d.HasChange("enabled_metrics") { if err := updateASGMetricsCollection(d, conn); err != nil { - return fmt.Errorf("Error updating AutoScaling Group Metrics collection: %s", err) + return fmt.Errorf("Error updating Auto Scaling Group Metrics collection: %s", err) } } if d.HasChange("suspended_processes") { if err := updateASGSuspendedProcesses(d, conn); err != nil { - return fmt.Errorf("Error updating AutoScaling Group Suspended Processes: %s", err) + return fmt.Errorf("Error updating Auto Scaling Group Suspended Processes: %s", err) } } @@ -1189,7 +1269,7 @@ func resourceAwsAutoscalingGroupUpdate(d *schema.ResourceData, meta interface{}) func resourceAwsAutoscalingGroupDelete(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).autoscalingconn - // Read the autoscaling group first. If it doesn't exist, we're done. + // Read the Auto Scaling Group first. If it doesn't exist, we're done. // We need the group in order to check if there are instances attached. // If so, we need to remove those first. g, err := getAwsAutoscalingGroup(d.Id(), conn) @@ -1197,7 +1277,7 @@ func resourceAwsAutoscalingGroupDelete(d *schema.ResourceData, meta interface{}) return err } if g == nil { - log.Printf("[WARN] Autoscaling Group (%s) not found, removing from state", d.Id()) + log.Printf("[WARN] Auto Scaling Group (%s) not found, removing from state", d.Id()) return nil } if len(g.Instances) > 0 || *g.DesiredCapacity > 0 { @@ -1206,7 +1286,7 @@ func resourceAwsAutoscalingGroupDelete(d *schema.ResourceData, meta interface{}) } } - log.Printf("[DEBUG] AutoScaling Group destroy: %v", d.Id()) + log.Printf("[DEBUG] Auto Scaling Group destroy: %v", d.Id()) deleteopts := autoscaling.DeleteAutoScalingGroupInput{ AutoScalingGroupName: aws.String(d.Id()), ForceDelete: aws.Bool(d.Get("force_delete").(bool)), @@ -1240,7 +1320,7 @@ func resourceAwsAutoscalingGroupDelete(d *schema.ResourceData, meta interface{}) } } if err != nil { - return fmt.Errorf("Error deleting autoscaling group: %s", err) + return fmt.Errorf("Error deleting Auto Scaling Group: %s", err) } var group *autoscaling.Group @@ -1259,17 +1339,19 @@ func resourceAwsAutoscalingGroupDelete(d *schema.ResourceData, meta interface{}) } } if err != nil { - return fmt.Errorf("Error deleting autoscaling group: %s", err) + return fmt.Errorf("Error deleting Auto Scaling Group: %s", err) } return nil } +// TODO: make this a finder +// TODO: this should return a NotFoundError if not found func getAwsAutoscalingGroup(asgName string, conn *autoscaling.AutoScaling) (*autoscaling.Group, error) { describeOpts := autoscaling.DescribeAutoScalingGroupsInput{ AutoScalingGroupNames: []*string{aws.String(asgName)}, } - log.Printf("[DEBUG] AutoScaling Group describe configuration: %#v", describeOpts) + log.Printf("[DEBUG] Auto Scaling Group describe configuration: %#v", describeOpts) describeGroups, err := conn.DescribeAutoScalingGroups(&describeOpts) if err != nil { autoscalingerr, ok := err.(awserr.Error) @@ -1277,10 +1359,10 @@ func getAwsAutoscalingGroup(asgName string, conn *autoscaling.AutoScaling) (*aut return nil, nil } - return nil, fmt.Errorf("Error retrieving AutoScaling groups: %s", err) + return nil, fmt.Errorf("Error retrieving Auto Scaling Groups: %s", err) } - // Search for the autoscaling group + // Search for the Auto Scaling Group for idx, asc := range describeGroups.AutoScalingGroups { if *asc.AutoScalingGroupName == asgName { return describeGroups.AutoScalingGroups[idx], nil @@ -1294,12 +1376,12 @@ func resourceAwsAutoscalingGroupDrain(d *schema.ResourceData, meta interface{}) conn := meta.(*AWSClient).autoscalingconn if d.Get("force_delete").(bool) { - log.Printf("[DEBUG] Skipping ASG drain, force_delete was set.") + log.Printf("[DEBUG] Skipping Auto Scaling Group drain, force_delete was set.") return nil } // First, set the capacity to zero so the group will drain - log.Printf("[DEBUG] Reducing autoscaling group capacity to zero") + log.Printf("[DEBUG] Reducing Auto Scaling Group capacity to zero") opts := autoscaling.UpdateAutoScalingGroupInput{ AutoScalingGroupName: aws.String(d.Id()), DesiredCapacity: aws.Int64(0), @@ -1310,7 +1392,7 @@ func resourceAwsAutoscalingGroupDrain(d *schema.ResourceData, meta interface{}) return fmt.Errorf("Error setting capacity to zero to drain: %s", err) } - // Next, wait for the autoscale group to drain + // Next, wait for the Auto Scaling Group to drain log.Printf("[DEBUG] Waiting for group to have zero instances") var g *autoscaling.Group err := resource.Retry(d.Timeout(schema.TimeoutDelete), func() *resource.RetryError { @@ -1319,7 +1401,7 @@ func resourceAwsAutoscalingGroupDrain(d *schema.ResourceData, meta interface{}) return resource.NonRetryableError(err) } if g == nil { - log.Printf("[WARN] Autoscaling Group (%s) not found, removing from state", d.Id()) + log.Printf("[WARN] Auto Scaling Group (%s) not found, removing from state", d.Id()) d.SetId("") return nil } @@ -1334,14 +1416,14 @@ func resourceAwsAutoscalingGroupDrain(d *schema.ResourceData, meta interface{}) if isResourceTimeoutError(err) { g, err = getAwsAutoscalingGroup(d.Id(), conn) if err != nil { - return fmt.Errorf("Error getting autoscaling group info when draining: %s", err) + return fmt.Errorf("Error getting Auto Scaling Group info when draining: %s", err) } if g != nil && len(g.Instances) > 0 { return fmt.Errorf("Group still has %d instances", len(g.Instances)) } } if err != nil { - return fmt.Errorf("Error draining autoscaling group: %s", err) + return fmt.Errorf("Error draining Auto Scaling Group: %s", err) } return nil } @@ -1363,7 +1445,7 @@ func enableASGMetricsCollection(d *schema.ResourceData, conn *autoscaling.AutoSc Metrics: expandStringList(d.Get("enabled_metrics").(*schema.Set).List()), } - log.Printf("[INFO] Enabling metrics collection for the ASG: %s", d.Id()) + log.Printf("[INFO] Enabling metrics collection for the Auto Scaling Group: %s", d.Id()) _, metricsErr := conn.EnableMetricsCollection(props) return metricsErr @@ -1390,7 +1472,7 @@ func updateASGSuspendedProcesses(d *schema.ResourceData, conn *autoscaling.AutoS _, err := conn.ResumeProcesses(props) if err != nil { - return fmt.Errorf("Error Resuming Processes for ASG %q: %s", d.Id(), err) + return fmt.Errorf("Error Resuming Processes for Auto Scaling Group %q: %s", d.Id(), err) } } @@ -1403,7 +1485,7 @@ func updateASGSuspendedProcesses(d *schema.ResourceData, conn *autoscaling.AutoS _, err := conn.SuspendProcesses(props) if err != nil { - return fmt.Errorf("Error Suspending Processes for ASG %q: %s", d.Id(), err) + return fmt.Errorf("Error Suspending Processes for Auto Scaling Group %q: %s", d.Id(), err) } } @@ -1433,7 +1515,7 @@ func updateASGMetricsCollection(d *schema.ResourceData, conn *autoscaling.AutoSc _, err := conn.DisableMetricsCollection(props) if err != nil { - return fmt.Errorf("Failure to Disable metrics collection types for ASG %s: %s", d.Id(), err) + return fmt.Errorf("Failure to Disable metrics collection types for Auto Scaling Group %s: %s", d.Id(), err) } } @@ -1447,7 +1529,7 @@ func updateASGMetricsCollection(d *schema.ResourceData, conn *autoscaling.AutoSc _, err := conn.EnableMetricsCollection(props) if err != nil { - return fmt.Errorf("Failure to Enable metrics collection types for ASG %s: %s", d.Id(), err) + return fmt.Errorf("Failure to Enable metrics collection types for Auto Scaling Group %s: %s", d.Id(), err) } } @@ -1795,3 +1877,116 @@ func waitUntilAutoscalingGroupLoadBalancersRemoved(conn *autoscaling.AutoScaling return nil } + +func createAutoScalingGroupInstanceRefreshInput(asgName string, l []interface{}) *autoscaling.StartInstanceRefreshInput { + if len(l) == 0 || l[0] == nil { + return nil + } + + m := l[0].(map[string]interface{}) + + return &autoscaling.StartInstanceRefreshInput{ + AutoScalingGroupName: aws.String(asgName), + Strategy: aws.String(m["strategy"].(string)), + Preferences: expandAutoScalingGroupInstanceRefreshPreferences(m["preferences"].([]interface{})), + } +} + +func expandAutoScalingGroupInstanceRefreshPreferences(l []interface{}) *autoscaling.RefreshPreferences { + if len(l) == 0 || l[0] == nil { + return nil + } + + m := l[0].(map[string]interface{}) + + refreshPreferences := &autoscaling.RefreshPreferences{} + + if v, ok := m["instance_warmup"]; ok { + if v, null, _ := nullable.Int(v.(string)).Value(); !null { + refreshPreferences.InstanceWarmup = aws.Int64(v) + } + } + + if v, ok := m["min_healthy_percentage"]; ok { + refreshPreferences.MinHealthyPercentage = aws.Int64(int64(v.(int))) + } + + return refreshPreferences +} + +func autoScalingGroupRefreshInstances(conn *autoscaling.AutoScaling, asgName string, refreshConfig []interface{}) error { + input := createAutoScalingGroupInstanceRefreshInput(asgName, refreshConfig) + err := resource.Retry(waiter.InstanceRefreshStartedTimeout, func() *resource.RetryError { + _, err := conn.StartInstanceRefresh(input) + if tfawserr.ErrCodeEquals(err, autoscaling.ErrCodeInstanceRefreshInProgressFault) { + cancelErr := cancelAutoscalingInstanceRefresh(conn, asgName) + if cancelErr != nil { + return resource.NonRetryableError(cancelErr) + } + return resource.RetryableError(err) + } + if err != nil { + return resource.NonRetryableError(err) + } + return nil + }) + if isResourceTimeoutError(err) { + _, err = conn.StartInstanceRefresh(input) + } + if err != nil { + return fmt.Errorf("error starting Instance Refresh: %w", err) + } + + return nil +} + +func cancelAutoscalingInstanceRefresh(conn *autoscaling.AutoScaling, asgName string) error { + input := autoscaling.CancelInstanceRefreshInput{ + AutoScalingGroupName: aws.String(asgName), + } + output, err := conn.CancelInstanceRefresh(&input) + if tfawserr.ErrCodeEquals(err, autoscaling.ErrCodeActiveInstanceRefreshNotFoundFault) { + return nil + } + if err != nil { + return fmt.Errorf("error cancelling Instance Refresh on Auto Scaling Group (%s): %w", asgName, err) + } + if output == nil { + return fmt.Errorf("error cancelling Instance Refresh on Auto Scaling Group (%s): empty result", asgName) + } + + _, err = waiter.InstanceRefreshCancelled(conn, asgName, aws.StringValue(output.InstanceRefreshId)) + if err != nil { + return fmt.Errorf("error waiting for cancellation of Instance Refresh (%s) on Auto Scaling Group (%s): %w", aws.StringValue(output.InstanceRefreshId), asgName, err) + } + + return nil +} + +func validateAutoScalingGroupInstanceRefreshTriggerFields(i interface{}, path cty.Path) diag.Diagnostics { + v, ok := i.(string) + if !ok { + return diag.Errorf("expected type to be string") + } + + if v == "launch_configuration" || v == "launch_template" || v == "mixed_instances_policy" { + return diag.Diagnostics{ + diag.Diagnostic{ + Severity: diag.Warning, + Summary: fmt.Sprintf("'%s' always triggers an instance refresh and can be removed", v), + }, + } + } + + schema := resourceAwsAutoscalingGroup().Schema + for attr, attrSchema := range schema { + if v == attr { + if attrSchema.Computed && !attrSchema.Optional { + return diag.Errorf("'%s' is a read-only parameter and cannot be used to trigger an instance refresh", v) + } + return nil + } + } + + return diag.Errorf("'%s' is not a recognized parameter name for aws_autoscaling_group", v) +} diff --git a/aws/resource_aws_autoscaling_group_test.go b/aws/resource_aws_autoscaling_group_test.go index 4d98a71af06..90008081638 100644 --- a/aws/resource_aws_autoscaling_group_test.go +++ b/aws/resource_aws_autoscaling_group_test.go @@ -37,14 +37,14 @@ func testSweepAutoscalingGroups(region string) error { resp, err := conn.DescribeAutoScalingGroups(&autoscaling.DescribeAutoScalingGroupsInput{}) if err != nil { if testSweepSkipSweepError(err) { - log.Printf("[WARN] Skipping AutoScaling Group sweep for %s: %s", region, err) + log.Printf("[WARN] Skipping Auto Scaling Group sweep for %s: %s", region, err) return nil } - return fmt.Errorf("Error retrieving AutoScaling Groups in Sweeper: %s", err) + return fmt.Errorf("Error retrieving Auto Scaling Groups in Sweeper: %s", err) } if len(resp.AutoScalingGroups) == 0 { - log.Print("[DEBUG] No aws autoscaling groups to sweep") + log.Print("[DEBUG] No Auto Scaling Groups to sweep") return nil } @@ -98,6 +98,7 @@ func TestAccAWSAutoScalingGroup_basic(t *testing.T) { testAccCheckAWSAutoScalingGroupAttributes(&group, randName), testAccMatchResourceAttrRegionalARN("aws_autoscaling_group.bar", "arn", "autoscaling", regexp.MustCompile(`autoScalingGroup:.+`)), resource.TestCheckTypeSetElemAttrPair("aws_autoscaling_group.bar", "availability_zones.*", "data.aws_availability_zones.available", "names.0"), + testAccCheckAutoScalingInstanceRefreshCount(&group, 0), resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "default_cooldown", "300"), resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "desired_capacity", "4"), resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "enabled_metrics.#", "0"), @@ -125,6 +126,7 @@ func TestAccAWSAutoScalingGroup_basic(t *testing.T) { resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "termination_policies.1", "ClosestToNextInstanceHour"), resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "vpc_zone_identifier.#", "0"), resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "max_instance_lifetime", "0"), + resource.TestCheckNoResourceAttr("aws_autoscaling_group.bar", "instance_refresh.#"), ), }, { @@ -146,12 +148,10 @@ func TestAccAWSAutoScalingGroup_basic(t *testing.T) { Check: resource.ComposeTestCheckFunc( testAccCheckAWSAutoScalingGroupExists("aws_autoscaling_group.bar", &group), testAccCheckAWSLaunchConfigurationExists("aws_launch_configuration.new", &lc), - resource.TestCheckResourceAttr( - "aws_autoscaling_group.bar", "desired_capacity", "5"), - resource.TestCheckResourceAttr( - "aws_autoscaling_group.bar", "termination_policies.0", "ClosestToNextInstanceHour"), - resource.TestCheckResourceAttr( - "aws_autoscaling_group.bar", "protect_from_scale_in", "true"), + testAccCheckAutoScalingInstanceRefreshCount(&group, 0), + resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "desired_capacity", "5"), + resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "termination_policies.0", "ClosestToNextInstanceHour"), + resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "protect_from_scale_in", "true"), testLaunchConfigurationName("aws_autoscaling_group.bar", &lc), testAccCheckAutoscalingTags(&group.Tags, "FromTags1Changed", map[string]interface{}{ "value": "value1changed", @@ -550,8 +550,7 @@ func TestAccAWSAutoScalingGroup_withPlacementGroup(t *testing.T) { Config: testAccAWSAutoScalingGroupConfig_withPlacementGroup(randName), Check: resource.ComposeTestCheckFunc( testAccCheckAWSAutoScalingGroupExists("aws_autoscaling_group.bar", &group), - resource.TestCheckResourceAttr( - "aws_autoscaling_group.bar", "placement_group", randName), + resource.TestCheckResourceAttr("aws_autoscaling_group.bar", "placement_group", randName), ), }, { @@ -993,6 +992,162 @@ func TestAccAWSAutoScalingGroup_ALB_TargetGroups_ELBCapacity(t *testing.T) { }) } +func TestAccAWSAutoScalingGroup_InstanceRefresh_Basic(t *testing.T) { + var group autoscaling.Group + resourceName := "aws_autoscaling_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSAutoScalingGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAwsAutoScalingGroupConfig_InstanceRefresh_Basic(), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSAutoScalingGroupExists(resourceName, &group), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.#", "1"), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.strategy", "Rolling"), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.preferences.#", "0"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "force_delete", + "wait_for_capacity_timeout", + "instance_refresh", + }, + }, + { + Config: testAccAwsAutoScalingGroupConfig_InstanceRefresh_Full(), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSAutoScalingGroupExists(resourceName, &group), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.#", "1"), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.strategy", "Rolling"), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.preferences.#", "1"), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.preferences.0.instance_warmup", "10"), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.preferences.0.min_healthy_percentage", "50"), + ), + }, + { + Config: testAccAwsAutoScalingGroupConfig_InstanceRefresh_Disabled(), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSAutoScalingGroupExists(resourceName, &group), + resource.TestCheckNoResourceAttr(resourceName, "instance_refresh.#"), + ), + }, + }, + }) +} + +func TestAccAWSAutoScalingGroup_InstanceRefresh_Start(t *testing.T) { + var group autoscaling.Group + resourceName := "aws_autoscaling_group.test" + launchConfigurationName := "aws_launch_configuration.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSAutoScalingGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAwsAutoScalingGroupConfig_InstanceRefresh_Start("one"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSAutoScalingGroupExists(resourceName, &group), + resource.TestCheckResourceAttrPair(resourceName, "launch_configuration", launchConfigurationName, "name"), + testAccCheckAutoScalingInstanceRefreshCount(&group, 0), + ), + }, + { + Config: testAccAwsAutoScalingGroupConfig_InstanceRefresh_Start("two"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSAutoScalingGroupExists(resourceName, &group), + resource.TestCheckResourceAttrPair(resourceName, "launch_configuration", launchConfigurationName, "name"), + testAccCheckAutoScalingInstanceRefreshCount(&group, 1), + testAccCheckAutoScalingInstanceRefreshStatus(&group, 0, autoscaling.InstanceRefreshStatusPending, autoscaling.InstanceRefreshStatusInProgress), + ), + }, + { + Config: testAccAwsAutoScalingGroupConfig_InstanceRefresh_Start("three"), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSAutoScalingGroupExists(resourceName, &group), + resource.TestCheckResourceAttrPair(resourceName, "launch_configuration", launchConfigurationName, "name"), + testAccCheckAutoScalingInstanceRefreshCount(&group, 2), + testAccCheckAutoScalingInstanceRefreshStatus(&group, 0, autoscaling.InstanceRefreshStatusPending, autoscaling.InstanceRefreshStatusInProgress), + testAccCheckAutoScalingInstanceRefreshStatus(&group, 1, autoscaling.InstanceRefreshStatusCancelled), + ), + }, + }, + }) +} + +func TestAccAWSAutoScalingGroup_InstanceRefresh_Triggers(t *testing.T) { + var group autoscaling.Group + resourceName := "aws_autoscaling_group.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSAutoScalingGroupDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAwsAutoScalingGroupConfig_InstanceRefresh_Basic(), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSAutoScalingGroupExists(resourceName, &group), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.#", "1"), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.strategy", "Rolling"), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.preferences.#", "0"), + ), + }, + { + Config: testAccAwsAutoScalingGroupConfig_InstanceRefresh_Triggers(), + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSAutoScalingGroupExists(resourceName, &group), + resource.TestCheckResourceAttr(resourceName, "instance_refresh.0.triggers.#", "1"), + resource.TestCheckTypeSetElemAttr(resourceName, "instance_refresh.0.triggers.*", "tags"), + testAccCheckAutoScalingInstanceRefreshCount(&group, 1), + testAccCheckAutoScalingInstanceRefreshStatus(&group, 0, autoscaling.InstanceRefreshStatusPending, autoscaling.InstanceRefreshStatusInProgress), + ), + }, + }, + }) +} + +func testAccCheckAWSAutoScalingGroupExists(n string, group *autoscaling.Group) 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 Auto Scaling Group ID is set") + } + + conn := testAccProvider.Meta().(*AWSClient).autoscalingconn + + describeGroups, err := conn.DescribeAutoScalingGroups( + &autoscaling.DescribeAutoScalingGroupsInput{ + AutoScalingGroupNames: []*string{aws.String(rs.Primary.ID)}, + }) + + if err != nil { + return err + } + + if len(describeGroups.AutoScalingGroups) != 1 || + *describeGroups.AutoScalingGroups[0].AutoScalingGroupName != rs.Primary.ID { + return fmt.Errorf("Auto Scaling Group not found") + } + + *group = *describeGroups.AutoScalingGroups[0] + + return nil + } +} + func testAccCheckAWSAutoScalingGroupDestroy(s *terraform.State) error { conn := testAccProvider.Meta().(*AWSClient).autoscalingconn @@ -1010,7 +1165,7 @@ func testAccCheckAWSAutoScalingGroupDestroy(s *terraform.State) error { if err == nil { if len(describeGroups.AutoScalingGroups) != 0 && *describeGroups.AutoScalingGroups[0].AutoScalingGroupName == rs.Primary.ID { - return fmt.Errorf("AutoScaling Group still exists") + return fmt.Errorf("Auto Scaling Group still exists") } } @@ -1030,7 +1185,7 @@ func testAccCheckAWSAutoScalingGroupDestroy(s *terraform.State) error { func testAccCheckAWSAutoScalingGroupAttributes(group *autoscaling.Group, name string) resource.TestCheckFunc { return func(s *terraform.State) error { if *group.AutoScalingGroupName != name { - return fmt.Errorf("Bad Autoscaling Group name, expected (%s), got (%s)", name, *group.AutoScalingGroupName) + return fmt.Errorf("Bad Auto Scaling Group name, expected (%s), got (%s)", name, *group.AutoScalingGroupName) } if *group.MaxSize != 5 { @@ -1086,39 +1241,6 @@ func testAccCheckAWSAutoScalingGroupAttributesLoadBalancer(group *autoscaling.Gr } } -func testAccCheckAWSAutoScalingGroupExists(n string, group *autoscaling.Group) 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 AutoScaling Group ID is set") - } - - conn := testAccProvider.Meta().(*AWSClient).autoscalingconn - - describeGroups, err := conn.DescribeAutoScalingGroups( - &autoscaling.DescribeAutoScalingGroupsInput{ - AutoScalingGroupNames: []*string{aws.String(rs.Primary.ID)}, - }) - - if err != nil { - return err - } - - if len(describeGroups.AutoScalingGroups) != 1 || - *describeGroups.AutoScalingGroups[0].AutoScalingGroupName != rs.Primary.ID { - return fmt.Errorf("AutoScaling Group not found") - } - - *group = *describeGroups.AutoScalingGroups[0] - - return nil - } -} - func testLaunchConfigurationName(n string, lc *autoscaling.LaunchConfiguration) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[n] @@ -2283,6 +2405,7 @@ resource "aws_launch_configuration" "foobar" { instance_type = "t2.micro" } +# TODO: Unused? resource "aws_placement_group" "test" { name = "asg_pg_%s" strategy = "cluster" @@ -4226,3 +4349,399 @@ resource "aws_autoscaling_group" "test" { } `, rName) } + +func testAccAwsAutoScalingGroupConfig_InstanceRefresh_Basic() string { + return fmt.Sprintf(` +resource "aws_autoscaling_group" "test" { + availability_zones = [data.aws_availability_zones.current.names[0]] + max_size = 2 + min_size = 1 + desired_capacity = 1 + launch_configuration = aws_launch_configuration.test.name + + instance_refresh { + strategy = "Rolling" + } +} + +data "aws_ami" "test" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["amzn-ami-hvm-*-x86_64-gp2"] + } +} + +data "aws_availability_zones" "current" { + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} + +resource "aws_launch_configuration" "test" { + image_id = data.aws_ami.test.id + instance_type = "t3.nano" +} +`) +} + +func testAccAwsAutoScalingGroupConfig_InstanceRefresh_Full() string { + return fmt.Sprintf(` +resource "aws_autoscaling_group" "test" { + availability_zones = [data.aws_availability_zones.current.names[0]] + max_size = 2 + min_size = 1 + desired_capacity = 1 + launch_configuration = aws_launch_configuration.test.name + + instance_refresh { + strategy = "Rolling" + preferences { + instance_warmup = 10 + min_healthy_percentage = 50 + } + } +} + +data "aws_ami" "test" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["amzn-ami-hvm-*-x86_64-gp2"] + } +} + +data "aws_availability_zones" "current" { + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} + +resource "aws_launch_configuration" "test" { + image_id = data.aws_ami.test.id + instance_type = "t3.nano" +} +`) +} + +func testAccAwsAutoScalingGroupConfig_InstanceRefresh_Disabled() string { + return fmt.Sprintf(` +resource "aws_autoscaling_group" "test" { + availability_zones = [data.aws_availability_zones.current.names[0]] + max_size = 2 + min_size = 1 + desired_capacity = 1 + launch_configuration = aws_launch_configuration.test.name +} + +data "aws_ami" "test" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["amzn-ami-hvm-*-x86_64-gp2"] + } +} + +data "aws_availability_zones" "current" { + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} + +resource "aws_launch_configuration" "test" { + image_id = data.aws_ami.test.id + instance_type = "t3.nano" +} +`) +} + +func testAccAwsAutoScalingGroupConfig_InstanceRefresh_Start(launchConfigurationName string) string { + return fmt.Sprintf(` +resource "aws_autoscaling_group" "test" { + availability_zones = [data.aws_availability_zones.current.names[0]] + max_size = 2 + min_size = 1 + desired_capacity = 1 + launch_configuration = aws_launch_configuration.test.name + + instance_refresh { + strategy = "Rolling" + } +} + +data "aws_ami" "test" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["amzn-ami-hvm-*-x86_64-gp2"] + } +} + +data "aws_availability_zones" "current" { + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} + +resource "aws_launch_configuration" "test" { + name_prefix = %[1]q + image_id = data.aws_ami.test.id + instance_type = "t3.nano" + + lifecycle { + create_before_destroy = true + } +} +`, launchConfigurationName) +} + +func testAccAwsAutoScalingGroupConfig_InstanceRefresh_Triggers() string { + return fmt.Sprintf(` +resource "aws_autoscaling_group" "test" { + availability_zones = [data.aws_availability_zones.current.names[0]] + max_size = 2 + min_size = 1 + desired_capacity = 1 + launch_configuration = aws_launch_configuration.test.name + + instance_refresh { + strategy = "Rolling" + triggers = ["tags"] + } + + tag { + key = "Key" + value = "Value" + propagate_at_launch = true + } +} + +data "aws_ami" "test" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["amzn-ami-hvm-*-x86_64-gp2"] + } +} + +data "aws_availability_zones" "current" { + state = "available" + + filter { + name = "opt-in-status" + values = ["opt-in-not-required"] + } +} + +resource "aws_launch_configuration" "test" { + image_id = data.aws_ami.test.id + instance_type = "t3.nano" +} +`) +} + +func testAccCheckAutoScalingInstanceRefreshCount(group *autoscaling.Group, expected int) resource.TestCheckFunc { + return func(state *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).autoscalingconn + + input := autoscaling.DescribeInstanceRefreshesInput{ + AutoScalingGroupName: group.AutoScalingGroupName, + } + resp, err := conn.DescribeInstanceRefreshes(&input) + if err != nil { + return fmt.Errorf("error describing Auto Scaling Group (%s) Instance Refreshes: %w", aws.StringValue(group.AutoScalingGroupName), err) + } + + if len(resp.InstanceRefreshes) != expected { + return fmt.Errorf("expected %d Instance Refreshes, got %d", expected, len(resp.InstanceRefreshes)) + } + return nil + } +} + +func testAccCheckAutoScalingInstanceRefreshStatus(group *autoscaling.Group, offset int, expected ...string) resource.TestCheckFunc { + return func(state *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).autoscalingconn + + input := autoscaling.DescribeInstanceRefreshesInput{ + AutoScalingGroupName: group.AutoScalingGroupName, + } + resp, err := conn.DescribeInstanceRefreshes(&input) + if err != nil { + return fmt.Errorf("error describing Auto Scaling Group (%s) Instance Refreshes: %w", aws.StringValue(group.AutoScalingGroupName), err) + } + + if len(resp.InstanceRefreshes) < offset { + return fmt.Errorf("expected at least %d Instance Refreshes, got %d", offset+1, len(resp.InstanceRefreshes)) + } + + actual := aws.StringValue(resp.InstanceRefreshes[offset].Status) + for _, s := range expected { + if actual == s { + return nil + } + } + return fmt.Errorf("expected Instance Refresh at index %d to be in %q, got %q", offset, expected, actual) + } +} + +func TestCreateAutoScalingGroupInstanceRefreshInput(t *testing.T) { + const asgName = "test-asg" + testCases := []struct { + name string + input []interface{} + expected *autoscaling.StartInstanceRefreshInput + }{ + { + name: "empty list", + input: []interface{}{}, + expected: nil, + }, + { + name: "nil", + input: []interface{}{nil}, + expected: nil, + }, + { + name: "defaults", + input: []interface{}{map[string]interface{}{ + "strategy": "Rolling", + "preferences": []interface{}{}, + }}, + expected: &autoscaling.StartInstanceRefreshInput{ + AutoScalingGroupName: aws.String(asgName), + Strategy: aws.String("Rolling"), + Preferences: nil, + }, + }, + { + name: "instance_warmup only", + input: []interface{}{map[string]interface{}{ + "strategy": "Rolling", + "preferences": []interface{}{ + map[string]interface{}{ + "instance_warmup": "60", + }, + }, + }}, + expected: &autoscaling.StartInstanceRefreshInput{ + AutoScalingGroupName: aws.String(asgName), + Strategy: aws.String("Rolling"), + Preferences: &autoscaling.RefreshPreferences{ + InstanceWarmup: aws.Int64(60), + MinHealthyPercentage: nil, + }, + }, + }, + { + name: "instance_warmup zero", + input: []interface{}{map[string]interface{}{ + "strategy": "Rolling", + "preferences": []interface{}{ + map[string]interface{}{ + "instance_warmup": "0", + }, + }, + }}, + expected: &autoscaling.StartInstanceRefreshInput{ + AutoScalingGroupName: aws.String(asgName), + Strategy: aws.String("Rolling"), + Preferences: &autoscaling.RefreshPreferences{ + InstanceWarmup: aws.Int64(0), + MinHealthyPercentage: nil, + }, + }, + }, + { + name: "instance_warmup empty string", + input: []interface{}{map[string]interface{}{ + "strategy": "Rolling", + "preferences": []interface{}{ + map[string]interface{}{ + "instance_warmup": "", + "min_healthy_percentage": 80, + }, + }, + }}, + expected: &autoscaling.StartInstanceRefreshInput{ + AutoScalingGroupName: aws.String(asgName), + Strategy: aws.String("Rolling"), + Preferences: &autoscaling.RefreshPreferences{ + InstanceWarmup: nil, + MinHealthyPercentage: aws.Int64(80), + }, + }, + }, + { + name: "min_healthy_percentage only", + input: []interface{}{map[string]interface{}{ + "strategy": "Rolling", + "preferences": []interface{}{ + map[string]interface{}{ + "min_healthy_percentage": 80, + }, + }, + }}, + expected: &autoscaling.StartInstanceRefreshInput{ + AutoScalingGroupName: aws.String(asgName), + Strategy: aws.String("Rolling"), + Preferences: &autoscaling.RefreshPreferences{ + InstanceWarmup: nil, + MinHealthyPercentage: aws.Int64(80), + }, + }, + }, + { + name: "preferences", + input: []interface{}{map[string]interface{}{ + "strategy": "Rolling", + "preferences": []interface{}{ + map[string]interface{}{ + "instance_warmup": "60", + "min_healthy_percentage": 80, + }, + }, + }}, + expected: &autoscaling.StartInstanceRefreshInput{ + AutoScalingGroupName: aws.String(asgName), + Strategy: aws.String("Rolling"), + Preferences: &autoscaling.RefreshPreferences{ + InstanceWarmup: aws.Int64(60), + MinHealthyPercentage: aws.Int64(80), + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + got := createAutoScalingGroupInstanceRefreshInput(asgName, testCase.input) + + if !reflect.DeepEqual(got, testCase.expected) { + t.Errorf("got %s, expected %s", got, testCase.expected) + } + }) + } +} diff --git a/aws/resource_aws_autoscaling_group_waiting.go b/aws/resource_aws_autoscaling_group_waiting.go index 1e3279c3d3d..4a432d149a1 100644 --- a/aws/resource_aws_autoscaling_group_waiting.go +++ b/aws/resource_aws_autoscaling_group_waiting.go @@ -41,7 +41,7 @@ func waitForASGCapacity( return resource.NonRetryableError(err) } if g == nil { - log.Printf("[WARN] Autoscaling Group (%s) not found, removing from state", d.Id()) + log.Printf("[WARN] Auto Scaling Group (%s) not found, removing from state", d.Id()) d.SetId("") return nil } @@ -57,11 +57,11 @@ func waitForASGCapacity( g, err := getAwsAutoscalingGroup(d.Id(), meta.(*AWSClient).autoscalingconn) if err != nil { - return fmt.Errorf("Error getting autoscaling group info: %s", err) + return fmt.Errorf("Error getting Auto Scaling Group info: %s", err) } if g == nil { - log.Printf("[WARN] Autoscaling Group (%s) not found, removing from state", d.Id()) + log.Printf("[WARN] Auto Scaling Group (%s) not found, removing from state", d.Id()) d.SetId("") return nil } diff --git a/go.mod b/go.mod index 597534f085f..409d552f47b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/fatih/color v1.9.0 // indirect github.com/hashicorp/aws-sdk-go-base v0.7.0 github.com/hashicorp/go-cleanhttp v0.5.1 + github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/go-hclog v0.10.0 // indirect github.com/hashicorp/go-multierror v1.1.0 github.com/hashicorp/go-version v1.2.1 @@ -17,6 +18,7 @@ require ( github.com/mattn/go-colorable v0.1.7 // indirect github.com/mitchellh/copystructure v1.0.0 github.com/mitchellh/go-homedir v1.1.0 + github.com/mitchellh/go-testing-interface v1.0.4 github.com/pquerna/otp v1.3.0 github.com/stretchr/testify v1.6.1 // indirect gopkg.in/yaml.v2 v2.3.0 diff --git a/website/docs/r/autoscaling_group.html.markdown b/website/docs/r/autoscaling_group.html.markdown index 8f533d9c82b..bfceea975dd 100644 --- a/website/docs/r/autoscaling_group.html.markdown +++ b/website/docs/r/autoscaling_group.html.markdown @@ -3,7 +3,7 @@ subcategory: "Autoscaling" layout: "aws" page_title: "AWS: aws_autoscaling_group" description: |- - Provides an AutoScaling Group resource. + Provides an Auto Scaling Group resource. --- # Resource: aws_autoscaling_group @@ -174,7 +174,7 @@ resource "aws_autoscaling_group" "example" { } ``` -## Interpolated tags +### Interpolated tags ```hcl variable "extra_tags" { @@ -217,15 +217,60 @@ resource "aws_autoscaling_group" "bar" { } ``` +### Automatically refresh all instances after the group is updated + +```hcl +resource "aws_autoscaling_group" "example" { + availability_zones = ["us-east-1a"] + desired_capacity = 1 + max_size = 2 + min_size = 1 + + launch_template { + id = aws_launch_template.example.id + version = aws_launch_template.example.latest_version + } + + tag { + key = "Key" + value = "Value" + propagate_at_launch = true + } + + instance_refresh { + strategy = "Rolling" + preferences { + min_healthy_percentage = 50 + } + triggers = ["tag"] + } +} + +data "aws_ami" "example" { + most_recent = true + owners = ["amazon"] + + filter { + name = "name" + values = ["amzn-ami-hvm-*-x86_64-gp2"] + } +} + +resource "aws_launch_template" "example" { + image_id = data.aws_ami.example.id + instance_type = "t3.nano" +} +``` + ## Argument Reference The following arguments are supported: -* `name` - (Optional) The name of the auto scaling group. By default generated by Terraform. +* `name` - (Optional) The name of the Auto Scaling Group. By default generated by Terraform. * `name_prefix` - (Optional) Creates a unique name beginning with the specified prefix. Conflicts with `name`. -* `max_size` - (Required) The maximum size of the auto scale group. -* `min_size` - (Required) The minimum size of the auto scale group. +* `max_size` - (Required) The maximum size of the Auto Scaling Group. +* `min_size` - (Required) The minimum size of the Auto Scaling Group. (See also [Waiting for Capacity](#waiting-for-capacity) below.) * `availability_zones` - (Optional) A list of one or more availability zones for the group. Used for EC2-Classic and default subnets when not specified with `vpc_zone_identifier` argument. Conflicts with `vpc_zone_identifier`. * `capacity_rebalance` - (Optional) Indicates whether capacity rebalance is enabled. Otherwise, capacity rebalance is disabled. @@ -235,18 +280,18 @@ The following arguments are supported: * `mixed_instances_policy` (Optional) Configuration block containing settings to define launch targets for Auto Scaling groups. Defined below. * `initial_lifecycle_hook` - (Optional) One or more [Lifecycle Hooks](http://docs.aws.amazon.com/autoscaling/latest/userguide/lifecycle-hooks.html) - to attach to the autoscaling group **before** instances are launched. The + to attach to the Auto Scaling Group **before** instances are launched. The syntax is exactly the same as the separate [`aws_autoscaling_lifecycle_hook`](/docs/providers/aws/r/autoscaling_lifecycle_hook.html) resource, without the `autoscaling_group_name` attribute. Please note that this will only work when creating - a new autoscaling group. For all other use-cases, please use `aws_autoscaling_lifecycle_hook` resource. + a new Auto Scaling Group. For all other use-cases, please use `aws_autoscaling_lifecycle_hook` resource. * `health_check_grace_period` - (Optional, Default: 300) Time (in seconds) after instance comes into service before checking health. * `health_check_type` - (Optional) "EC2" or "ELB". Controls how health checking is done. * `desired_capacity` - (Optional) The number of Amazon EC2 instances that should be running in the group. (See also [Waiting for Capacity](#waiting-for-capacity) below.) -* `force_delete` - (Optional) Allows deleting the autoscaling group without waiting - for all instances in the pool to terminate. You can force an autoscaling group to delete +* `force_delete` - (Optional) Allows deleting the Auto Scaling Group without waiting + for all instances in the pool to terminate. You can force an Auto Scaling Group to delete even if it's in the process of scaling a resource. Normally, Terraform drains all the instances before deleting the group. This bypasses that behavior and potentially leaves resources dangling. @@ -254,9 +299,9 @@ The following arguments are supported: group names. Only valid for classic load balancers. For ALBs, use `target_group_arns` instead. * `vpc_zone_identifier` (Optional) A list of subnet IDs to launch resources in. Subnets automatically determine which availability zones the group will reside. Conflicts with `availability_zones`. * `target_group_arns` (Optional) A set of `aws_alb_target_group` ARNs, for use with Application or Network Load Balancing. -* `termination_policies` (Optional) A list of policies to decide how the instances in the auto scale group should be terminated. The allowed values are `OldestInstance`, `NewestInstance`, `OldestLaunchConfiguration`, `ClosestToNextInstanceHour`, `OldestLaunchTemplate`, `AllocationStrategy`, `Default`. -* `suspended_processes` - (Optional) A list of processes to suspend for the AutoScaling Group. The allowed values are `Launch`, `Terminate`, `HealthCheck`, `ReplaceUnhealthy`, `AZRebalance`, `AlarmNotification`, `ScheduledActions`, `AddToLoadBalancer`. -Note that if you suspend either the `Launch` or `Terminate` process types, it can prevent your autoscaling group from functioning properly. +* `termination_policies` (Optional) A list of policies to decide how the instances in the Auto Scaling Group should be terminated. The allowed values are `OldestInstance`, `NewestInstance`, `OldestLaunchConfiguration`, `ClosestToNextInstanceHour`, `OldestLaunchTemplate`, `AllocationStrategy`, `Default`. +* `suspended_processes` - (Optional) A list of processes to suspend for the Auto Scaling Group. The allowed values are `Launch`, `Terminate`, `HealthCheck`, `ReplaceUnhealthy`, `AZRebalance`, `AlarmNotification`, `ScheduledActions`, `AddToLoadBalancer`. +Note that if you suspend either the `Launch` or `Terminate` process types, it can prevent your Auto Scaling Group from functioning properly. * `tag` (Optional) Configuration block(s) containing resource tags. Conflicts with `tags`. Documented below. * `tags` (Optional) Set of maps containing resource tags. Conflicts with `tag`. Documented below. * `placement_group` (Optional) The name of the placement group into which you'll launch your instances, if any. @@ -268,19 +313,22 @@ Note that if you suspend either the `Launch` or `Terminate` process types, it ca for Capacity](#waiting-for-capacity) below.) Setting this to "0" causes Terraform to skip all Capacity Waiting behavior. * `min_elb_capacity` - (Optional) Setting this causes Terraform to wait for - this number of instances from this autoscaling group to show up healthy in the + this number of instances from this Auto Scaling Group to show up healthy in the ELB only on creation. Updates will not wait on ELB instance number changes. (See also [Waiting for Capacity](#waiting-for-capacity) below.) * `wait_for_elb_capacity` - (Optional) Setting this will cause Terraform to wait - for exactly this number of healthy instances from this autoscaling group in + for exactly this number of healthy instances from this Auto Scaling Group in all attached load balancers on both create and update operations. (Takes precedence over `min_elb_capacity` behavior.) (See also [Waiting for Capacity](#waiting-for-capacity) below.) * `protect_from_scale_in` (Optional) Allows setting instance protection. The - autoscaling group will not select instances with this setting for termination + Auto Scaling Group will not select instances with this setting for termination during scale in events. * `service_linked_role_arn` (Optional) The ARN of the service-linked role that the ASG will use to call other AWS services * `max_instance_lifetime` (Optional) The maximum amount of time, in seconds, that an instance can be in service, values must be either equal to 0 or between 604800 and 31536000 seconds. +* `instance_refresh` - (Optional) If this block is configured, start an + [Instance Refresh](https://docs.aws.amazon.com/autoscaling/ec2/userguide/asg-instance-refresh.html) + when this Auto Scaling Group is updated. Defined [below](#instance_refresh). ### launch_template @@ -348,21 +396,37 @@ This allows the construction of dynamic lists of tags which is not possible usin ~> **NOTE:** Other AWS APIs may automatically add special tags to their associated Auto Scaling Group for management purposes, such as ECS Capacity Providers adding the `AmazonECSManaged` tag. These generally should be included in the configuration so Terraform does not attempt to remove them and so if the `min_size` was greater than zero on creation, that these tag(s) are applied to any initial EC2 Instances in the Auto Scaling Group. If these tag(s) were missing in the Auto Scaling Group configuration on creation, affected EC2 Instances missing the tags may require manual intervention of adding the tags to ensure they work properly with the other AWS service. +### instance_refresh + +This configuration block supports the following: + +* `strategy` - (Required) The strategy to use for instance refresh. The only allowed value is `Rolling`. See [StartInstanceRefresh Action](https://docs.aws.amazon.com/autoscaling/ec2/APIReference/API_StartInstanceRefresh.html#API_StartInstanceRefresh_RequestParameters) for more information. +* `preferences` - (Optional) Override default parameters for Instance Refresh. + * `instance_warmup_seconds` - (Optional) The number of seconds until a newly launched instance is configured and ready to use. Default behavior is to use the Auto Scaling Group's health check grace period. + * `min_healthy_percentage` - (Optional) The amount of capacity in the Auto Scaling group that must remain healthy during an instance refresh to allow the operation to continue, as a percentage of the desired capacity of the Auto Scaling group. Defaults to `90`. +* `triggers` - (Optional) Set of additional property names that will trigger an Instance Refresh. A refresh will always be triggered by a change in any of `launch_configuration`, `launch_template`, or `mixed_instances_policy`. + +~> **NOTE:** A refresh is started when any of the following Auto Scaling Group properties change: `launch_configuration`, `launch_template`, `mixed_instances_policy`. Additional properties can be specified in the `triggers` property of `instance_refresh`. + +~> **NOTE:** Auto Scaling Groups support up to one active instance refresh at a time. When this resource is updated, any existing refresh is cancelled. + +~> **NOTE:** Depending on health check settings and group size, an instance refresh may take a long time or fail. This resource does not wait for the instance refresh to complete. + ## Attributes Reference In addition to all arguments above, the following attributes are exported: -* `id` - The autoscaling group id. -* `arn` - The ARN for this AutoScaling Group -* `availability_zones` - The availability zones of the autoscale group. -* `min_size` - The minimum size of the autoscale group -* `max_size` - The maximum size of the autoscale group +* `id` - The Auto Scaling Group id. +* `arn` - The ARN for this Auto Scaling Group +* `availability_zones` - The availability zones of the Auto Scaling Group. +* `min_size` - The minimum size of the Auto Scaling Group +* `max_size` - The maximum size of the Auto Scaling Group * `default_cooldown` - Time between a scaling activity and the succeeding scaling activity. -* `name` - The name of the autoscale group +* `name` - The name of the Auto Scaling Group * `health_check_grace_period` - Time after instance comes into service before checking health. * `health_check_type` - "EC2" or "ELB". Controls how health checking is done. * `desired_capacity` -The number of Amazon EC2 instances that should be running in the group. -* `launch_configuration` - The launch configuration of the autoscale group +* `launch_configuration` - The launch configuration of the Auto Scaling Group * `vpc_zone_identifier` (Optional) - The VPC zone identifier ~> **NOTE:** When using `ELB` as the `health_check_type`, `health_check_grace_period` is required. @@ -372,7 +436,7 @@ the `initial_lifecycle_hook` attribute from this resource, or via the separate [`aws_autoscaling_lifecycle_hook`](/docs/providers/aws/r/autoscaling_lifecycle_hook.html) resource. `initial_lifecycle_hook` exists here because any lifecycle hooks added with `aws_autoscaling_lifecycle_hook` will not be added until the -autoscaling group has been created, and depending on your +Auto Scaling Group has been created, and depending on your [capacity](#waiting-for-capacity) settings, after the initial instances have been launched, creating unintended behavior. If you need hooks to run on all instances, add them with `initial_lifecycle_hook` here, but take @@ -451,7 +515,7 @@ for more information. ## Import -AutoScaling Groups can be imported using the `name`, e.g. +Auto Scaling Groups can be imported using the `name`, e.g. ``` $ terraform import aws_autoscaling_group.web web-asg