diff --git a/.changelog/42708.txt b/.changelog/42708.txt new file mode 100644 index 000000000000..64d58e83abaf --- /dev/null +++ b/.changelog/42708.txt @@ -0,0 +1,7 @@ +```release-note:new-resource +aws_transfer_web_app +``` + +```release-note:new-resource +aws_transfer_web_app_customization +``` diff --git a/internal/service/transfer/exports_test.go b/internal/service/transfer/exports_test.go index bee2d12e1deb..b6031ba3b137 100644 --- a/internal/service/transfer/exports_test.go +++ b/internal/service/transfer/exports_test.go @@ -5,16 +5,18 @@ package transfer // Exports for use in tests only. var ( - ResourceAccess = resourceAccess - ResourceAgreement = resourceAgreement - ResourceCertificate = resourceCertificate - ResourceConnector = resourceConnector - ResourceProfile = resourceProfile - ResourceServer = resourceServer - ResourceSSHKey = resourceSSHKey - ResourceTag = resourceTag - ResourceUser = resourceUser - ResourceWorkflow = resourceWorkflow + ResourceAccess = resourceAccess + ResourceAgreement = resourceAgreement + ResourceCertificate = resourceCertificate + ResourceConnector = resourceConnector + ResourceProfile = resourceProfile + ResourceServer = resourceServer + ResourceSSHKey = resourceSSHKey + ResourceTag = resourceTag + ResourceUser = resourceUser + ResourceWebApp = newResourceWebApp + ResourceWebAppCustomization = newResourceWebAppCustomization + ResourceWorkflow = resourceWorkflow FindAccessByTwoPartKey = findAccessByTwoPartKey FindAgreementByTwoPartKey = findAgreementByTwoPartKey @@ -26,4 +28,6 @@ var ( FindUserByTwoPartKey = findUserByTwoPartKey FindUserSSHKeyByThreePartKey = findUserSSHKeyByThreePartKey FindWorkflowByID = findWorkflowByID + FindWebAppByID = findWebAppByID + FindWebAppCustomizationByID = findWebAppCustomizationByID ) diff --git a/internal/service/transfer/service_package_gen.go b/internal/service/transfer/service_package_gen.go index 8d84829bf2a5..5b1823df704b 100644 --- a/internal/service/transfer/service_package_gen.go +++ b/internal/service/transfer/service_package_gen.go @@ -25,7 +25,21 @@ func (p *servicePackage) FrameworkDataSources(ctx context.Context) []*types.Serv } func (p *servicePackage) FrameworkResources(ctx context.Context) []*types.ServicePackageFrameworkResource { - return []*types.ServicePackageFrameworkResource{} + return []*types.ServicePackageFrameworkResource{ + { + Factory: newResourceWebApp, + TypeName: "aws_transfer_web_app", + Name: "Web App", + Tags: &types.ServicePackageResourceTags{ + IdentifierAttribute: names.AttrARN, + }, + }, + { + Factory: newResourceWebAppCustomization, + TypeName: "aws_transfer_web_app_customization", + Name: "Web App Customization", + }, + } } func (p *servicePackage) SDKDataSources(ctx context.Context) []*types.ServicePackageSDKDataSource { diff --git a/internal/service/transfer/sweep.go b/internal/service/transfer/sweep.go index b56ba5747fb6..c8ce8844690b 100644 --- a/internal/service/transfer/sweep.go +++ b/internal/service/transfer/sweep.go @@ -28,6 +28,8 @@ func RegisterSweepers() { "aws_transfer_server", }, }) + + awsv2.Register("aws_transfer_web_app", sweepWebApps) } func sweepServers(region string) error { diff --git a/internal/service/transfer/test-fixtures/Terraform-LogoMark_onDark.png b/internal/service/transfer/test-fixtures/Terraform-LogoMark_onDark.png new file mode 100644 index 000000000000..f727329c4f71 Binary files /dev/null and b/internal/service/transfer/test-fixtures/Terraform-LogoMark_onDark.png differ diff --git a/internal/service/transfer/test-fixtures/Terraform-LogoMark_onLight.png b/internal/service/transfer/test-fixtures/Terraform-LogoMark_onLight.png new file mode 100644 index 000000000000..0b712e042d68 Binary files /dev/null and b/internal/service/transfer/test-fixtures/Terraform-LogoMark_onLight.png differ diff --git a/internal/service/transfer/web_app.go b/internal/service/transfer/web_app.go new file mode 100644 index 000000000000..2999726cf654 --- /dev/null +++ b/internal/service/transfer/web_app.go @@ -0,0 +1,557 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package transfer + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/YakDriver/regexache" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/transfer" + awstypes "github.com/aws/aws-sdk-go-v2/service/transfer/types" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/create" + "github.com/hashicorp/terraform-provider-aws/internal/enum" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + "github.com/hashicorp/terraform-provider-aws/internal/errs/fwdiag" + "github.com/hashicorp/terraform-provider-aws/internal/framework" + "github.com/hashicorp/terraform-provider-aws/internal/framework/flex" + fwtypes "github.com/hashicorp/terraform-provider-aws/internal/framework/types" + "github.com/hashicorp/terraform-provider-aws/internal/sweep" + sweepfw "github.com/hashicorp/terraform-provider-aws/internal/sweep/framework" + tftags "github.com/hashicorp/terraform-provider-aws/internal/tags" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @FrameworkResource("aws_transfer_web_app", name="Web App") +// @Tags(identifierAttribute="arn") +func newResourceWebApp(_ context.Context) (resource.ResourceWithConfigure, error) { + r := &resourceWebApp{} + + r.SetDefaultCreateTimeout(5 * time.Minute) + r.SetDefaultDeleteTimeout(5 * time.Minute) + + return r, nil +} + +const ( + ResNameWebApp = "Web App" +) + +type resourceWebApp struct { + framework.ResourceWithConfigure + framework.WithTimeouts +} + +func (r *resourceWebApp) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "access_endpoint": schema.StringAttribute{ + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthAtMost(1024), + }, + }, + names.AttrARN: framework.ARNAttributeComputedOnly(), + names.AttrID: framework.IDAttribute(), + names.AttrTags: tftags.TagsAttribute(), + names.AttrTagsAll: tftags.TagsAttributeComputedOnly(), + "web_app_endpoint_policy": schema.StringAttribute{ + CustomType: fwtypes.StringEnumType[awstypes.WebAppEndpointPolicy](), + Optional: true, + Computed: true, + Validators: []validator.String{ + enum.FrameworkValidate[awstypes.WebAppEndpointPolicy](), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplaceIfConfigured(), + }, + }, + "web_app_units": schema.ListAttribute{ + CustomType: fwtypes.NewListNestedObjectTypeOf[webAppUnitsModel](ctx), + Optional: true, + Computed: true, + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + ElementType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "provisioned": types.Int64Type, + }, + }, + }, + }, + Blocks: map[string]schema.Block{ + "identity_provider_details": schema.ListNestedBlock{ + CustomType: fwtypes.NewListNestedObjectTypeOf[identityProviderDetailsModel](ctx), + Validators: []validator.List{ + listvalidator.IsRequired(), + listvalidator.SizeAtMost(1), + }, + NestedObject: schema.NestedBlockObject{ + Blocks: map[string]schema.Block{ + "identity_center_config": schema.ListNestedBlock{ + CustomType: fwtypes.NewListNestedObjectTypeOf[identityCenterConfigModel](ctx), + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "application_arn": schema.StringAttribute{ + Computed: true, + }, + "instance_arn": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(10, 1024), + stringvalidator.RegexMatches(regexache.MustCompile(`^arn:[\w-]+:sso:::instance/(sso)?ins-[a-zA-Z0-9-.]{16}$`), ""), + }, + }, + names.AttrRole: schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(20, 2048), + stringvalidator.RegexMatches(regexache.MustCompile(`^arn:.*role/\S+$`), ""), + }, + }, + }, + }, + }, + }, + }, + }, + names.AttrTimeouts: timeouts.Block(ctx, timeouts.Opts{ + Create: true, + Update: false, + Delete: true, + }), + }, + } +} + +func (r *resourceWebApp) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + conn := r.Meta().TransferClient(ctx) + + var plan resourceWebAppModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var input transfer.CreateWebAppInput + resp.Diagnostics.Append(flex.Expand(ctx, plan, &input)...) + if resp.Diagnostics.HasError() { + return + } + + input.Tags = getTagsIn(ctx) + + out, err := conn.CreateWebApp(ctx, &input) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionCreating, ResNameWebApp, "", err), + err.Error(), + ) + return + } + if out == nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionCreating, ResNameWebApp, "", nil), + errors.New("empty output").Error(), + ) + return + } + + plan.WebAppId = flex.StringToFramework(ctx, out.WebAppId) + + createTimeout := r.CreateTimeout(ctx, plan.Timeouts) + _, err = waitWebAppCreated(ctx, conn, plan.WebAppId.ValueString(), createTimeout) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionWaitingForCreation, ResNameWebApp, plan.WebAppId.String(), err), + err.Error(), + ) + return + } + + rout, _ := findWebAppByID(ctx, conn, plan.WebAppId.ValueString()) + resp.Diagnostics.Append(flex.Flatten(ctx, rout, &plan, flex.WithFieldNamePrefix("Described"))...) + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *resourceWebApp) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + conn := r.Meta().TransferClient(ctx) + + var state resourceWebAppModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := findWebAppByID(ctx, conn, state.WebAppId.ValueString()) + if tfresource.NotFound(err) { + resp.Diagnostics.Append(fwdiag.NewResourceNotFoundWarningDiagnostic(err)) + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionReading, ResNameWebApp, state.WebAppId.String(), err), + err.Error(), + ) + return + } + + resp.Diagnostics.Append(flex.Flatten(ctx, out, &state, flex.WithFieldNamePrefix("Described"))...) + if resp.Diagnostics.HasError() { + return + } + + setTagsOut(ctx, out.Tags) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *resourceWebApp) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + needUpdate := false + conn := r.Meta().TransferClient(ctx) + + var plan, state resourceWebAppModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + diff, d := flex.Diff(ctx, plan, state) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + + if diff.HasChanges() { + input := transfer.UpdateWebAppInput{ + WebAppId: state.WebAppId.ValueStringPointer(), + } + + if !state.AccessEndpoint.Equal(plan.AccessEndpoint) { + if v := plan.AccessEndpoint.ValueStringPointer(); v != nil && aws.ToString(v) != "" { + input.AccessEndpoint = v + } + needUpdate = true + } + if !state.IdentityProviderDetails.Equal(plan.IdentityProviderDetails) { + if v, diags := plan.IdentityProviderDetails.ToPtr(ctx); v != nil && !diags.HasError() { + if v, diags := v.IdentityCenterConfig.ToPtr(ctx); v != nil && !diags.HasError() { + input.IdentityProviderDetails = &awstypes.UpdateWebAppIdentityProviderDetailsMemberIdentityCenterConfig{ + Value: awstypes.UpdateWebAppIdentityCenterConfig{ + Role: v.Role.ValueStringPointer(), + }, + } + needUpdate = true + } + } + } + if !state.WebAppUnits.Equal(plan.WebAppUnits) { + if v, diags := plan.WebAppUnits.ToPtr(ctx); v != nil && !diags.HasError() { + if v, diags := plan.WebAppUnits.ToPtr(ctx); v != nil && !diags.HasError() { + input.WebAppUnits = &awstypes.WebAppUnitsMemberProvisioned{ + Value: flex.Int32ValueFromFrameworkInt64(ctx, v.Provisioned), + } + needUpdate = true + } + } + } + + if needUpdate { + out, err := conn.UpdateWebApp(ctx, &input) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionUpdating, ResNameWebApp, plan.WebAppId.String(), err), + err.Error(), + ) + return + } + if out == nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionUpdating, ResNameWebApp, plan.WebAppId.String(), nil), + errors.New("empty output").Error(), + ) + return + } + } + + if !state.Tags.Equal(plan.Tags) { + if err := updateTags(ctx, conn, plan.ARN.ValueString(), state.Tags, plan.Tags); err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionUpdating, ResNameWebApp, plan.WebAppId.String(), err), + err.Error(), + ) + return + } + } + + if resp.Diagnostics.HasError() { + return + } + } + + rout, _ := findWebAppByID(ctx, conn, plan.WebAppId.ValueString()) + resp.Diagnostics.Append(flex.Flatten(ctx, rout, &plan, flex.WithFieldNamePrefix("Described"))...) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *resourceWebApp) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + conn := r.Meta().TransferClient(ctx) + + var state resourceWebAppModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + input := transfer.DeleteWebAppInput{ + WebAppId: state.WebAppId.ValueStringPointer(), + } + + _, err := conn.DeleteWebApp(ctx, &input) + if err != nil { + if errs.IsA[*awstypes.ResourceNotFoundException](err) { + return + } + + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionDeleting, ResNameWebApp, state.WebAppId.String(), err), + err.Error(), + ) + return + } + + deleteTimeout := r.DeleteTimeout(ctx, state.Timeouts) + _, err = waitWebAppDeleted(ctx, conn, state.WebAppId.ValueString(), deleteTimeout) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionWaitingForDeletion, ResNameWebApp, state.WebAppId.String(), err), + err.Error(), + ) + return + } +} + +func (r *resourceWebApp) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root(names.AttrID), req, resp) +} + +const ( + statusNormal = "Normal" +) + +func waitWebAppCreated(ctx context.Context, conn *transfer.Client, id string, timeout time.Duration) (*awstypes.DescribedWebApp, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{}, + Target: []string{statusNormal}, + Refresh: statusWebApp(ctx, conn, id), + Timeout: timeout, + NotFoundChecks: 20, + ContinuousTargetOccurence: 2, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if out, ok := outputRaw.(*awstypes.DescribedWebApp); ok { + return out, err + } + + return nil, err +} + +func waitWebAppDeleted(ctx context.Context, conn *transfer.Client, id string, timeout time.Duration) (*awstypes.DescribedWebApp, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{statusNormal}, + Target: []string{}, + Refresh: statusWebApp(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if out, ok := outputRaw.(*awstypes.DescribedWebApp); ok { + return out, err + } + + return nil, err +} + +func statusWebApp(ctx context.Context, conn *transfer.Client, id string) retry.StateRefreshFunc { + return func() (any, string, error) { + out, err := findWebAppByID(ctx, conn, id) + if tfresource.NotFound(err) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + return out, statusNormal, nil + } +} + +func findWebAppByID(ctx context.Context, conn *transfer.Client, id string) (*awstypes.DescribedWebApp, error) { + input := transfer.DescribeWebAppInput{ + WebAppId: aws.String(id), + } + + out, err := conn.DescribeWebApp(ctx, &input) + if err != nil { + if errs.IsA[*awstypes.ResourceNotFoundException](err) { + return nil, &retry.NotFoundError{ + LastError: err, + LastRequest: &input, + } + } + + return nil, err + } + + if out == nil || out.WebApp == nil { + return nil, tfresource.NewEmptyResultError(&input) + } + + return out.WebApp, nil +} + +type resourceWebAppModel struct { + AccessEndpoint types.String `tfsdk:"access_endpoint"` + ARN types.String `tfsdk:"arn"` + IdentityProviderDetails fwtypes.ListNestedObjectValueOf[identityProviderDetailsModel] `tfsdk:"identity_provider_details"` + Tags tftags.Map `tfsdk:"tags"` + TagsAll tftags.Map `tfsdk:"tags_all"` + Timeouts timeouts.Value `tfsdk:"timeouts"` + WebAppEndpointPolicy fwtypes.StringEnum[awstypes.WebAppEndpointPolicy] `tfsdk:"web_app_endpoint_policy"` + WebAppUnits fwtypes.ListNestedObjectValueOf[webAppUnitsModel] `tfsdk:"web_app_units"` + WebAppId types.String `tfsdk:"id"` +} + +type identityProviderDetailsModel struct { + IdentityCenterConfig fwtypes.ListNestedObjectValueOf[identityCenterConfigModel] `tfsdk:"identity_center_config"` +} + +type identityCenterConfigModel struct { + ApplicationArn types.String `tfsdk:"application_arn"` + InstanceArn types.String `tfsdk:"instance_arn"` + Role types.String `tfsdk:"role"` +} + +type webAppUnitsModel struct { + Provisioned types.Int64 `tfsdk:"provisioned"` +} + +func sweepWebApps(ctx context.Context, client *conns.AWSClient) ([]sweep.Sweepable, error) { + input := transfer.ListWebAppsInput{} + conn := client.TransferClient(ctx) + var sweepResources []sweep.Sweepable + + pages := transfer.NewListWebAppsPaginator(conn, &input) + for pages.HasMorePages() { + page, err := pages.NextPage(ctx) + if err != nil { + return nil, err + } + + for _, v := range page.WebApps { + sweepResources = append(sweepResources, sweepfw.NewSweepResource(newResourceWebApp, client, + sweepfw.NewAttribute(names.AttrID, aws.ToString(v.WebAppId))), + ) + } + } + + return sweepResources, nil +} + +var ( + _ flex.Expander = webAppUnitsModel{} + _ flex.Flattener = &webAppUnitsModel{} + _ flex.Expander = identityProviderDetailsModel{} + _ flex.Flattener = &identityProviderDetailsModel{} +) + +func (m webAppUnitsModel) Expand(ctx context.Context) (any, diag.Diagnostics) { + var diags diag.Diagnostics + var v awstypes.WebAppUnits + + switch { + case !m.Provisioned.IsNull(): + var apiObject awstypes.WebAppUnitsMemberProvisioned + apiObject.Value = aws.ToInt32(flex.Int32FromFrameworkInt64(ctx, &m.Provisioned)) + v = &apiObject + } + + return v, diags +} + +func (m *webAppUnitsModel) Flatten(ctx context.Context, v any) diag.Diagnostics { + var diags diag.Diagnostics + switch t := v.(type) { + case awstypes.WebAppUnitsMemberProvisioned: + m.Provisioned = flex.Int32ToFrameworkInt64(ctx, &t.Value) + } + return diags +} + +func (m identityProviderDetailsModel) Expand(ctx context.Context) (any, diag.Diagnostics) { + var diags diag.Diagnostics + var v awstypes.WebAppIdentityProviderDetails + + switch { + case !m.IdentityCenterConfig.IsNull(): + data, d := m.IdentityCenterConfig.ToPtr(ctx) + diags.Append(d...) + if diags.HasError() { + return nil, diags + } + var apiObject awstypes.WebAppIdentityProviderDetailsMemberIdentityCenterConfig + diags.Append(flex.Expand(ctx, data, &apiObject.Value)...) + if diags.HasError() { + return nil, diags + } + v = &apiObject + } + + return v, diags +} + +func (m *identityProviderDetailsModel) Flatten(ctx context.Context, v any) diag.Diagnostics { + var diags diag.Diagnostics + + switch t := v.(type) { + case awstypes.DescribedWebAppIdentityProviderDetailsMemberIdentityCenterConfig: + var data identityCenterConfigModel + diags.Append(flex.Flatten(ctx, t.Value, &data)...) + if diags.HasError() { + return diags + } + m.IdentityCenterConfig = fwtypes.NewListNestedObjectValueOfPtrMust[identityCenterConfigModel](ctx, &data) + default: + diags.AddError("Interface Conversion Error", fmt.Sprintf("cannot flatten %T into %T", v, m)) + } + return diags +} diff --git a/internal/service/transfer/web_app_customization.go b/internal/service/transfer/web_app_customization.go new file mode 100644 index 000000000000..f0a326964ab7 --- /dev/null +++ b/internal/service/transfer/web_app_customization.go @@ -0,0 +1,427 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package transfer + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/transfer" + awstypes "github.com/aws/aws-sdk-go-v2/service/transfer/types" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-provider-aws/internal/create" + "github.com/hashicorp/terraform-provider-aws/internal/errs" + "github.com/hashicorp/terraform-provider-aws/internal/errs/fwdiag" + "github.com/hashicorp/terraform-provider-aws/internal/framework" + "github.com/hashicorp/terraform-provider-aws/internal/framework/flex" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + itypes "github.com/hashicorp/terraform-provider-aws/internal/types" + "github.com/hashicorp/terraform-provider-aws/names" +) + +// @FrameworkResource("aws_transfer_web_app_customization", name="Web App Customization") +func newResourceWebAppCustomization(_ context.Context) (resource.ResourceWithConfigure, error) { + r := &resourceWebAppCustomization{} + + r.SetDefaultCreateTimeout(5 * time.Minute) + r.SetDefaultDeleteTimeout(5 * time.Minute) + + return r, nil +} + +const ( + ResNameWebAppCustomization = "Web App Customization" +) + +type resourceWebAppCustomization struct { + framework.ResourceWithConfigure + framework.WithTimeouts +} + +func (r *resourceWebAppCustomization) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + names.AttrARN: framework.ARNAttributeComputedOnly(), + "favicon_file": schema.StringAttribute{ + // If faviconFile is not specified when calling the UpdateWebAppCustomization API, + // the existing favicon remains unchanged. + // Therefore, this field is marked as Optional and Computed. + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 20960), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + names.AttrID: framework.IDAttribute(), + "logo_file": schema.StringAttribute{ + // Same as favicon_file + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 51200), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "title": schema.StringAttribute{ + Optional: true, + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 100), + }, + }, + "web_app_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + Blocks: map[string]schema.Block{ + names.AttrTimeouts: timeouts.Block(ctx, timeouts.Opts{ + Create: true, + Update: false, + Delete: true, + }), + }, + } +} + +func (r *resourceWebAppCustomization) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + conn := r.Meta().TransferClient(ctx) + + var plan resourceWebAppCustomizationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + var input transfer.UpdateWebAppCustomizationInput + resp.Diagnostics.Append(flex.Expand(ctx, plan, &input)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := conn.UpdateWebAppCustomization(ctx, &input) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionCreating, ResNameWebAppCustomization, plan.ID.String(), err), + err.Error(), + ) + return + } + if out == nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionCreating, ResNameWebAppCustomization, plan.ID.String(), nil), + errors.New("empty output").Error(), + ) + return + } + + resp.Diagnostics.Append(flex.Flatten(ctx, out, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + plan.ID = flex.StringToFramework(ctx, out.WebAppId) + + createTimeout := r.CreateTimeout(ctx, plan.Timeouts) + _, err = waitWebAppCustomizationCreated(ctx, conn, plan.ID.ValueString(), createTimeout) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionWaitingForCreation, ResNameWebAppCustomization, plan.ID.String(), err), + err.Error(), + ) + return + } + + rout, _ := findWebAppCustomizationByID(ctx, conn, plan.ID.ValueString()) + resp.Diagnostics.Append(flex.Flatten(ctx, rout, &plan)...) + + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *resourceWebAppCustomization) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + conn := r.Meta().TransferClient(ctx) + + var state resourceWebAppCustomizationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := findWebAppCustomizationByID(ctx, conn, state.ID.ValueString()) + if tfresource.NotFound(err) { + resp.Diagnostics.Append(fwdiag.NewResourceNotFoundWarningDiagnostic(err)) + resp.State.RemoveResource(ctx) + return + } + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionReading, ResNameWebAppCustomization, state.ID.String(), err), + err.Error(), + ) + return + } + + resp.Diagnostics.Append(flex.Flatten(ctx, out, &state)...) + if resp.Diagnostics.HasError() { + return + } + + state.ID = flex.StringToFramework(ctx, out.WebAppId) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *resourceWebAppCustomization) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + conn := r.Meta().TransferClient(ctx) + + var plan, state resourceWebAppCustomizationModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + diff, d := flex.Diff(ctx, plan, state) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + + if diff.HasChanges() { + var input transfer.UpdateWebAppCustomizationInput + resp.Diagnostics.Append(flex.Expand(ctx, plan, &input)...) + if resp.Diagnostics.HasError() { + return + } + + out, err := conn.UpdateWebAppCustomization(ctx, &input) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionUpdating, ResNameWebAppCustomization, plan.ID.String(), err), + err.Error(), + ) + return + } + if out == nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionUpdating, ResNameWebAppCustomization, plan.ID.String(), nil), + errors.New("empty output").Error(), + ) + return + } + + resp.Diagnostics.Append(flex.Flatten(ctx, out, &plan)...) + if resp.Diagnostics.HasError() { + return + } + } + + rout, _ := findWebAppCustomizationByID(ctx, conn, plan.ID.ValueString()) + resp.Diagnostics.Append(flex.Flatten(ctx, rout, &plan)...) + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *resourceWebAppCustomization) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + conn := r.Meta().TransferClient(ctx) + + var state resourceWebAppCustomizationModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + input := transfer.DeleteWebAppCustomizationInput{ + WebAppId: state.ID.ValueStringPointer(), + } + + _, err := conn.DeleteWebAppCustomization(ctx, &input) + if err != nil { + if errs.IsA[*awstypes.ResourceNotFoundException](err) { + return + } + + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionDeleting, ResNameWebAppCustomization, state.ID.String(), err), + err.Error(), + ) + return + } + + deleteTimeout := r.DeleteTimeout(ctx, state.Timeouts) + _, err = waitWebAppCustomizationDeleted(ctx, conn, state.ID.ValueString(), deleteTimeout) + if err != nil { + resp.Diagnostics.AddError( + create.ProblemStandardMessage(names.Transfer, create.ErrActionWaitingForDeletion, ResNameWebAppCustomization, state.ID.String(), err), + err.Error(), + ) + return + } +} + +func (r *resourceWebAppCustomization) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root(names.AttrID), req, resp) +} + +func waitWebAppCustomizationCreated(ctx context.Context, conn *transfer.Client, id string, timeout time.Duration) (*awstypes.DescribedWebAppCustomization, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{}, + Target: []string{statusNormal}, + Refresh: statusWebAppCustomization(ctx, conn, id), + Timeout: timeout, + NotFoundChecks: 20, + ContinuousTargetOccurence: 2, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if out, ok := outputRaw.(*awstypes.DescribedWebAppCustomization); ok { + return out, err + } + + return nil, err +} + +func waitWebAppCustomizationDeleted(ctx context.Context, conn *transfer.Client, id string, timeout time.Duration) (*awstypes.DescribedWebAppCustomization, error) { + stateConf := &retry.StateChangeConf{ + Pending: []string{statusNormal}, + Target: []string{}, + Refresh: statusWebAppCustomization(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + if out, ok := outputRaw.(*awstypes.DescribedWebAppCustomization); ok { + return out, err + } + + return nil, err +} + +func statusWebAppCustomization(ctx context.Context, conn *transfer.Client, id string) retry.StateRefreshFunc { + return func() (any, string, error) { + out, err := findWebAppCustomizationByID(ctx, conn, id) + if tfresource.NotFound(err) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + return out, statusNormal, nil + } +} + +func findWebAppCustomizationByID(ctx context.Context, conn *transfer.Client, id string) (*awstypes.DescribedWebAppCustomization, error) { + input := transfer.DescribeWebAppCustomizationInput{ + WebAppId: aws.String(id), + } + + out, err := conn.DescribeWebAppCustomization(ctx, &input) + if err != nil { + if errs.IsA[*awstypes.ResourceNotFoundException](err) { + return nil, &retry.NotFoundError{ + LastError: err, + LastRequest: &input, + } + } + + return nil, err + } + + if out == nil || out.WebAppCustomization == nil { + return nil, tfresource.NewEmptyResultError(&input) + } + + return out.WebAppCustomization, nil +} + +type resourceWebAppCustomizationModel struct { + ARN types.String `tfsdk:"arn"` + FaviconFile types.String `tfsdk:"favicon_file"` + ID types.String `tfsdk:"id"` + LogoFile types.String `tfsdk:"logo_file"` + Timeouts timeouts.Value `tfsdk:"timeouts"` + Title types.String `tfsdk:"title"` + WebAppID types.String `tfsdk:"web_app_id"` +} + +var ( + _ flex.Expander = resourceWebAppCustomizationModel{} + _ flex.Flattener = &resourceWebAppCustomizationModel{} +) + +func (m resourceWebAppCustomizationModel) Expand(ctx context.Context) (any, diag.Diagnostics) { + var input transfer.UpdateWebAppCustomizationInput + var diags diag.Diagnostics + input.WebAppId = m.WebAppID.ValueStringPointer() + + if !m.FaviconFile.IsNull() && m.FaviconFile.ValueString() != "" { + if v, err := itypes.Base64Decode(m.FaviconFile.ValueString()); err != nil { + diags.AddError( + "Favicon File Decode Error", + "An unexpected error occurred while decoding the Favicon File. ", + ) + } else { + input.FaviconFile = v + } + } else { + input.FaviconFile = nil + } + if !m.LogoFile.IsNull() && m.LogoFile.ValueString() != "" { + if v, err := itypes.Base64Decode(m.LogoFile.ValueString()); err != nil { + diags.AddError( + "Logo File Decode Error", + "An unexpected error occurred while decoding the Logo File. ", + ) + } else { + input.LogoFile = v + } + } else { + input.LogoFile = nil + } + if !m.Title.IsNull() && m.Title.ValueString() != "" { + input.Title = m.Title.ValueStringPointer() + } else { + input.Title = aws.String("") + } + return &input, nil +} + +func (m *resourceWebAppCustomizationModel) Flatten(ctx context.Context, in any) diag.Diagnostics { + var diags diag.Diagnostics + switch t := in.(type) { + case awstypes.DescribedWebAppCustomization: + m.ARN = flex.StringToFramework(ctx, t.Arn) + m.FaviconFile = flex.StringToFramework(ctx, aws.String(itypes.Base64Encode(t.FaviconFile))) + m.ID = flex.StringToFramework(ctx, t.WebAppId) + m.LogoFile = flex.StringToFramework(ctx, aws.String(itypes.Base64Encode(t.LogoFile))) + m.Title = flex.StringToFramework(ctx, t.Title) + m.WebAppID = flex.StringToFramework(ctx, t.WebAppId) + case transfer.UpdateWebAppCustomizationOutput: + m.WebAppID = flex.StringToFramework(ctx, t.WebAppId) + default: + diags.AddError("Interface Conversion Error", fmt.Sprintf("cannot flatten %T into %T", in, m)) + } + return diags +} diff --git a/internal/service/transfer/web_app_customization_test.go b/internal/service/transfer/web_app_customization_test.go new file mode 100644 index 000000000000..0ed7e29ea7ef --- /dev/null +++ b/internal/service/transfer/web_app_customization_test.go @@ -0,0 +1,293 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package transfer_test + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + + awstypes "github.com/aws/aws-sdk-go-v2/service/transfer/types" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/create" + tftransfer "github.com/hashicorp/terraform-provider-aws/internal/service/transfer" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + itypes "github.com/hashicorp/terraform-provider-aws/internal/types" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccTransferWebAppCustomization_basic(t *testing.T) { + ctx := acctest.Context(t) + + var webappcustomization awstypes.DescribedWebAppCustomization + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app_customization.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + testAccPreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppCustomizationDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppCustomizationConfig_basic(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppCustomizationExists(ctx, resourceName, &webappcustomization), + resource.TestCheckResourceAttrPair(resourceName, "web_app_id", "aws_transfer_web_app.test", names.AttrID), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccTransferWebAppCustomization_title(t *testing.T) { + ctx := acctest.Context(t) + + var webappcustomization awstypes.DescribedWebAppCustomization + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app_customization.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + testAccPreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppCustomizationDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppCustomizationConfig_title(rName, "test"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppCustomizationExists(ctx, resourceName, &webappcustomization), + resource.TestCheckResourceAttrPair(resourceName, "web_app_id", "aws_transfer_web_app.test", names.AttrID), + resource.TestCheckResourceAttr(resourceName, "title", "test"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccWebAppCustomizationConfig_title(rName, "test2"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppCustomizationExists(ctx, resourceName, &webappcustomization), + resource.TestCheckResourceAttrPair(resourceName, "web_app_id", "aws_transfer_web_app.test", names.AttrID), + resource.TestCheckResourceAttr(resourceName, "title", "test2"), + ), + }, + { + Config: testAccWebAppCustomizationConfig_basic(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppCustomizationExists(ctx, resourceName, &webappcustomization), + resource.TestCheckResourceAttrPair(resourceName, "web_app_id", "aws_transfer_web_app.test", names.AttrID), + resource.TestCheckNoResourceAttr(resourceName, "title"), + ), + }, + }, + }) +} + +func TestAccTransferWebAppCustomization_files(t *testing.T) { + ctx := acctest.Context(t) + + var webappcustomization awstypes.DescribedWebAppCustomization + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app_customization.test" + darkBytes, _ := os.ReadFile("test-fixtures/Terraform-LogoMark_onDark.png") + lightBytes, _ := os.ReadFile("test-fixtures/Terraform-LogoMark_onLight.png") + darkFileBase64Encoded := itypes.Base64Encode(darkBytes) + lightFileBase64Encoded := itypes.Base64Encode(lightBytes) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + testAccPreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppCustomizationDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppCustomizationConfig_files(rName, "Dark", "Light"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppCustomizationExists(ctx, resourceName, &webappcustomization), + resource.TestCheckResourceAttrPair(resourceName, "web_app_id", "aws_transfer_web_app.test", names.AttrID), + resource.TestCheckResourceAttr(resourceName, "logo_file", darkFileBase64Encoded), + resource.TestCheckResourceAttr(resourceName, "favicon_file", lightFileBase64Encoded), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccWebAppCustomizationConfig_files(rName, "Light", "Dark"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppCustomizationExists(ctx, resourceName, &webappcustomization), + resource.TestCheckResourceAttrPair(resourceName, "web_app_id", "aws_transfer_web_app.test", names.AttrID), + resource.TestCheckResourceAttr(resourceName, "logo_file", lightFileBase64Encoded), + resource.TestCheckResourceAttr(resourceName, "favicon_file", darkFileBase64Encoded), + ), + }, + { + Config: testAccWebAppCustomizationConfig_basic(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppCustomizationExists(ctx, resourceName, &webappcustomization), + resource.TestCheckResourceAttrPair(resourceName, "web_app_id", "aws_transfer_web_app.test", names.AttrID), + resource.TestCheckResourceAttr(resourceName, "logo_file", lightFileBase64Encoded), + resource.TestCheckResourceAttr(resourceName, "favicon_file", darkFileBase64Encoded), + ), + }, + }, + }) +} + +func TestAccTransferWebAppCustomization_disappears(t *testing.T) { + ctx := acctest.Context(t) + + var webappcustomization awstypes.DescribedWebAppCustomization + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app_customization.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + testAccPreCheck(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppCustomizationDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppCustomizationConfig_basic(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppCustomizationExists(ctx, resourceName, &webappcustomization), + acctest.CheckFrameworkResourceDisappears(ctx, acctest.Provider, tftransfer.ResourceWebAppCustomization, resourceName), + ), + ExpectNonEmptyPlan: true, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PostApplyPostRefresh: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceName, plancheck.ResourceActionCreate), + }, + }, + }, + }, + }) +} + +func testAccCheckWebAppCustomizationDestroy(ctx context.Context) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).TransferClient(ctx) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_transfer_web_app_customization" { + continue + } + + _, err := tftransfer.FindWebAppCustomizationByID(ctx, conn, rs.Primary.ID) + if tfresource.NotFound(err) { + return nil + } + if err != nil { + return create.Error(names.Transfer, create.ErrActionCheckingDestroyed, tftransfer.ResNameWebAppCustomization, rs.Primary.ID, err) + } + + return create.Error(names.Transfer, create.ErrActionCheckingDestroyed, tftransfer.ResNameWebAppCustomization, rs.Primary.ID, errors.New("not destroyed")) + } + + return nil + } +} + +func testAccCheckWebAppCustomizationExists(ctx context.Context, name string, webappcustomization *awstypes.DescribedWebAppCustomization) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return create.Error(names.Transfer, create.ErrActionCheckingExistence, tftransfer.ResNameWebAppCustomization, name, errors.New("not found")) + } + + if rs.Primary.ID == "" { + return create.Error(names.Transfer, create.ErrActionCheckingExistence, tftransfer.ResNameWebAppCustomization, name, errors.New("not set")) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).TransferClient(ctx) + + resp, err := tftransfer.FindWebAppCustomizationByID(ctx, conn, rs.Primary.ID) + if err != nil { + return create.Error(names.Transfer, create.ErrActionCheckingExistence, tftransfer.ResNameWebAppCustomization, rs.Primary.ID, err) + } + + *webappcustomization = *resp + + return nil + } +} + +func testAccWebAppCustomizationConfig_base(rName string) string { + return acctest.ConfigCompose( + testAccWebAppConfig_base(rName), ` +resource "aws_transfer_web_app" "test" { + identity_provider_details { + identity_center_config { + instance_arn = tolist(data.aws_ssoadmin_instances.test.arns)[0] + role = aws_iam_role.test.arn + } + } +} +`) +} + +func testAccWebAppCustomizationConfig_basic(rName string) string { + return acctest.ConfigCompose( + testAccWebAppCustomizationConfig_base(rName), ` +resource "aws_transfer_web_app_customization" "test" { + web_app_id = aws_transfer_web_app.test.id +} +`) +} + +func testAccWebAppCustomizationConfig_title(rName, title string) string { + return acctest.ConfigCompose( + testAccWebAppCustomizationConfig_base(rName), + fmt.Sprintf(` +resource "aws_transfer_web_app_customization" "test" { + web_app_id = aws_transfer_web_app.test.id + title = %[1]q +} +`, title)) +} + +func testAccWebAppCustomizationConfig_files(rName, logoFileSuffix, faviconFileSuffix string) string { + return acctest.ConfigCompose( + testAccWebAppCustomizationConfig_base(rName), + fmt.Sprintf(` +resource "aws_transfer_web_app_customization" "test" { + web_app_id = aws_transfer_web_app.test.id + logo_file = filebase64("test-fixtures/Terraform-LogoMark_on%[1]s.png") + favicon_file = filebase64("test-fixtures/Terraform-LogoMark_on%[2]s.png") +} +`, logoFileSuffix, faviconFileSuffix)) +} diff --git a/internal/service/transfer/web_app_test.go b/internal/service/transfer/web_app_test.go new file mode 100644 index 000000000000..9fe9fbdd2427 --- /dev/null +++ b/internal/service/transfer/web_app_test.go @@ -0,0 +1,572 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package transfer_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/YakDriver/regexache" + "github.com/aws/aws-sdk-go-v2/aws" + awstypes "github.com/aws/aws-sdk-go-v2/service/transfer/types" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/create" + tftransfer "github.com/hashicorp/terraform-provider-aws/internal/service/transfer" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/names" +) + +func TestAccTransferWebApp_basic(t *testing.T) { + ctx := acctest.Context(t) + + var webappBefore, webappAfter awstypes.DescribedWebApp + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + acctest.PreCheckSSOAdminInstances(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppConfig_basic(rName, rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappBefore), + resource.TestMatchResourceAttr(resourceName, "access_endpoint", regexache.MustCompile(`^https:\/\/.*.aws$`)), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "1"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccWebAppConfig_basic(rName+"-tag-changed", rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestMatchResourceAttr(resourceName, "access_endpoint", regexache.MustCompile(`^https:\/\/.*.aws$`)), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "1"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName+"-tag-changed"), + ), + }, + { + Config: testAccWebAppConfig_basic(rName+"-tag-changed", rName+"-tag-changed"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestMatchResourceAttr(resourceName, "access_endpoint", regexache.MustCompile(`^https:\/\/.*.aws$`)), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "1"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName+"-tag-changed"), + ), + }, + }, + }) +} + +func TestAccTransferWebApp_webAppUnits(t *testing.T) { + ctx := acctest.Context(t) + + var webappBefore, webappAfter awstypes.DescribedWebApp + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + acctest.PreCheckSSOAdminInstances(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppConfig_webAppUnits(rName, 1), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappBefore), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_endpoint_policy", "STANDARD"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccWebAppConfig_webAppUnits(rName, 2), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "2"), + resource.TestCheckResourceAttr(resourceName, "web_app_endpoint_policy", "STANDARD"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + { + Config: testAccWebAppConfig_basic(rName, rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "2"), + resource.TestCheckResourceAttr(resourceName, "web_app_endpoint_policy", "STANDARD"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + }, + }) +} + +func TestAccTransferWebApp_accessEndpoint(t *testing.T) { + ctx := acctest.Context(t) + + var webappBefore, webappAfter awstypes.DescribedWebApp + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + acctest.PreCheckSSOAdminInstances(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppConfig_accessEndPoint(rName, "https://example.com"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappBefore), + resource.TestCheckResourceAttr(resourceName, "access_endpoint", "https://example.com"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_endpoint_policy", "STANDARD"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccWebAppConfig_accessEndPoint(rName, "https://example2.com"), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestCheckResourceAttr(resourceName, "access_endpoint", "https://example2.com"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_endpoint_policy", "STANDARD"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + { + Config: testAccWebAppConfig_basic(rName, rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestCheckResourceAttr(resourceName, "access_endpoint", "https://example2.com"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.#", "1"), + resource.TestCheckResourceAttr(resourceName, "identity_provider_details.0.identity_center_config.#", "1"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.instance_arn", "data.aws_ssoadmin_instances.test", "arns.0"), + resource.TestCheckResourceAttrPair(resourceName, "identity_provider_details.0.identity_center_config.0.role", "aws_iam_role.test", names.AttrARN), + resource.TestCheckResourceAttr(resourceName, "web_app_units.#", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_units.0.provisioned", "1"), + resource.TestCheckResourceAttr(resourceName, "web_app_endpoint_policy", "STANDARD"), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + }, + }) +} + +func TestAccTransferWebApp_tags(t *testing.T) { + ctx := acctest.Context(t) + + var webappBefore, webappAfter awstypes.DescribedWebApp + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + acctest.PreCheckSSOAdminInstances(ctx, t) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppConfig_basic(rName, rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappBefore), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + { + Config: testAccWebAppConfig_noTags(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "0"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccWebAppConfig_basic(rName, rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + { + Config: testAccWebAppConfig_multipleTags(rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "2"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + resource.TestCheckResourceAttr(resourceName, "tags.Env", rName), + ), + }, + { + Config: testAccWebAppConfig_basic(rName, rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webappAfter), + testAccCheckWebAppNotRecreated(&webappBefore, &webappAfter), + resource.TestCheckResourceAttr(resourceName, acctest.CtTagsPercent, "1"), + resource.TestCheckResourceAttr(resourceName, "tags.Name", rName), + ), + }, + }, + }) +} + +func TestAccTransferWebApp_disappears(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var webapp awstypes.DescribedWebApp + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + resourceName := "aws_transfer_web_app.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + acctest.PreCheck(ctx, t) + acctest.PreCheckPartitionHasService(t, names.TransferEndpointID) + }, + ErrorCheck: acctest.ErrorCheck(t, names.TransferServiceID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckWebAppDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccWebAppConfig_basic(rName, rName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckWebAppExists(ctx, resourceName, &webapp), + acctest.CheckFrameworkResourceDisappears(ctx, acctest.Provider, tftransfer.ResourceWebApp, resourceName), + ), + ExpectNonEmptyPlan: true, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PostApplyPostRefresh: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceName, plancheck.ResourceActionCreate), + }, + }, + }, + }, + }) +} + +func testAccCheckWebAppDestroy(ctx context.Context) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := acctest.Provider.Meta().(*conns.AWSClient).TransferClient(ctx) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_transfer_web_app" { + continue + } + + _, err := tftransfer.FindWebAppByID(ctx, conn, rs.Primary.ID) + if tfresource.NotFound(err) { + return nil + } + if err != nil { + return create.Error(names.Transfer, create.ErrActionCheckingDestroyed, tftransfer.ResNameWebApp, rs.Primary.ID, err) + } + + return create.Error(names.Transfer, create.ErrActionCheckingDestroyed, tftransfer.ResNameWebApp, rs.Primary.ID, errors.New("not destroyed")) + } + + return nil + } +} + +func testAccCheckWebAppExists(ctx context.Context, name string, webapp *awstypes.DescribedWebApp) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return create.Error(names.Transfer, create.ErrActionCheckingExistence, tftransfer.ResNameWebApp, name, errors.New("not found")) + } + + if rs.Primary.ID == "" { + return create.Error(names.Transfer, create.ErrActionCheckingExistence, tftransfer.ResNameWebApp, name, errors.New("not set")) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).TransferClient(ctx) + + resp, err := tftransfer.FindWebAppByID(ctx, conn, rs.Primary.ID) + if err != nil { + return create.Error(names.Transfer, create.ErrActionCheckingExistence, tftransfer.ResNameWebApp, rs.Primary.ID, err) + } + + *webapp = *resp + + return nil + } +} + +func testAccCheckWebAppNotRecreated(before, after *awstypes.DescribedWebApp) resource.TestCheckFunc { + return func(s *terraform.State) error { + if before, after := aws.ToString(before.WebAppId), aws.ToString(after.WebAppId); before != after { + return create.Error(names.Transfer, create.ErrActionCheckingNotRecreated, tftransfer.ResNameWebApp, before, errors.New("recreated")) + } + + return nil + } +} + +func testAccWebAppConfig_base(roleName string) string { + return fmt.Sprintf(` +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} +data "aws_partition" "current" {} + +data "aws_ssoadmin_instances" "test" {} + +data "aws_iam_policy_document" "assume_role_transfer" { + statement { + effect = "Allow" + actions = [ + "sts:AssumeRole", + "sts:SetContext" + ] + principals { + type = "Service" + identifiers = ["transfer.amazonaws.com"] + } + condition { + test = "StringEquals" + values = [data.aws_caller_identity.current.account_id] + variable = "aws:SourceAccount" + } + } +} + +resource "aws_iam_role" "test" { + name = %[1]q + assume_role_policy = data.aws_iam_policy_document.assume_role_transfer.json +} + +data "aws_iam_policy_document" "web_app_identity_bearer" { + statement { + effect = "Allow" + actions = [ + "s3:GetDataAccess", + "s3:ListCallerAccessGrants", + ] + resources = [ + "arn:${data.aws_partition.current.partition}:s3:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:access-grants/*" + ] + condition { + test = "StringEquals" + values = [data.aws_caller_identity.current.account_id] + variable = "s3:ResourceAccount" + } + } + statement { + effect = "Allow" + actions = [ + "s3:ListAccessGrantsInstances" + ] + resources = ["*"] + condition { + test = "StringEquals" + values = [data.aws_caller_identity.current.account_id] + variable = "s3:ResourceAccount" + } + } +} + +resource "aws_iam_role_policy" "web_app_identity_bearer" { + policy = data.aws_iam_policy_document.web_app_identity_bearer.json + role = aws_iam_role.test.name +} +`, roleName) +} + +func testAccWebAppConfig_basic(rName, roleName string) string { + return acctest.ConfigCompose( + testAccWebAppConfig_base(roleName), fmt.Sprintf(` +resource "aws_transfer_web_app" "test" { + identity_provider_details { + identity_center_config { + instance_arn = tolist(data.aws_ssoadmin_instances.test.arns)[0] + role = aws_iam_role.test.arn + } + } + tags = { + Name = %[1]q + } +} +`, rName)) +} + +func testAccWebAppConfig_webAppUnits(rName string, webAppUnitsProvisioned int) string { + return acctest.ConfigCompose( + testAccWebAppConfig_base(rName), fmt.Sprintf(` +resource "aws_transfer_web_app" "test" { + identity_provider_details { + identity_center_config { + instance_arn = tolist(data.aws_ssoadmin_instances.test.arns)[0] + role = aws_iam_role.test.arn + } + } + web_app_units { + provisioned = %[2]d + } + + tags = { + Name = %[1]q + } +} +`, rName, webAppUnitsProvisioned)) +} + +func testAccWebAppConfig_accessEndPoint(rName, accessEndPoint string) string { + return acctest.ConfigCompose( + testAccWebAppConfig_base(rName), fmt.Sprintf(` +resource "aws_transfer_web_app" "test" { + identity_provider_details { + identity_center_config { + instance_arn = tolist(data.aws_ssoadmin_instances.test.arns)[0] + role = aws_iam_role.test.arn + } + } + access_endpoint = %[2]q + + tags = { + Name = %[1]q + } +} +`, rName, accessEndPoint)) +} + +func testAccWebAppConfig_noTags(rName string) string { + return acctest.ConfigCompose( + testAccWebAppConfig_base(rName), ` +resource "aws_transfer_web_app" "test" { + identity_provider_details { + identity_center_config { + instance_arn = tolist(data.aws_ssoadmin_instances.test.arns)[0] + role = aws_iam_role.test.arn + } + } +} +`) +} + +func testAccWebAppConfig_multipleTags(rName string) string { + return acctest.ConfigCompose( + testAccWebAppConfig_base(rName), fmt.Sprintf(` +resource "aws_transfer_web_app" "test" { + identity_provider_details { + identity_center_config { + instance_arn = tolist(data.aws_ssoadmin_instances.test.arns)[0] + role = aws_iam_role.test.arn + } + } + + tags = { + Name = %[1]q + Env = %[1]q + } + +} +`, rName)) +} diff --git a/website/docs/r/transfer_web_app.html.markdown b/website/docs/r/transfer_web_app.html.markdown new file mode 100644 index 000000000000..b582c324a5e2 --- /dev/null +++ b/website/docs/r/transfer_web_app.html.markdown @@ -0,0 +1,151 @@ +--- +subcategory: "Transfer Family" +layout: "aws" +page_title: "AWS: aws_transfer_web_app" +description: |- + Terraform resource for managing an AWS Transfer Family Web App. +--- + +# Resource: aws_transfer_web_app + +Terraform resource for managing an AWS Transfer Family Web App. + +## Example Usage + +### Basic Usage + +```terraform +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} +data "aws_partition" "current" {} + +data "aws_ssoadmin_instances" "example" {} + +data "aws_iam_policy_document" "assume_role_transfer" { + statement { + effect = "Allow" + actions = [ + "sts:AssumeRole", + "sts:SetContext" + ] + principals { + type = "Service" + identifiers = ["transfer.amazonaws.com"] + } + condition { + test = "StringEquals" + values = [data.aws_caller_identity.current.account_id] + variable = "aws:SourceAccount" + } + } +} + +resource "aws_iam_role" "example" { + name = "example" + assume_role_policy = data.aws_iam_policy_document.assume_role_transfer.json +} + +data "aws_iam_policy_document" "example" { + statement { + effect = "Allow" + actions = [ + "s3:GetDataAccess", + "s3:ListCallerAccessGrants", + ] + resources = [ + "arn:${data.aws_partition.current.partition}:s3:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:access-grants/*" + ] + condition { + test = "StringEquals" + values = [data.aws_caller_identity.current.account_id] + variable = "s3:ResourceAccount" + } + } + statement { + effect = "Allow" + actions = [ + "s3:ListAccessGrantsInstances" + ] + resources = ["*"] + condition { + test = "StringEquals" + values = [data.aws_caller_identity.current.account_id] + variable = "s3:ResourceAccount" + } + } +} + +resource "aws_iam_role_policy" "example" { + policy = data.aws_iam_policy_document.example.json + role = aws_iam_role.example.name +} + +resource "aws_transfer_web_app" "example" { + identity_provider_details { + identity_center_config { + instance_arn = tolist(data.aws_ssoadmin_instances.example.arns)[0] + role = aws_iam_role.example.arn + } + } + web_app_units { + provisioned = 1 + } + tags = { + Name = "test" + } +} +``` + +## Argument Reference + +The following arguments are required: + +* `identity_provider_details` - (Required) Block for details of the identity provider to use with the web app. See [Identity provider details](#identity-provider-details) below. + +The following arguments are optional: + +* `access_endpoint` - (Optional) URL provided to interact with the Transfer Family web app. +* `tags` - (Optional) Key-value pairs that can be used to group and search for web apps. +* `web_app_endpoint_policy` - (Optional) Type of endpoint policy for the web app. Valid values are: `STANDARD`(default) or `FIPS`. +* `web_app_units` - (Optional) Block for number of concurrent connections or the user sessions on the web app. + * provisioned - (Optional) Number of units of concurrent connections. + +### Identity provider details + +* `identity_center_config` - (Optional) Block that describes the values to use for the IAM Identity Center settings. See [Identity center config](#identity-center-config) below. + +### Identity center config + +* instance_arn - (Optional) ARN of the IAM Identity Center used for the web app. +* role - (Optional) ARN of an identity bearer role for your web app. + +## Attribute Reference + +This resource exports the following attributes in addition to the arguments above: + +* `arn` - ARN of the Web App. +* `id` - ID of the Wep App resource. + +## Timeouts + +[Configuration options](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts): + +* `create` - (Default `10m`) +* `delete` - (Default `10m`) + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import Transfer Family Web App using the `id`. For example: + +```terraform +import { + to = aws_transfer_web_app.example + id = "web_app-id-12345678" +} +``` + +Using `terraform import`, import Transfer Family Web App using the `example_id_arg`. For example: + +```console +% terraform import aws_transfer_web_app.example web_app-id-12345678 +``` diff --git a/website/docs/r/transfer_web_app_customization.html.markdown b/website/docs/r/transfer_web_app_customization.html.markdown new file mode 100644 index 000000000000..d78922465383 --- /dev/null +++ b/website/docs/r/transfer_web_app_customization.html.markdown @@ -0,0 +1,81 @@ +--- +subcategory: "Transfer Family" +layout: "aws" +page_title: "AWS: aws_transfer_web_app_customization" +description: |- + Terraform resource for managing an AWS Transfer Family Web App Customization. +--- + +# Resource: aws_transfer_web_app_customization + +Terraform resource for managing an AWS Transfer Family Web App Customization. + +## Example Usage + +### Basic Usage + +```terraform +resource "aws_transfer_web_app" "test" { + identity_provider_details { + identity_center_config { + instance_arn = tolist(data.aws_ssoadmin_instances.test.arns)[0] + role = aws_iam_role.test.arn + } + } + web_app_units { + provisioned = 1 + } + tags = { + Name = "test" + } +} + +resource "aws_transfer_web_app_customization" "test" { + web_app_id = aws_transfer_web_app.test.id + favicon_file = filebase64("${path.module}/favicon.png") + logo_file = filebase64("${path.module}/logo.png") + title = "test" +} +``` + +## Argument Reference + +The following arguments are required: + +* `web_app_id` - (Required) The identifier of the web app to be customized. + +The following arguments are optional: + +* `favicon_file` - (Optional) Base64-encoded string representing the favicon image. Terraform will detect drift only if this argument is specified. To remove the favicon, recreate the resource. +* `logo_file` - (Optional) Base64-encoded string representing the logo image. Terraform will detect drift only if this argument is specified. To remove the logo, recreate the resource. +* `title` – (Optional) Title of the web app. Must be between 1 and 100 characters in length (an empty string is not allowed). To remove the title, omit this argument from your configuration. + +## Attribute Reference + +This resource exports the following attributes in addition to the arguments above: + +* `id` - Same as `web_app_id`. + +## Timeouts + +[Configuration options](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts): + +* `create` - (Default `5m`) +* `delete` - (Default `5m`) + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import Transfer Family Web App Customization using the `web_app_id`. For example: + +```terraform +import { + to = aws_transfer_web_app_customization.example + id = "webapp-12345678901234567890" +} +``` + +Using `terraform import`, import Transfer Family Web App Customization using the `web_app_id`. For example: + +```console +% terraform import aws_transfer_web_app_customization.example webapp-12345678901234567890 +```