diff --git a/.changelog/45314.txt b/.changelog/45314.txt new file mode 100644 index 000000000000..ae1ab24f29f3 --- /dev/null +++ b/.changelog/45314.txt @@ -0,0 +1,11 @@ +```release-note:enhancement +resource/aws_transfer_connector: Add `egress_config` argument to support VPC Lattice connectivity for SFTP connectors +``` + +```release-note:enhancement +resource/aws_transfer_connector: Make `url` argument optional to support VPC Lattice connectors +``` + +```release-note:enhancement +data-source/aws_transfer_connector: Add `egress_config` attribute to expose VPC Lattice connectivity configuration +``` diff --git a/internal/service/transfer/connector.go b/internal/service/transfer/connector.go index 6d7292bc0948..7f84bf504c08 100644 --- a/internal/service/transfer/connector.go +++ b/internal/service/transfer/connector.go @@ -5,7 +5,9 @@ package transfer import ( "context" + "fmt" "log" + "time" "github.com/YakDriver/regexache" "github.com/aws/aws-sdk-go-v2/aws" @@ -38,6 +40,12 @@ func resourceConnector() *schema.Resource { StateContext: schema.ImportStatePassthroughContext, }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(10 * time.Minute), + Update: schema.DefaultTimeout(10 * time.Minute), + Delete: schema.DefaultTimeout(10 * time.Minute), + }, + Schema: map[string]*schema.Schema{ "access_role": { Type: schema.TypeString, @@ -134,11 +142,39 @@ func resourceConnector() *schema.Resource { }, }, }, + "egress_config": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "vpc_lattice": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "resource_configuration_arn": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringLenBetween(1, 2048), + }, + "port_number": { + Type: schema.TypeInt, + Optional: true, + ValidateFunc: validation.IntBetween(1, 65535), + }, + }, + }, + }, + }, + }, + }, names.AttrTags: tftags.TagsSchema(), names.AttrTagsAll: tftags.TagsSchemaComputed(), names.AttrURL: { Type: schema.TypeString, - Required: true, + Optional: true, }, }, } @@ -151,13 +187,20 @@ func resourceConnectorCreate(ctx context.Context, d *schema.ResourceData, meta a input := &transfer.CreateConnectorInput{ AccessRole: aws.String(d.Get("access_role").(string)), Tags: getTagsIn(ctx), - Url: aws.String(d.Get(names.AttrURL).(string)), + } + + if v, ok := d.GetOk(names.AttrURL); ok { + input.Url = aws.String(v.(string)) } if v, ok := d.GetOk("as2_config"); ok { input.As2Config = expandAs2ConnectorConfig(v.([]any)) } + if v, ok := d.GetOk("egress_config"); ok { + input.EgressConfig = expandConnectorEgressConfig(v.([]any)) + } + if v, ok := d.GetOk("logging_role"); ok { input.LoggingRole = aws.String(v.(string)) } @@ -178,6 +221,10 @@ func resourceConnectorCreate(ctx context.Context, d *schema.ResourceData, meta a d.SetId(aws.ToString(output.ConnectorId)) + if _, err := waitConnectorCreated(ctx, conn, d.Id(), d.Timeout(schema.TimeoutCreate)); err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for Transfer Connector (%s) create: %s", d.Id(), err) + } + return append(diags, resourceConnectorRead(ctx, d, meta)...) } @@ -203,6 +250,9 @@ func resourceConnectorRead(ctx context.Context, d *schema.ResourceData, meta any return sdkdiag.AppendErrorf(diags, "setting as2_config: %s", err) } d.Set("connector_id", output.ConnectorId) + if err := d.Set("egress_config", flattenConnectorEgressConfig(output.EgressConfig)); err != nil { + return sdkdiag.AppendErrorf(diags, "setting egress_config: %s", err) + } d.Set("logging_role", output.LoggingRole) d.Set("security_policy_name", output.SecurityPolicyName) if err := d.Set("sftp_config", flattenSftpConnectorConfig(output.SftpConfig)); err != nil { @@ -232,6 +282,10 @@ func resourceConnectorUpdate(ctx context.Context, d *schema.ResourceData, meta a input.As2Config = expandAs2ConnectorConfig(d.Get("as2_config").([]any)) } + if d.HasChange("egress_config") { + input.EgressConfig = expandUpdateConnectorEgressConfig(d.Get("egress_config").([]any)) + } + if d.HasChange("logging_role") { input.LoggingRole = aws.String(d.Get("logging_role").(string)) } @@ -253,6 +307,10 @@ func resourceConnectorUpdate(ctx context.Context, d *schema.ResourceData, meta a if err != nil { return sdkdiag.AppendErrorf(diags, "updating Transfer Connector (%s): %s", d.Id(), err) } + + if _, err := waitConnectorUpdated(ctx, conn, d.Id(), d.Timeout(schema.TimeoutUpdate)); err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for Transfer Connector (%s) update: %s", d.Id(), err) + } } return append(diags, resourceConnectorRead(ctx, d, meta)...) @@ -276,6 +334,10 @@ func resourceConnectorDelete(ctx context.Context, d *schema.ResourceData, meta a return sdkdiag.AppendErrorf(diags, "deleting Transfer Connector (%s): %s", d.Id(), err) } + if _, err := waitConnectorDeleted(ctx, conn, d.Id(), d.Timeout(schema.TimeoutDelete)); err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for Transfer Connector (%s) delete: %s", d.Id(), err) + } + return diags } @@ -383,3 +445,187 @@ func flattenSftpConnectorConfig(apiObject *awstypes.SftpConnectorConfig) []any { return []any{tfMap} } + +func expandConnectorEgressConfig(tfList []any) awstypes.ConnectorEgressConfig { + if len(tfList) < 1 || tfList[0] == nil { + return nil + } + + tfMap := tfList[0].(map[string]any) + + if v, ok := tfMap["vpc_lattice"].([]any); ok && len(v) > 0 { + return &awstypes.ConnectorEgressConfigMemberVpcLattice{ + Value: *expandConnectorVPCLatticeEgressConfig(v), + } + } + + return nil +} + +func expandUpdateConnectorEgressConfig(tfList []any) awstypes.UpdateConnectorEgressConfig { + if len(tfList) < 1 || tfList[0] == nil { + return nil + } + + tfMap := tfList[0].(map[string]any) + + if v, ok := tfMap["vpc_lattice"].([]any); ok && len(v) > 0 { + return &awstypes.UpdateConnectorEgressConfigMemberVpcLattice{ + Value: *expandUpdateConnectorVPCLatticeEgressConfig(v), + } + } + + return nil +} + +func expandConnectorVPCLatticeEgressConfig(tfList []any) *awstypes.ConnectorVpcLatticeEgressConfig { + if len(tfList) < 1 || tfList[0] == nil { + return nil + } + + tfMap := tfList[0].(map[string]any) + + apiObject := &awstypes.ConnectorVpcLatticeEgressConfig{ + ResourceConfigurationArn: aws.String(tfMap["resource_configuration_arn"].(string)), + } + + if v, ok := tfMap["port_number"].(int); ok && v > 0 { + apiObject.PortNumber = aws.Int32(int32(v)) + } + + return apiObject +} + +func expandUpdateConnectorVPCLatticeEgressConfig(tfList []any) *awstypes.UpdateConnectorVpcLatticeEgressConfig { + if len(tfList) < 1 || tfList[0] == nil { + return nil + } + + tfMap := tfList[0].(map[string]any) + + apiObject := &awstypes.UpdateConnectorVpcLatticeEgressConfig{ + ResourceConfigurationArn: aws.String(tfMap["resource_configuration_arn"].(string)), + } + + if v, ok := tfMap["port_number"].(int); ok && v > 0 { + apiObject.PortNumber = aws.Int32(int32(v)) + } + + return apiObject +} + +func flattenConnectorEgressConfig(apiObject awstypes.DescribedConnectorEgressConfig) []any { + if apiObject == nil { + return nil + } + + tfMap := map[string]any{} + + switch v := apiObject.(type) { + case *awstypes.DescribedConnectorEgressConfigMemberVpcLattice: + tfMap["vpc_lattice"] = flattenDescribedConnectorVPCLatticeEgressConfig(&v.Value) + } + + if len(tfMap) == 0 { + return nil + } + + return []any{tfMap} +} + +func flattenDescribedConnectorVPCLatticeEgressConfig(apiObject *awstypes.DescribedConnectorVpcLatticeEgressConfig) []any { + if apiObject == nil { + return nil + } + + tfMap := map[string]any{} + + if v := apiObject.ResourceConfigurationArn; v != nil { + tfMap["resource_configuration_arn"] = aws.ToString(v) + } + + if v := apiObject.PortNumber; v != nil { + tfMap["port_number"] = aws.ToInt32(v) + } + + return []any{tfMap} +} + +func statusConnector(ctx context.Context, conn *transfer.Client, id string) retry.StateRefreshFunc { + return func() (any, string, error) { + output, err := findConnectorByID(ctx, conn, id) + + if tfresource.NotFound(err) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + return output, string(output.Status), nil + } +} + +func waitConnectorCreated(ctx context.Context, conn *transfer.Client, id string, timeout time.Duration) (*awstypes.DescribedConnector, error) { + stateConf := &retry.StateChangeConf{ + Pending: enum.Slice(awstypes.ConnectorStatusPending), + Target: enum.Slice(awstypes.ConnectorStatusActive), + Refresh: statusConnector(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + + if output, ok := outputRaw.(*awstypes.DescribedConnector); ok { + if output.Status == awstypes.ConnectorStatusErrored { + tfresource.SetLastError(err, fmt.Errorf("%s", aws.ToString(output.ErrorMessage))) + } + + return output, err + } + + return nil, err +} + +func waitConnectorUpdated(ctx context.Context, conn *transfer.Client, id string, timeout time.Duration) (*awstypes.DescribedConnector, error) { + stateConf := &retry.StateChangeConf{ + Pending: enum.Slice(awstypes.ConnectorStatusPending), + Target: enum.Slice(awstypes.ConnectorStatusActive), + Refresh: statusConnector(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + + if output, ok := outputRaw.(*awstypes.DescribedConnector); ok { + if output.Status == awstypes.ConnectorStatusErrored { + tfresource.SetLastError(err, fmt.Errorf("%s", aws.ToString(output.ErrorMessage))) + } + + return output, err + } + + return nil, err +} + +func waitConnectorDeleted(ctx context.Context, conn *transfer.Client, id string, timeout time.Duration) (*awstypes.DescribedConnector, error) { + stateConf := &retry.StateChangeConf{ + Pending: enum.Slice(awstypes.ConnectorStatusActive, awstypes.ConnectorStatusPending), + Target: []string{}, + Refresh: statusConnector(ctx, conn, id), + Timeout: timeout, + } + + outputRaw, err := stateConf.WaitForStateContext(ctx) + + if output, ok := outputRaw.(*awstypes.DescribedConnector); ok { + if output.Status == awstypes.ConnectorStatusErrored { + tfresource.SetLastError(err, fmt.Errorf("%s", aws.ToString(output.ErrorMessage))) + } + + return output, err + } + + return nil, err +} diff --git a/internal/service/transfer/connector_data_source.go b/internal/service/transfer/connector_data_source.go index deec4f21e2d2..910261e543a4 100644 --- a/internal/service/transfer/connector_data_source.go +++ b/internal/service/transfer/connector_data_source.go @@ -8,9 +8,11 @@ import ( "github.com/YakDriver/regexache" "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-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-provider-aws/internal/create" @@ -44,7 +46,8 @@ func (d *connectorDataSource) Schema(ctx context.Context, req datasource.SchemaR names.AttrARN: schema.StringAttribute{ Computed: true, }, - "as2_config": framework.DataSourceComputedListOfObjectAttribute[dsAs2Config](ctx), + "as2_config": framework.DataSourceComputedListOfObjectAttribute[dsAs2Config](ctx), + "egress_config": framework.DataSourceComputedListOfObjectAttribute[dsEgressConfig](ctx), names.AttrID: schema.StringAttribute{ CustomType: fwtypes.RegexpType, Required: true, @@ -98,11 +101,19 @@ func (d *connectorDataSource) Read(ctx context.Context, req datasource.ReadReque return } - resp.Diagnostics.Append(flex.Flatten(ctx, description.Connector, &data)...) + resp.Diagnostics.Append(flex.Flatten(ctx, description.Connector, &data, flex.WithIgnoredFieldNamesAppend("EgressConfig"))...) if resp.Diagnostics.HasError() { return } + // Manually flatten EgressConfig since it's a union type + egressConfig, diags := flattenDataSourceEgressConfig(ctx, description.Connector.EgressConfig) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + data.EgressConfig = egressConfig + tags := keyValueTags(ctx, description.Connector.Tags).IgnoreAWS().IgnoreConfig(d.Meta().IgnoreTagsConfig(ctx)) data.Tags = tftags.FlattenStringValueMap(ctx, tags.Map()) @@ -111,16 +122,17 @@ func (d *connectorDataSource) Read(ctx context.Context, req datasource.ReadReque type connectorDataSourceModel struct { framework.WithRegionModel - ARN types.String `tfsdk:"arn"` - AccessRole types.String `tfsdk:"access_role"` - As2Config fwtypes.ListNestedObjectValueOf[dsAs2Config] `tfsdk:"as2_config"` - ConnectorId fwtypes.Regexp `tfsdk:"id"` - LoggingRole types.String `tfsdk:"logging_role"` - SecurityPolicyName types.String `tfsdk:"security_policy_name"` - ServiceManagedEgressIpAddresses fwtypes.ListOfString `tfsdk:"service_managed_egress_ip_addresses"` - SftpConfig fwtypes.ListNestedObjectValueOf[dsSftpConfig] `tfsdk:"sftp_config"` - Tags tftags.Map `tfsdk:"tags"` - Url types.String `tfsdk:"url"` + ARN types.String `tfsdk:"arn"` + AccessRole types.String `tfsdk:"access_role"` + As2Config fwtypes.ListNestedObjectValueOf[dsAs2Config] `tfsdk:"as2_config"` + ConnectorId fwtypes.Regexp `tfsdk:"id"` + EgressConfig fwtypes.ListNestedObjectValueOf[dsEgressConfig] `tfsdk:"egress_config"` + LoggingRole types.String `tfsdk:"logging_role"` + SecurityPolicyName types.String `tfsdk:"security_policy_name"` + ServiceManagedEgressIpAddresses fwtypes.ListOfString `tfsdk:"service_managed_egress_ip_addresses"` + SftpConfig fwtypes.ListNestedObjectValueOf[dsSftpConfig] `tfsdk:"sftp_config"` + Tags tftags.Map `tfsdk:"tags"` + Url types.String `tfsdk:"url"` } type dsAs2Config struct { @@ -139,3 +151,67 @@ type dsSftpConfig struct { TrustedHostKeys fwtypes.ListValueOf[types.String] `tfsdk:"trusted_host_keys"` UserSecretId types.String `tfsdk:"user_secret_id"` } + +type dsEgressConfig struct { + VpcLattice fwtypes.ListNestedObjectValueOf[dsVpcLatticeEgressConfig] `tfsdk:"vpc_lattice"` +} + +type dsVpcLatticeEgressConfig struct { + ResourceConfigurationArn types.String `tfsdk:"resource_configuration_arn"` + PortNumber types.Int64 `tfsdk:"port_number"` +} + +// flattenDataSourceEgressConfig manually flattens the union type DescribedConnectorEgressConfig. +// AutoFlex cannot handle union types (interfaces), so manual flattening with type switching is required. +// nosemgrep:ci.semgrep.framework.manual-flattener-functions +func flattenDataSourceEgressConfig(ctx context.Context, apiObject awstypes.DescribedConnectorEgressConfig) (fwtypes.ListNestedObjectValueOf[dsEgressConfig], diag.Diagnostics) { + var diags diag.Diagnostics + + if apiObject == nil { + return fwtypes.NewListNestedObjectValueOfNull[dsEgressConfig](ctx), diags + } + + var egressConfig dsEgressConfig + + switch v := apiObject.(type) { + case *awstypes.DescribedConnectorEgressConfigMemberVpcLattice: + vpcLattice, d := flattenDataSourceVPCLatticeEgressConfig(ctx, &v.Value) + diags.Append(d...) + egressConfig.VpcLattice = vpcLattice + } + + listValue, d := fwtypes.NewListNestedObjectValueOfPtr(ctx, &egressConfig) + diags.Append(d...) + + return listValue, diags +} + +// flattenDataSourceVPCLatticeEgressConfig manually flattens VPC Lattice egress configuration. +// This is called by flattenDataSourceEgressConfig which handles the union type. +// nosemgrep:ci.semgrep.framework.manual-flattener-functions +func flattenDataSourceVPCLatticeEgressConfig(ctx context.Context, apiObject *awstypes.DescribedConnectorVpcLatticeEgressConfig) (fwtypes.ListNestedObjectValueOf[dsVpcLatticeEgressConfig], diag.Diagnostics) { + var diags diag.Diagnostics + + if apiObject == nil { + return fwtypes.NewListNestedObjectValueOfNull[dsVpcLatticeEgressConfig](ctx), diags + } + + var vpcLatticeConfig dsVpcLatticeEgressConfig + + if v := apiObject.ResourceConfigurationArn; v != nil { + vpcLatticeConfig.ResourceConfigurationArn = types.StringValue(*v) + } else { + vpcLatticeConfig.ResourceConfigurationArn = types.StringNull() + } + + if v := apiObject.PortNumber; v != nil { + vpcLatticeConfig.PortNumber = types.Int64Value(int64(*v)) + } else { + vpcLatticeConfig.PortNumber = types.Int64Null() + } + + listValue, d := fwtypes.NewListNestedObjectValueOfPtr(ctx, &vpcLatticeConfig) + diags.Append(d...) + + return listValue, diags +} diff --git a/internal/service/transfer/connector_data_source_test.go b/internal/service/transfer/connector_data_source_test.go index 64c3be006053..d9bec8714cc9 100644 --- a/internal/service/transfer/connector_data_source_test.go +++ b/internal/service/transfer/connector_data_source_test.go @@ -51,6 +51,45 @@ func TestAccTransferConnectorDataSource_basic(t *testing.T) { }) } +func TestAccTransferConnectorDataSource_egressConfig(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + dataSourceName := "data.aws_transfer_connector.test" + resourceName := "aws_transfer_connector.test" + publicKey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDNt3kA/dBkS6ZyU/sVDiGMuWJQaRPmLNbs/25K/e/fIl07ZWUgqqsFkcycLLMNFGD30Cmgp6XCXfNlIjzFWhNam+4cBb4DPpvieUw44VgsHK5JQy3JKlUfglmH5rs4G5pLiVfZpFU6jqvTsu4mE1CHCP0sXJlJhGxMG3QbsqYWNKiqGFEhuzGMs6fQlMkNiXsFoDmh33HAcXCbaFSC7V7xIqT1hlKu0iOL+GNjMj4R3xy0o3jafhO4MG2s3TwCQQCyaa5oyjL8iP8p3L9yp6cbIcXaS72SIgbCSGCyrcQPIKP2lJJHvE1oVWzLVBhR4eSzrlFDv7K4IErzaJmHqdiz" // nosemgrep:ci.ssh-key + + 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: testAccCheckConnectorDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccConnectorDataSourceConfig_egressConfig(rName, publicKey), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrPair(dataSourceName, "access_role", resourceName, "access_role"), + resource.TestCheckResourceAttrPair(dataSourceName, names.AttrARN, resourceName, names.AttrARN), + resource.TestCheckResourceAttrPair(dataSourceName, "egress_config.#", resourceName, "egress_config.#"), + resource.TestCheckResourceAttrPair(dataSourceName, "egress_config.0.vpc_lattice.#", resourceName, "egress_config.0.vpc_lattice.#"), + resource.TestCheckResourceAttrPair(dataSourceName, "egress_config.0.vpc_lattice.0.resource_configuration_arn", resourceName, "egress_config.0.vpc_lattice.0.resource_configuration_arn"), + resource.TestCheckResourceAttrPair(dataSourceName, "egress_config.0.vpc_lattice.0.port_number", resourceName, "egress_config.0.vpc_lattice.0.port_number"), + resource.TestCheckResourceAttrPair(dataSourceName, names.AttrID, resourceName, names.AttrID), + resource.TestCheckResourceAttrPair(dataSourceName, "sftp_config.#", resourceName, "sftp_config.#"), + resource.TestCheckResourceAttrPair(dataSourceName, names.AttrTags, resourceName, names.AttrTags), + ), + }, + }, + }) +} + func testAccConnectorDataSourceConfig_basic(rName, url string) string { return fmt.Sprintf(` resource "aws_iam_role" "test" { @@ -120,3 +159,106 @@ data "aws_transfer_connector" "test" { `, rName, url) } + +func testAccConnectorDataSourceConfig_egressConfig(rName, publickey string) string { + return acctest.ConfigCompose(acctest.ConfigAvailableAZsNoOptIn(), fmt.Sprintf(` +resource "aws_iam_role" "test" { + name = %[1]q + assume_role_policy = <