diff --git a/.changelog/21485.txt b/.changelog/21485.txt new file mode 100644 index 00000000000..98125186732 --- /dev/null +++ b/.changelog/21485.txt @@ -0,0 +1,7 @@ +```release-note:new-resource +aws_appstream_user +``` + +```release-note:new-resource +aws_appstream_stack_user_association +``` \ No newline at end of file diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 417c3cba0bb..265f1366e88 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -793,10 +793,12 @@ func Provider() *schema.Provider { "aws_apprunner_custom_domain_association": apprunner.ResourceCustomDomainAssociation(), "aws_apprunner_service": apprunner.ResourceService(), - "aws_appstream_directory_config": appstream.ResourceDirectoryConfig(), - "aws_appstream_fleet": appstream.ResourceFleet(), - "aws_appstream_image_builder": appstream.ResourceImageBuilder(), - "aws_appstream_stack": appstream.ResourceStack(), + "aws_appstream_directory_config": appstream.ResourceDirectoryConfig(), + "aws_appstream_fleet": appstream.ResourceFleet(), + "aws_appstream_image_builder": appstream.ResourceImageBuilder(), + "aws_appstream_stack": appstream.ResourceStack(), + "aws_appstream_user_stack_association": appstream.ResourceUserStackAssociation(), + "aws_appstream_user": appstream.ResourceUser(), "aws_appsync_api_key": appsync.ResourceAPIKey(), "aws_appsync_datasource": appsync.ResourceDataSource(), diff --git a/internal/service/appstream/find.go b/internal/service/appstream/find.go index 6ecab218910..ba9ee0e2a61 100644 --- a/internal/service/appstream/find.go +++ b/internal/service/appstream/find.go @@ -6,6 +6,8 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/appstream" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) // FindStackByName Retrieve a appstream stack by name @@ -82,12 +84,68 @@ func FindImageBuilderByName(ctx context.Context, conn *appstream.AppStream, name return !lastPage }) + if tfawserr.ErrCodeEquals(err, appstream.ErrCodeResourceNotFoundException) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + if err != nil { return nil, err } if result == nil { - return nil, nil + return nil, &resource.NotFoundError{ + Message: "Empty result", + LastRequest: input, + } + } + + return result, nil +} + +// FindUserByUserNameAndAuthType Retrieve a appstream fleet by Username and authentication type +func FindUserByUserNameAndAuthType(ctx context.Context, conn *appstream.AppStream, username, authType string) (*appstream.User, error) { + input := &appstream.DescribeUsersInput{ + AuthenticationType: aws.String(authType), + } + + var result *appstream.User + + err := describeUsersPagesWithContext(ctx, conn, input, func(page *appstream.DescribeUsersOutput, lastPage bool) bool { + if page == nil { + return !lastPage + } + + for _, user := range page.Users { + if user == nil { + continue + } + if aws.StringValue(user.UserName) == username { + result = user + return false + } + } + + return !lastPage + }) + + if tfawserr.ErrCodeEquals(err, appstream.ErrCodeResourceNotFoundException) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + if err != nil { + return nil, err + } + + if result == nil { + return nil, &resource.NotFoundError{ + Message: "Empty result", + LastRequest: input, + } } return result, nil diff --git a/internal/service/appstream/fleet_test.go b/internal/service/appstream/fleet_test.go index 1fd56009139..ded6a14d589 100644 --- a/internal/service/appstream/fleet_test.go +++ b/internal/service/appstream/fleet_test.go @@ -24,6 +24,7 @@ func init() { func testAccErrorCheckSkipAppStream(t *testing.T) resource.ErrorCheckFunc { return acctest.ErrorCheckSkipMessagesContaining(t, "ResourceNotFoundException: The image", + "InvalidParameterValueException: The AppStream 2.0 user pool feature", ) } diff --git a/internal/service/appstream/generate.go b/internal/service/appstream/generate.go index 1351395a902..fcfd8214a8b 100644 --- a/internal/service/appstream/generate.go +++ b/internal/service/appstream/generate.go @@ -1,4 +1,4 @@ -//go:generate go run ../../generate/listpages/main.go -ListOps=DescribeFleets,DescribeImageBuilders,DescribeStacks +//go:generate go run ../../generate/listpages/main.go -ListOps=DescribeFleets,DescribeImageBuilders,DescribeStacks,DescribeUsers //go:generate go run ../../generate/tags/main.go -ListTags -ServiceTagsMap -UpdateTags // ONLY generate directives and package declaration! Do not add anything else to this file. diff --git a/internal/service/appstream/list_pages_gen.go b/internal/service/appstream/list_pages_gen.go index 1b8ad8912f4..7d119c866c0 100644 --- a/internal/service/appstream/list_pages_gen.go +++ b/internal/service/appstream/list_pages_gen.go @@ -1,4 +1,4 @@ -// Code generated by "internal/generate/listpages/main.go -ListOps=DescribeFleets,DescribeImageBuilders,DescribeStacks"; DO NOT EDIT. +// Code generated by "internal/generate/listpages/main.go -ListOps=DescribeFleets,DescribeImageBuilders,DescribeStacks,DescribeUsers"; DO NOT EDIT. package appstream @@ -71,3 +71,24 @@ func describeStacksPagesWithContext(ctx context.Context, conn *appstream.AppStre } return nil } + +func describeUsersPages(conn *appstream.AppStream, input *appstream.DescribeUsersInput, fn func(*appstream.DescribeUsersOutput, bool) bool) error { + return describeUsersPagesWithContext(context.Background(), conn, input, fn) +} + +func describeUsersPagesWithContext(ctx context.Context, conn *appstream.AppStream, input *appstream.DescribeUsersInput, fn func(*appstream.DescribeUsersOutput, bool) bool) error { + for { + output, err := conn.DescribeUsersWithContext(ctx, input) + if err != nil { + return err + } + + lastPage := aws.StringValue(output.NextToken) == "" + if !fn(output, lastPage) || lastPage { + break + } + + input.NextToken = output.NextToken + } + return nil +} diff --git a/internal/service/appstream/status.go b/internal/service/appstream/status.go index 4e916fdf851..8f81eddbcd6 100644 --- a/internal/service/appstream/status.go +++ b/internal/service/appstream/status.go @@ -6,6 +6,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/appstream" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" ) //statusStackState fetches the fleet and its state @@ -57,3 +58,20 @@ func statusImageBuilderState(ctx context.Context, conn *appstream.AppStream, nam return imageBuilder, aws.StringValue(imageBuilder.State), nil } } + +//statusUserAvailable fetches the user available +func statusUserAvailable(ctx context.Context, conn *appstream.AppStream, username, authType string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + user, err := FindUserByUserNameAndAuthType(ctx, conn, username, authType) + + if tfresource.NotFound(err) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + return user, userAvailable, nil + } +} diff --git a/internal/service/appstream/user.go b/internal/service/appstream/user.go new file mode 100644 index 00000000000..9d208eb20fd --- /dev/null +++ b/internal/service/appstream/user.go @@ -0,0 +1,227 @@ +package appstream + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/appstream" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" +) + +func ResourceUser() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceUserCreate, + ReadWithoutTimeout: resourceUserRead, + UpdateWithoutTimeout: resourceUserUpdate, + DeleteWithoutTimeout: resourceUserDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "arn": { + Type: schema.TypeString, + Computed: true, + }, + "authentication_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(appstream.AuthenticationType_Values(), false), + }, + "created_time": { + Type: schema.TypeString, + Computed: true, + }, + "enabled": { + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + "first_name": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(0, 2048), + }, + "last_name": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(0, 2048), + }, + "send_email_notification": { + Type: schema.TypeBool, + Optional: true, + Default: true, + }, + "user_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(1, 128), + }, + }, + } +} + +func resourceUserCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).AppStreamConn + + userName := d.Get("user_name").(string) + authType := d.Get("authentication_type").(string) + + input := &appstream.CreateUserInput{ + AuthenticationType: aws.String(authType), + UserName: aws.String(userName), + } + + if v, ok := d.GetOk("first_name"); ok { + input.FirstName = aws.String(v.(string)) + } + + if v, ok := d.GetOk("last_name"); ok { + input.LastName = aws.String(v.(string)) + } + + if !d.Get("send_email_notification").(bool) { + input.MessageAction = aws.String(appstream.MessageActionSuppress) + } + + _, err := conn.CreateUserWithContext(ctx, input) + + id := EncodeUserID(userName, authType) + + if err != nil { + return diag.FromErr(fmt.Errorf("error creating AppStream User (%s): %w", id, err)) + } + + if _, err = waitUserAvailable(ctx, conn, userName, authType); err != nil { + return diag.FromErr(fmt.Errorf("error waiting for AppStream User (%s) to be available: %w", id, err)) + } + + // Enabling/disabling workflow + if !d.Get("enabled").(bool) { + input := &appstream.DisableUserInput{ + AuthenticationType: aws.String(authType), + UserName: aws.String(userName), + } + + _, err = conn.DisableUserWithContext(ctx, input) + if err != nil { + return diag.FromErr(fmt.Errorf("error disabling AppStream User (%s): %w", id, err)) + } + } + + d.SetId(id) + + return resourceUserRead(ctx, d, meta) +} + +func resourceUserRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).AppStreamConn + + userName, authType, err := DecodeUserID(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error decoding AppStream User ID (%s): %w", d.Id(), err)) + } + + user, err := FindUserByUserNameAndAuthType(ctx, conn, userName, authType) + if tfresource.NotFound(err) && !d.IsNewResource() { + log.Printf("[WARN] AppStream User (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + if err != nil { + return diag.FromErr(fmt.Errorf("error reading AppStream User (%s): %w", d.Id(), err)) + } + + d.Set("arn", user.Arn) + d.Set("authentication_type", user.AuthenticationType) + d.Set("created_time", aws.TimeValue(user.CreatedTime).Format(time.RFC3339)) + d.Set("enabled", user.Enabled) + d.Set("first_name", user.FirstName) + + d.Set("last_name", user.LastName) + d.Set("user_name", user.UserName) + + return nil +} + +func resourceUserUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).AppStreamConn + + userName, authType, err := DecodeUserID(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error decoding AppStream User ID (%s): %w", d.Id(), err)) + } + + if d.HasChange("enabled") { + if d.Get("enabled").(bool) { + input := &appstream.EnableUserInput{ + AuthenticationType: aws.String(authType), + UserName: aws.String(userName), + } + + _, err = conn.EnableUserWithContext(ctx, input) + if err != nil { + return diag.FromErr(fmt.Errorf("error enabling AppStream User (%s): %w", d.Id(), err)) + } + } else { + input := &appstream.DisableUserInput{ + AuthenticationType: aws.String(authType), + UserName: aws.String(userName), + } + + _, err = conn.DisableUserWithContext(ctx, input) + if err != nil { + return diag.FromErr(fmt.Errorf("error disabling AppStream User (%s): %w", d.Id(), err)) + } + } + } + + return nil +} + +func resourceUserDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).AppStreamConn + + userName, authType, err := DecodeUserID(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error decoding AppStream User ID (%s): %w", d.Id(), err)) + } + + _, err = conn.DeleteUserWithContext(ctx, &appstream.DeleteUserInput{ + AuthenticationType: aws.String(authType), + UserName: aws.String(userName), + }) + + if err != nil { + if tfawserr.ErrCodeEquals(err, appstream.ErrCodeResourceNotFoundException) { + return nil + } + return diag.FromErr(fmt.Errorf("error deleting AppStream User (%s): %w", d.Id(), err)) + } + + return nil +} + +func EncodeUserID(userName, authType string) string { + return fmt.Sprintf("%s/%s", userName, authType) +} + +func DecodeUserID(id string) (string, string, error) { + idParts := strings.SplitN(id, "/", 2) + if len(idParts) != 2 { + return "", "", fmt.Errorf("expected ID in format UserName/AuthenticationType, received: %s", id) + } + return idParts[0], idParts[1], nil +} diff --git a/internal/service/appstream/user_stack_association.go b/internal/service/appstream/user_stack_association.go new file mode 100644 index 00000000000..f2c86cb855d --- /dev/null +++ b/internal/service/appstream/user_stack_association.go @@ -0,0 +1,164 @@ +package appstream + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/appstream" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/go-multierror" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/hashicorp/terraform-provider-aws/internal/conns" +) + +func ResourceUserStackAssociation() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceUserStackAssociationCreate, + ReadWithoutTimeout: resourceUserStackAssociationRead, + UpdateWithoutTimeout: schema.NoopContext, + DeleteWithoutTimeout: resourceUserStackAssociationDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "authentication_type": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(appstream.AuthenticationType_Values(), false), + }, + "send_email_notification": { + Type: schema.TypeBool, + Optional: true, + }, + "stack_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringLenBetween(1, 128), + }, + "user_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + }, + } +} + +func resourceUserStackAssociationCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).AppStreamConn + + input := &appstream.UserStackAssociation{ + AuthenticationType: aws.String(d.Get("authentication_type").(string)), + StackName: aws.String(d.Get("stack_name").(string)), + UserName: aws.String(d.Get("user_name").(string)), + } + + if v, ok := d.GetOk("send_email_notification"); ok { + input.SendEmailNotification = aws.Bool(v.(bool)) + } + + id := EncodeUserStackAssociationID(d.Get("user_name").(string), d.Get("authentication_type").(string), d.Get("stack_name").(string)) + + output, err := conn.BatchAssociateUserStackWithContext(ctx, &appstream.BatchAssociateUserStackInput{ + UserStackAssociations: []*appstream.UserStackAssociation{input}, + }) + + if err != nil { + return diag.FromErr(fmt.Errorf("error creating AppStream User Stack Association (%s): %w", id, err)) + } + if len(output.Errors) > 0 { + var errs *multierror.Error + + for _, err := range output.Errors { + errs = multierror.Append(errs, fmt.Errorf("%s: %s", aws.StringValue(err.ErrorCode), aws.StringValue(err.ErrorMessage))) + } + return diag.FromErr(fmt.Errorf("error creating AppStream User Stack Association (%s): %w", id, errs)) + + } + + d.SetId(id) + + return resourceUserStackAssociationRead(ctx, d, meta) +} + +func resourceUserStackAssociationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).AppStreamConn + + userName, authType, stackName, err := DecodeUserStackAssociationID(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error decoding AppStream User Stack Association ID (%s): %w", d.Id(), err)) + } + + resp, err := conn.DescribeUserStackAssociationsWithContext(ctx, + &appstream.DescribeUserStackAssociationsInput{ + AuthenticationType: aws.String(authType), + StackName: aws.String(stackName), + UserName: aws.String(userName), + }) + + if !d.IsNewResource() && tfawserr.ErrCodeEquals(err, appstream.ErrCodeResourceNotFoundException) { + log.Printf("[WARN] AppStream User Stack Association (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if len(resp.UserStackAssociations) == 0 { + log.Printf("[WARN] AppStream User Stack Association (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + association := resp.UserStackAssociations[0] + + d.Set("authentication_type", association.AuthenticationType) + d.Set("stack_name", association.StackName) + d.Set("user_name", association.UserName) + + return nil +} + +func resourceUserStackAssociationDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).AppStreamConn + + userName, authType, stackName, err := DecodeUserStackAssociationID(d.Id()) + if err != nil { + return diag.FromErr(fmt.Errorf("error decoding AppStream User Stack Association ID (%s): %w", d.Id(), err)) + } + + input := &appstream.UserStackAssociation{ + AuthenticationType: aws.String(authType), + StackName: aws.String(stackName), + UserName: aws.String(userName), + } + + _, err = conn.BatchDisassociateUserStackWithContext(ctx, &appstream.BatchDisassociateUserStackInput{ + UserStackAssociations: []*appstream.UserStackAssociation{input}, + }) + + if err != nil { + if tfawserr.ErrCodeEquals(err, appstream.ErrCodeResourceNotFoundException) { + return nil + } + return diag.FromErr(fmt.Errorf("error deleting AppStream User Stack Association (%s): %w", d.Id(), err)) + } + return nil +} + +func EncodeUserStackAssociationID(userName, authType, stackName string) string { + return fmt.Sprintf("%s/%s/%s", userName, authType, stackName) +} + +func DecodeUserStackAssociationID(id string) (string, string, string, error) { + idParts := strings.SplitN(id, "/", 3) + if len(idParts) != 3 { + return "", "", "", fmt.Errorf("expected ID in format UserName/AuthenticationType/StackName, received: %s", id) + } + return idParts[0], idParts[1], idParts[2], nil +} diff --git a/internal/service/appstream/user_stack_association_test.go b/internal/service/appstream/user_stack_association_test.go new file mode 100644 index 00000000000..1c25e26f08b --- /dev/null +++ b/internal/service/appstream/user_stack_association_test.go @@ -0,0 +1,200 @@ +package appstream_test + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/appstream" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + sdkacctest "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfappstream "github.com/hashicorp/terraform-provider-aws/internal/service/appstream" +) + +func TestAccAppStreamUserStackAssociation_basic(t *testing.T) { + resourceName := "aws_appstream_user_stack_association.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + authType := "USERPOOL" + domain := acctest.RandomDomainName() + rEmail := acctest.RandomEmailAddress(domain) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckUserStackAssociationDestroy, + ErrorCheck: acctest.ErrorCheck(t, appstream.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccUserStackAssociationConfig(rName, authType, rEmail), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserStackAssociationExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "authentication_type", authType), + resource.TestCheckResourceAttr(resourceName, "stack_name", rName), + resource.TestCheckResourceAttr(resourceName, "user_name", rEmail), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccAppStreamUserStackAssociation_disappears(t *testing.T) { + resourceName := "aws_appstream_user_stack_association.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + authType := "USERPOOL" + domain := acctest.RandomDomainName() + rEmail := acctest.RandomEmailAddress(domain) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckUserStackAssociationDestroy, + ErrorCheck: acctest.ErrorCheck(t, appstream.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccUserStackAssociationConfig(rName, authType, rEmail), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserStackAssociationExists(resourceName), + acctest.CheckResourceDisappears(acctest.Provider, tfappstream.ResourceUserStackAssociation(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccAppStreamUserStackAssociation_complete(t *testing.T) { + resourceName := "aws_appstream_user_stack_association.test" + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + authType := "USERPOOL" + domain := acctest.RandomDomainName() + rEmail := acctest.RandomEmailAddress(domain) + rEmailUpdated := acctest.RandomEmailAddress(domain) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckUserStackAssociationDestroy, + ErrorCheck: acctest.ErrorCheck(t, appstream.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccUserStackAssociationConfig(rName, authType, rEmail), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserStackAssociationExists(resourceName), + ), + }, + { + Config: testAccUserStackAssociationConfig(rName, authType, rEmailUpdated), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserStackAssociationExists(resourceName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func testAccCheckUserStackAssociationExists(resourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).AppStreamConn + + userName, authType, stackName, err := tfappstream.DecodeUserStackAssociationID(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error decoding AppStream User Stack Association ID (%s): %w", rs.Primary.ID, err) + } + + resp, err := conn.DescribeUserStackAssociationsWithContext(context.Background(), &appstream.DescribeUserStackAssociationsInput{ + AuthenticationType: aws.String(authType), + StackName: aws.String(stackName), + UserName: aws.String(userName), + }) + + if err != nil { + return err + } + + if len(resp.UserStackAssociations) == 0 { + return fmt.Errorf("AppStream User Stack Association %q does not exist", rs.Primary.ID) + } + + return nil + } +} + +func testAccCheckUserStackAssociationDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).AppStreamConn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_appstream_user_stack_association" { + continue + } + + userName, authType, stackName, err := tfappstream.DecodeUserStackAssociationID(rs.Primary.ID) + if err != nil { + return fmt.Errorf("error decoding AppStream User Stack Association ID (%s): %w", rs.Primary.ID, err) + } + + resp, err := conn.DescribeUserStackAssociationsWithContext(context.Background(), &appstream.DescribeUserStackAssociationsInput{ + AuthenticationType: aws.String(authType), + StackName: aws.String(stackName), + UserName: aws.String(userName), + }) + + if tfawserr.ErrCodeEquals(err, appstream.ErrCodeResourceNotFoundException) { + continue + } + + if err != nil { + return err + } + + if len(resp.UserStackAssociations) > 0 { + return fmt.Errorf("AppStream User Stack Association %q still exists", rs.Primary.ID) + } + } + + return nil +} + +func testAccUserStackAssociationConfig(name, authType, userName string) string { + return fmt.Sprintf(` +resource "aws_appstream_stack" "test" { + name = %[1]q +} + +resource "aws_appstream_user" "test" { + authentication_type = %[2]q + user_name = %[3]q +} + +resource "aws_appstream_user_stack_association" "test" { + authentication_type = aws_appstream_user.test.authentication_type + stack_name = aws_appstream_stack.test.name + user_name = aws_appstream_user.test.user_name +} +`, name, authType, userName) +} diff --git a/internal/service/appstream/user_test.go b/internal/service/appstream/user_test.go new file mode 100644 index 00000000000..0b751c6b1d9 --- /dev/null +++ b/internal/service/appstream/user_test.go @@ -0,0 +1,224 @@ +package appstream_test + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/appstream" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfappstream "github.com/hashicorp/terraform-provider-aws/internal/service/appstream" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" +) + +func TestAccAppStreamUser_basic(t *testing.T) { + var userOutput appstream.User + resourceName := "aws_appstream_user.test" + authType := "USERPOOL" + domain := acctest.RandomDomainName() + rEmail := acctest.RandomEmailAddress(domain) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckUserDestroy, + ErrorCheck: acctest.ErrorCheck(t, appstream.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccUserConfig(authType, rEmail), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(resourceName, &userOutput), + resource.TestCheckResourceAttr(resourceName, "authentication_type", authType), + resource.TestCheckResourceAttr(resourceName, "user_name", rEmail), + acctest.CheckResourceAttrRFC3339(resourceName, "created_time"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"send_email_notification"}, + }, + }, + }) +} + +func TestAccAppStreamUser_disappears(t *testing.T) { + var userOutput appstream.User + resourceName := "aws_appstream_user.test" + authType := "USERPOOL" + domain := acctest.RandomDomainName() + rEmail := acctest.RandomEmailAddress(domain) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckUserDestroy, + ErrorCheck: acctest.ErrorCheck(t, appstream.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccUserConfig(authType, rEmail), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(resourceName, &userOutput), + acctest.CheckResourceDisappears(acctest.Provider, tfappstream.ResourceUser(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccAppStreamUser_complete(t *testing.T) { + var userOutput appstream.User + resourceName := "aws_appstream_user.test" + authType := "USERPOOL" + firstName := "John" + lastName := "Doe" + domain := acctest.RandomDomainName() + rEmail := acctest.RandomEmailAddress(domain) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(t) + }, + ProviderFactories: acctest.ProviderFactories, + CheckDestroy: testAccCheckUserDestroy, + ErrorCheck: acctest.ErrorCheck(t, appstream.EndpointsID), + Steps: []resource.TestStep{ + { + Config: testAccUserCompleteConfig(authType, rEmail, firstName, lastName, false), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(resourceName, &userOutput), + resource.TestCheckResourceAttr(resourceName, "authentication_type", authType), + resource.TestCheckResourceAttr(resourceName, "user_name", rEmail), + acctest.CheckResourceAttrRFC3339(resourceName, "created_time"), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"send_email_notification"}, + }, + { + Config: testAccUserCompleteConfig(authType, rEmail, firstName, lastName, true), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(resourceName, &userOutput), + resource.TestCheckResourceAttr(resourceName, "authentication_type", authType), + resource.TestCheckResourceAttr(resourceName, "user_name", rEmail), + acctest.CheckResourceAttrRFC3339(resourceName, "created_time"), + resource.TestCheckResourceAttr(resourceName, "enabled", "true"), + ), + }, + { + Config: testAccUserCompleteConfig(authType, rEmail, firstName, lastName, false), + Check: resource.ComposeTestCheckFunc( + testAccCheckUserExists(resourceName, &userOutput), + resource.TestCheckResourceAttr(resourceName, "authentication_type", authType), + resource.TestCheckResourceAttr(resourceName, "user_name", rEmail), + acctest.CheckResourceAttrRFC3339(resourceName, "created_time"), + resource.TestCheckResourceAttr(resourceName, "enabled", "false"), + ), + }, + }, + }) +} + +func testAccCheckUserExists(resourceName string, appStreamUser *appstream.User) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).AppStreamConn + + userName, authType, err := tfappstream.DecodeUserID(rs.Primary.ID) + if err != nil { + return err + } + + user, err := tfappstream.FindUserByUserNameAndAuthType(context.Background(), conn, userName, authType) + if tfresource.NotFound(err) { + return fmt.Errorf("AppStream User %q does not exist", rs.Primary.ID) + } + if err != nil { + return err + } + + *appStreamUser = *user + + return nil + } +} + +func testAccCheckUserDestroy(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).AppStreamConn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_appstream_user" { + continue + } + + userName, authType, err := tfappstream.DecodeUserID(rs.Primary.ID) + if err != nil { + return err + } + + resp, err := conn.DescribeUsersWithContext(context.Background(), &appstream.DescribeUsersInput{AuthenticationType: aws.String(authType)}) + + if tfawserr.ErrCodeEquals(err, appstream.ErrCodeResourceNotFoundException) { + continue + } + + if err != nil { + return err + } + + found := false + + for _, out := range resp.Users { + if aws.StringValue(out.UserName) == userName { + found = true + } + } + + if found { + return fmt.Errorf("AppStream User %q still exists", rs.Primary.ID) + } + } + + return nil +} + +func testAccUserConfig(authType, userName string) string { + return fmt.Sprintf(` +resource "aws_appstream_user" "test" { + authentication_type = %[1]q + user_name = %[2]q +} +`, authType, userName) +} + +func testAccUserCompleteConfig(authType, userName, firstName, lastName string, enabled bool) string { + return fmt.Sprintf(` +resource "aws_appstream_user" "test" { + authentication_type = %[1]q + user_name = %[2]q + first_name = %[3]q + last_name = %[4]q + enabled = %[5]t +} +`, authType, userName, firstName, lastName, enabled) +} diff --git a/internal/service/appstream/wait.go b/internal/service/appstream/wait.go index b0978861b26..a3824160b1a 100644 --- a/internal/service/appstream/wait.go +++ b/internal/service/appstream/wait.go @@ -23,6 +23,9 @@ const ( // imageBuilderStateTimeout Maximum amount of time to wait for the statusImageBuilderState to be RUNNING // or for the ImageBuilder to be deleted imageBuilderStateTimeout = 60 * time.Minute + // userOperationTimeout Maximum amount of time to wait for User operation eventual consistency + userOperationTimeout = 4 * time.Minute + userAvailable = "AVAILABLE" ) // waitStackStateDeleted waits for a deleted stack @@ -163,3 +166,20 @@ func waitImageBuilderStateDeleted(ctx context.Context, conn *appstream.AppStream return nil, err } + +// waitUserAvailable waits for a user be available +func waitUserAvailable(ctx context.Context, conn *appstream.AppStream, username, authType string) (*appstream.User, error) { + stateConf := &resource.StateChangeConf{ + Target: []string{userAvailable}, + Refresh: statusUserAvailable(ctx, conn, username, authType), + Timeout: userOperationTimeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + + if output, ok := outputRaw.(*appstream.User); ok { + return output, err + } + + return nil, err +} diff --git a/website/docs/r/appstream_user.html.markdown b/website/docs/r/appstream_user.html.markdown new file mode 100644 index 00000000000..ec733d0ac7a --- /dev/null +++ b/website/docs/r/appstream_user.html.markdown @@ -0,0 +1,54 @@ +--- +subcategory: "AppStream" +layout: "aws" +page_title: "AWS: aws_appstream_user" +description: |- + Provides an AppStream user +--- + +# Resource: aws_appstream_user + +Provides an AppStream user. + +## Example Usage + +```terraform +resource "aws_appstream_user" "example" { + authentication_type = "USERPOOL" + user_name = "EMAIL ADDRESS" + first_name = "FIRST NAME" + last_name = "LAST NAME" +} +``` + +## Argument Reference + +The following arguments are required: + +* `authentication_type` - (Required) Authentication type for the user. You must specify USERPOOL. Valid values: `API`, `SAML`, `USERPOOL` +* `user_name` - (Required) Email address of the user. + +The following arguments are optional: + +* `enabled` - (Optional) Specifies whether the user in the user pool is enabled. +* `first_name` - (Optional) First name, or given name, of the user. +* `last_name` - (Optional) Last name, or surname, of the user. +* `send_email_notification` - (Optional) Send an email notification. + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `arn` - ARN of the appstream user. +* `created_time` - Date and time, in UTC and extended RFC 3339 format, when the user was created. +* `id` - Unique ID of the appstream user. +* `status` - Status of the user in the user pool. + + +## Import + +`aws_appstream_user` can be imported using the `user_name` and `authentication_type` separated by a slash (`/`), e.g., + +``` +$ terraform import aws_appstream_user.example UserName/AuthenticationType +``` diff --git a/website/docs/r/appstream_user_stack_association.html.markdown b/website/docs/r/appstream_user_stack_association.html.markdown new file mode 100644 index 00000000000..8ddaa4fd0aa --- /dev/null +++ b/website/docs/r/appstream_user_stack_association.html.markdown @@ -0,0 +1,58 @@ +--- +subcategory: "AppStream" +layout: "aws" +page_title: "AWS: aws_appstream_user_stack_association" +description: |- + Manages an AppStream User Stack association. +--- + +# Resource: aws_appstream_user_stack_association + +Manages an AppStream User Stack association. + +## Example Usage + +```terraform +resource "aws_appstream_stack" "test" { + name = "STACK NAME" +} + +resource "aws_appstream_user" "test" { + authentication_type = "USERPOOL" + user_name = "EMAIL" +} + +resource "aws_appstream_user_stack_association" "test" { + authentication_type = aws_appstream_user.test.authentication_type + stack_name = aws_appstream_stack.test.name + user_name = aws_appstream_user.test.user_name +} +``` + +## Argument Reference + +The following arguments are required: + +* `authentication_type` - (Required) Authentication type for the user. +* `stack_name` (Required) Name of the stack that is associated with the user. +* `user_name` (Required) Email address of the user who is associated with the stack. + +The following arguments are optional: + +* `send_email_notification` - (Optional) Specifies whether a welcome email is sent to a user after the user is created in the user pool. + + +## Attributes Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - Unique ID of the appstream User Stack association. + + +## Import + +AppStream User Stack Association can be imported by using the `user_name`, `authentication_type`, and `stack_name`, separated by a slash (`/`), e.g., + +``` +$ terraform import aws_appstream_user_stack_association.example userName/auhtenticationType/stackName +```