diff --git a/aws/data_source_aws_instance.go b/aws/data_source_aws_instance.go index 3eb17e5c3593..6bc9b89e899b 100644 --- a/aws/data_source_aws_instance.go +++ b/aws/data_source_aws_instance.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/terraform-providers/terraform-provider-aws/aws/internal/hashcode" "github.com/terraform-providers/terraform-provider-aws/aws/internal/keyvaluetags" + tfiam "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/iam" ) func dataSourceAwsInstance() *schema.Resource { @@ -458,7 +459,18 @@ func instanceDescriptionAttributes(d *schema.ResourceData, instance *ec2.Instanc d.Set("private_dns", instance.PrivateDnsName) d.Set("private_ip", instance.PrivateIpAddress) d.Set("outpost_arn", instance.OutpostArn) - d.Set("iam_instance_profile", iamInstanceProfileArnToName(instance.IamInstanceProfile)) + + if instance.IamInstanceProfile != nil && instance.IamInstanceProfile.Arn != nil { + name, err := tfiam.InstanceProfileARNToName(aws.StringValue(instance.IamInstanceProfile.Arn)) + + if err != nil { + return fmt.Errorf("error setting iam_instance_profile: %w", err) + } + + d.Set("iam_instance_profile", name) + } else { + d.Set("iam_instance_profile", nil) + } // iterate through network interfaces, and set subnet, network_interface, public_addr if len(instance.NetworkInterfaces) > 0 { diff --git a/aws/internal/service/ec2/errors.go b/aws/internal/service/ec2/errors.go index 9162be33aede..ec18186d3016 100644 --- a/aws/internal/service/ec2/errors.go +++ b/aws/internal/service/ec2/errors.go @@ -19,6 +19,10 @@ const ( ErrCodeClientVpnRouteNotFound = "InvalidClientVpnRouteNotFound" ) +const ( + ErrCodeInvalidInstanceIDNotFound = "InvalidInstanceID.NotFound" +) + const ( InvalidSecurityGroupIDNotFound = "InvalidSecurityGroupID.NotFound" InvalidGroupNotFound = "InvalidGroup.NotFound" diff --git a/aws/internal/service/ec2/finder/finder.go b/aws/internal/service/ec2/finder/finder.go index 10e42d1facae..4a8966d6048d 100644 --- a/aws/internal/service/ec2/finder/finder.go +++ b/aws/internal/service/ec2/finder/finder.go @@ -74,6 +74,25 @@ func ClientVpnRouteByID(conn *ec2.EC2, routeID string) (*ec2.DescribeClientVpnRo return ClientVpnRoute(conn, endpointID, targetSubnetID, destinationCidr) } +// InstanceByID looks up a Instance by ID. When not found, returns nil and potentially an API error. +func InstanceByID(conn *ec2.EC2, id string) (*ec2.Instance, error) { + input := &ec2.DescribeInstancesInput{ + InstanceIds: aws.StringSlice([]string{id}), + } + + output, err := conn.DescribeInstances(input) + + if err != nil { + return nil, err + } + + if output == nil || len(output.Reservations) == 0 || output.Reservations[0] == nil || len(output.Reservations[0].Instances) == 0 || output.Reservations[0].Instances[0] == nil { + return nil, nil + } + + return output.Reservations[0].Instances[0], nil +} + // SecurityGroupByID looks up a security group by ID. When not found, returns nil and potentially an API error. func SecurityGroupByID(conn *ec2.EC2, id string) (*ec2.SecurityGroup, error) { req := &ec2.DescribeSecurityGroupsInput{ diff --git a/aws/internal/service/ec2/waiter/status.go b/aws/internal/service/ec2/waiter/status.go index 47a787755046..b563f3819e48 100644 --- a/aws/internal/service/ec2/waiter/status.go +++ b/aws/internal/service/ec2/waiter/status.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" tfec2 "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/ec2" "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/ec2/finder" + tfiam "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/iam" ) const ( @@ -210,6 +211,40 @@ func ClientVpnRouteStatus(conn *ec2.EC2, routeID string) resource.StateRefreshFu } } +// InstanceIamInstanceProfile fetches the Instance and its IamInstanceProfile +// +// The EC2 API accepts a name and always returns an ARN, so it is converted +// back to the name to prevent unexpected differences. +func InstanceIamInstanceProfile(conn *ec2.EC2, id string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + instance, err := finder.InstanceByID(conn, id) + + if tfawserr.ErrCodeEquals(err, tfec2.ErrCodeInvalidInstanceIDNotFound) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + if instance == nil { + return nil, "", nil + } + + if instance.IamInstanceProfile == nil || instance.IamInstanceProfile.Arn == nil { + return instance, "", nil + } + + name, err := tfiam.InstanceProfileARNToName(aws.StringValue(instance.IamInstanceProfile.Arn)) + + if err != nil { + return instance, "", err + } + + return instance, name, nil + } +} + const ( SecurityGroupStatusCreated = "Created" diff --git a/aws/internal/service/ec2/waiter/waiter.go b/aws/internal/service/ec2/waiter/waiter.go index c8c516294ef3..d7a5d99e2131 100644 --- a/aws/internal/service/ec2/waiter/waiter.go +++ b/aws/internal/service/ec2/waiter/waiter.go @@ -233,6 +233,24 @@ func ClientVpnRouteDeleted(conn *ec2.EC2, routeID string) (*ec2.ClientVpnRoute, return nil, err } +func InstanceIamInstanceProfileUpdated(conn *ec2.EC2, instanceID string, expectedValue string) (*ec2.Instance, error) { + stateConf := &resource.StateChangeConf{ + Target: []string{expectedValue}, + Refresh: InstanceIamInstanceProfile(conn, instanceID), + Timeout: InstanceAttributePropagationTimeout, + Delay: 10 * time.Second, + MinTimeout: 3 * time.Second, + } + + outputRaw, err := stateConf.WaitForState() + + if output, ok := outputRaw.(*ec2.Instance); ok { + return output, err + } + + return nil, err +} + func SecurityGroupCreated(conn *ec2.EC2, id string, timeout time.Duration) (*ec2.SecurityGroup, error) { stateConf := &resource.StateChangeConf{ Pending: []string{SecurityGroupStatusNotFound}, diff --git a/aws/internal/service/iam/arn.go b/aws/internal/service/iam/arn.go new file mode 100644 index 000000000000..c254c8228d3f --- /dev/null +++ b/aws/internal/service/iam/arn.go @@ -0,0 +1,40 @@ +package iam + +import ( + "fmt" + "strings" + + "github.com/aws/aws-sdk-go/aws/arn" +) + +const ( + ARNSeparator = "/" + ARNService = "iam" + + InstanceProfileResourcePrefix = "instance-profile" +) + +// InstanceProfileARNToName converts Amazon Resource Name (ARN) to Name. +func InstanceProfileARNToName(inputARN string) (string, error) { + parsedARN, err := arn.Parse(inputARN) + + if err != nil { + return "", fmt.Errorf("error parsing ARN (%s): %w", inputARN, err) + } + + if actual, expected := parsedARN.Service, ARNService; actual != expected { + return "", fmt.Errorf("expected service %s in ARN (%s), got: %s", expected, inputARN, actual) + } + + resourceParts := strings.Split(parsedARN.Resource, ARNSeparator) + + if actual, expected := len(resourceParts), 2; actual != expected { + return "", fmt.Errorf("expected %d resource parts in ARN (%s), got: %d", expected, inputARN, actual) + } + + if actual, expected := resourceParts[0], InstanceProfileResourcePrefix; actual != expected { + return "", fmt.Errorf("expected resource prefix %s in ARN (%s), got: %s", expected, inputARN, actual) + } + + return resourceParts[1], nil +} diff --git a/aws/internal/service/iam/arn_test.go b/aws/internal/service/iam/arn_test.go new file mode 100644 index 000000000000..2adc841f00b9 --- /dev/null +++ b/aws/internal/service/iam/arn_test.go @@ -0,0 +1,70 @@ +package iam_test + +import ( + "regexp" + "testing" + + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/iam" +) + +func TestInstanceProfileARNToName(t *testing.T) { + testCases := []struct { + TestName string + InputARN string + ExpectedError *regexp.Regexp + ExpectedName string + }{ + { + TestName: "empty ARN", + InputARN: "", + ExpectedError: regexp.MustCompile(`error parsing ARN`), + }, + { + TestName: "unparsable ARN", + InputARN: "test", + ExpectedError: regexp.MustCompile(`error parsing ARN`), + }, + { + TestName: "invalid ARN service", + InputARN: "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678", + ExpectedError: regexp.MustCompile(`expected service iam`), + }, + { + TestName: "invalid ARN resource parts", + InputARN: "arn:aws:iam:us-east-1:123456789012:instance-profile/test/name", + ExpectedError: regexp.MustCompile(`expected 2 resource parts`), + }, + { + TestName: "invalid ARN resource prefix", + InputARN: "arn:aws:iam:us-east-1:123456789012:role/name", + ExpectedError: regexp.MustCompile(`expected resource prefix instance-profile`), + }, + { + TestName: "valid ARN", + InputARN: "arn:aws:iam:us-east-1:123456789012:instance-profile/name", + ExpectedName: "name", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.TestName, func(t *testing.T) { + got, err := iam.InstanceProfileARNToName(testCase.InputARN) + + if err == nil && testCase.ExpectedError != nil { + t.Fatalf("expected error %s, got no error", testCase.ExpectedError.String()) + } + + if err != nil && testCase.ExpectedError == nil { + t.Fatalf("got unexpected error: %s", err) + } + + if err != nil && !testCase.ExpectedError.MatchString(err.Error()) { + t.Fatalf("expected error %s, got: %s", testCase.ExpectedError.String(), err) + } + + if got != testCase.ExpectedName { + t.Errorf("got %s, expected %s", got, testCase.ExpectedName) + } + }) + } +} diff --git a/aws/resource_aws_instance.go b/aws/resource_aws_instance.go index 7e69c85f6c3f..f071e1d86fbd 100644 --- a/aws/resource_aws_instance.go +++ b/aws/resource_aws_instance.go @@ -23,6 +23,7 @@ import ( "github.com/terraform-providers/terraform-provider-aws/aws/internal/keyvaluetags" tfec2 "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/ec2" "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/ec2/waiter" + tfiam "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/iam" "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" ) @@ -813,7 +814,18 @@ func resourceAwsInstanceRead(d *schema.ResourceData, meta interface{}) error { d.Set("private_dns", instance.PrivateDnsName) d.Set("private_ip", instance.PrivateIpAddress) d.Set("outpost_arn", instance.OutpostArn) - d.Set("iam_instance_profile", iamInstanceProfileArnToName(instance.IamInstanceProfile)) + + if instance.IamInstanceProfile != nil && instance.IamInstanceProfile.Arn != nil { + name, err := tfiam.InstanceProfileARNToName(aws.StringValue(instance.IamInstanceProfile.Arn)) + + if err != nil { + return fmt.Errorf("error setting iam_instance_profile: %w", err) + } + + d.Set("iam_instance_profile", name) + } else { + d.Set("iam_instance_profile", nil) + } // Set configured Network Interface Device Index Slice // We only want to read, and populate state for the configured network_interface attachments. Otherwise, other @@ -1109,6 +1121,10 @@ func resourceAwsInstanceUpdate(d *schema.ResourceData, meta interface{}) error { } } } + + if _, err := waiter.InstanceIamInstanceProfileUpdated(conn, d.Id(), d.Get("iam_instance_profile").(string)); err != nil { + return fmt.Errorf("error waiting for EC2 Instance (%s) IAM Instance Profile update: %w", d.Id(), err) + } } // SourceDestCheck can only be modified on an instance without manually specified network interfaces. @@ -2466,14 +2482,6 @@ func waitForInstanceDeletion(conn *ec2.EC2, id string, timeout time.Duration) er return nil } -func iamInstanceProfileArnToName(ip *ec2.IamInstanceProfile) string { - if ip == nil || ip.Arn == nil { - return "" - } - parts := strings.Split(aws.StringValue(ip.Arn), "/") - return parts[len(parts)-1] -} - func userDataHashSum(user_data string) string { // Check whether the user_data is not Base64 encoded. // Always calculate hash of base64 decoded value since we