diff --git a/.changelog/32435.txt b/.changelog/32435.txt new file mode 100644 index 000000000000..68a15a639398 --- /dev/null +++ b/.changelog/32435.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +aws_opensearch_vpc_endpoint +``` \ No newline at end of file diff --git a/internal/service/opensearch/domain.go b/internal/service/opensearch/domain.go index 6e10f2cf16f0..272693417ed0 100644 --- a/internal/service/opensearch/domain.go +++ b/internal/service/opensearch/domain.go @@ -896,7 +896,7 @@ func resourceDomainRead(ctx context.Context, d *schema.ResourceData, meta interf } if ds.VPCOptions != nil { - if err := d.Set("vpc_options", flattenVPCDerivedInfo(ds.VPCOptions)); err != nil { + if err := d.Set("vpc_options", []interface{}{flattenVPCDerivedInfo(ds.VPCOptions)}); err != nil { return sdkdiag.AppendErrorf(diags, "setting vpc_options: %s", err) } diff --git a/internal/service/opensearch/domain_data_source.go b/internal/service/opensearch/domain_data_source.go index 5068ba647146..027754316178 100644 --- a/internal/service/opensearch/domain_data_source.go +++ b/internal/service/opensearch/domain_data_source.go @@ -466,7 +466,7 @@ func dataSourceDomainRead(ctx context.Context, d *schema.ResourceData, meta inte } if ds.VPCOptions != nil { - if err := d.Set("vpc_options", flattenVPCDerivedInfo(ds.VPCOptions)); err != nil { + if err := d.Set("vpc_options", []interface{}{flattenVPCDerivedInfo(ds.VPCOptions)}); err != nil { return sdkdiag.AppendErrorf(diags, "setting vpc_options: %s", err) } diff --git a/internal/service/opensearch/exports_test.go b/internal/service/opensearch/exports_test.go new file mode 100644 index 000000000000..73459f63ae7b --- /dev/null +++ b/internal/service/opensearch/exports_test.go @@ -0,0 +1,10 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package opensearch + +// Exports for use in tests only. +var ( + FindVPCEndpointByID = findVPCEndpointByID + VPCEndpointsError = vpcEndpointsError +) diff --git a/internal/service/opensearch/flex.go b/internal/service/opensearch/flex.go index 27224b04ea99..e9d3ecbede94 100644 --- a/internal/service/opensearch/flex.go +++ b/internal/service/opensearch/flex.go @@ -113,19 +113,6 @@ func expandEncryptAtRestOptions(m map[string]interface{}) *opensearchservice.Enc return &options } -func expandVPCOptions(m map[string]interface{}) *opensearchservice.VPCOptions { - options := opensearchservice.VPCOptions{} - - if v, ok := m["security_group_ids"]; ok { - options.SecurityGroupIds = flex.ExpandStringSet(v.(*schema.Set)) - } - if v, ok := m["subnet_ids"]; ok { - options.SubnetIds = flex.ExpandStringSet(v.(*schema.Set)) - } - - return &options -} - func flattenCognitoOptions(c *opensearchservice.CognitoOptions) []map[string]interface{} { m := map[string]interface{}{} @@ -216,21 +203,46 @@ func flattenSnapshotOptions(snapshotOptions *opensearchservice.SnapshotOptions) return []map[string]interface{}{m} } -func flattenVPCDerivedInfo(o *opensearchservice.VPCDerivedInfo) []map[string]interface{} { - m := map[string]interface{}{} +func expandVPCOptions(tfMap map[string]interface{}) *opensearchservice.VPCOptions { + if tfMap == nil { + return nil + } - if o.AvailabilityZones != nil { - m["availability_zones"] = flex.FlattenStringSet(o.AvailabilityZones) + apiObject := &opensearchservice.VPCOptions{} + + if v, ok := tfMap["security_group_ids"].(*schema.Set); ok && v.Len() > 0 { + apiObject.SecurityGroupIds = flex.ExpandStringSet(v) } - if o.SecurityGroupIds != nil { - m["security_group_ids"] = flex.FlattenStringSet(o.SecurityGroupIds) + + if v, ok := tfMap["subnet_ids"].(*schema.Set); ok && v.Len() > 0 { + apiObject.SubnetIds = flex.ExpandStringSet(v) } - if o.SubnetIds != nil { - m["subnet_ids"] = flex.FlattenStringSet(o.SubnetIds) + + return apiObject +} + +func flattenVPCDerivedInfo(apiObject *opensearchservice.VPCDerivedInfo) map[string]interface{} { + if apiObject == nil { + return nil } - if o.VPCId != nil { - m["vpc_id"] = aws.StringValue(o.VPCId) + + tfMap := map[string]interface{}{} + + if v := apiObject.AvailabilityZones; v != nil { + tfMap["availability_zones"] = aws.StringValueSlice(v) } - return []map[string]interface{}{m} + if v := apiObject.SecurityGroupIds; v != nil { + tfMap["security_group_ids"] = aws.StringValueSlice(v) + } + + if v := apiObject.SubnetIds; v != nil { + tfMap["subnet_ids"] = aws.StringValueSlice(v) + } + + if v := apiObject.VPCId; v != nil { + tfMap["vpc_id"] = aws.StringValue(v) + } + + return tfMap } diff --git a/internal/service/opensearch/service_package_gen.go b/internal/service/opensearch/service_package_gen.go index 96f66fa6e58a..be066c6c9dec 100644 --- a/internal/service/opensearch/service_package_gen.go +++ b/internal/service/opensearch/service_package_gen.go @@ -58,6 +58,10 @@ func (p *servicePackage) SDKResources(ctx context.Context) []*types.ServicePacka Factory: ResourceOutboundConnection, TypeName: "aws_opensearch_outbound_connection", }, + { + Factory: ResourceVPCEndpoint, + TypeName: "aws_opensearch_vpc_endpoint", + }, } } diff --git a/internal/service/opensearch/vpc_endpoint.go b/internal/service/opensearch/vpc_endpoint.go new file mode 100644 index 000000000000..100f10251d95 --- /dev/null +++ b/internal/service/opensearch/vpc_endpoint.go @@ -0,0 +1,331 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package opensearch + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/opensearchservice" + "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/errs/sdkdiag" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "github.com/hashicorp/terraform-provider-aws/internal/verify" +) + +// @SDKResource("aws_opensearch_vpc_endpoint") +func ResourceVPCEndpoint() *schema.Resource { + return &schema.Resource{ + CreateWithoutTimeout: resourceVPCEndpointCreate, + ReadWithoutTimeout: resourceVPCEndpointRead, + UpdateWithoutTimeout: resourceVPCEndpointUpdate, + DeleteWithoutTimeout: resourceVPCEndpointDelete, + + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(60 * time.Minute), + Update: schema.DefaultTimeout(60 * time.Minute), + Delete: schema.DefaultTimeout(90 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "domain_arn": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: verify.ValidARN, + }, + "endpoint": { + Type: schema.TypeString, + Computed: true, + }, + "vpc_options": { + Type: schema.TypeList, + Required: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "availability_zones": { + Type: schema.TypeSet, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "security_group_ids": { + Type: schema.TypeSet, + Optional: true, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "subnet_ids": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "vpc_id": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func resourceVPCEndpointCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var diags diag.Diagnostics + conn := meta.(*conns.AWSClient).OpenSearchConn(ctx) + + input := &opensearchservice.CreateVpcEndpointInput{ + DomainArn: aws.String(d.Get("domain_arn").(string)), + VpcOptions: expandVPCOptions(d.Get("vpc_options").([]interface{})[0].(map[string]interface{})), + } + + output, err := conn.CreateVpcEndpointWithContext(ctx, input) + + if err != nil { + return sdkdiag.AppendErrorf(diags, "creating OpenSearch VPC Endpoint: %s", err) + } + + d.SetId(aws.StringValue(output.VpcEndpoint.VpcEndpointId)) + + if err := waitVPCEndpointCreated(ctx, conn, d.Id(), d.Timeout(schema.TimeoutCreate)); err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for OpenSearch VPC Endpoint (%s) create: %s", d.Id(), err) + } + + return append(diags, resourceVPCEndpointRead(ctx, d, meta)...) +} + +func resourceVPCEndpointRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var diags diag.Diagnostics + conn := meta.(*conns.AWSClient).OpenSearchConn(ctx) + + endpoint, err := findVPCEndpointByID(ctx, conn, d.Id()) + + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] OpenSearch VPC Endpoint (%s) not found, removing from state", d.Id()) + d.SetId("") + return diags + } + + if err != nil { + return sdkdiag.AppendErrorf(diags, "reading OpenSearch VPC Endpoint (%s): %s", d.Id(), err) + } + + d.Set("domain_arn", endpoint.DomainArn) + d.Set("endpoint", endpoint.Endpoint) + if endpoint.VpcOptions != nil { + if err := d.Set("vpc_options", []interface{}{flattenVPCDerivedInfo(endpoint.VpcOptions)}); err != nil { + return diag.Errorf("setting vpc_options: %s", err) + } + } else { + d.Set("vpc_options", nil) + } + + return diags +} + +func resourceVPCEndpointUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var diags diag.Diagnostics + conn := meta.(*conns.AWSClient).OpenSearchConn(ctx) + + input := &opensearchservice.UpdateVpcEndpointInput{ + VpcOptions: expandVPCOptions(d.Get("vpc_options").([]interface{})[0].(map[string]interface{})), + VpcEndpointId: aws.String(d.Id()), + } + + _, err := conn.UpdateVpcEndpointWithContext(ctx, input) + + if err != nil { + return sdkdiag.AppendErrorf(diags, "updating OpenSearch VPC Endpoint (%s): %s", d.Id(), err) + } + + if err := waitVPCEndpointUpdated(ctx, conn, d.Id(), d.Timeout(schema.TimeoutUpdate)); err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for OpenSearch VPC Endpoint (%s) update: %s", d.Id(), err) + } + + return append(diags, resourceVPCEndpointRead(ctx, d, meta)...) +} + +func resourceVPCEndpointDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + var diags diag.Diagnostics + conn := meta.(*conns.AWSClient).OpenSearchConn(ctx) + + log.Printf("[DEBUG] Deleting OpenSearch VPC Endpoint: %s", d.Id()) + _, err := conn.DeleteVpcEndpointWithContext(ctx, &opensearchservice.DeleteVpcEndpointInput{ + VpcEndpointId: aws.String(d.Id()), + }) + + if tfawserr.ErrCodeEquals(err, opensearchservice.ErrCodeResourceNotFoundException) { + return diags + } + + if err != nil { + return sdkdiag.AppendErrorf(diags, "deleting OpenSearch VPC Endpoint (%s): %s", d.Id(), err) + } + + if err := waitVPCEndpointDeleted(ctx, conn, d.Id(), d.Timeout(schema.TimeoutDelete)); err != nil { + return sdkdiag.AppendErrorf(diags, "waiting for OpenSearch VPC Endpoint (%s) delete: %s", d.Id(), err) + } + + return diags +} + +type vpcEndpointNotFoundError struct { + apiError error +} + +func (e *vpcEndpointNotFoundError) Error() string { + if e.apiError != nil { + return e.apiError.Error() + } + + return "VPC endpoint not found" +} + +func (e *vpcEndpointNotFoundError) Is(err error) bool { + _, ok := err.(*vpcEndpointNotFoundError) //nolint:errorlint // Explicitly does *not* match down the error tree + return ok +} + +func (e *vpcEndpointNotFoundError) As(target any) bool { + t, ok := target.(**retry.NotFoundError) + if !ok { + return false + } + + *t = &retry.NotFoundError{ + Message: e.Error(), + } + + return true +} + +func vpcEndpointError(apiObject *opensearchservice.VpcEndpointError) error { + if apiObject == nil { + return nil + } + + errorCode := aws.StringValue(apiObject.ErrorCode) + innerError := fmt.Errorf("%s: %s", errorCode, aws.StringValue(apiObject.ErrorMessage)) + err := fmt.Errorf("%s: %w", aws.StringValue(apiObject.VpcEndpointId), innerError) + + if errorCode == opensearchservice.VpcEndpointErrorCodeEndpointNotFound { + err = &vpcEndpointNotFoundError{apiError: err} + } + + return err +} + +func vpcEndpointsError(apiObjects []*opensearchservice.VpcEndpointError) error { + var errs []error + + for _, apiObject := range apiObjects { + errs = append(errs, vpcEndpointError(apiObject)) + } + + return errors.Join(errs...) +} + +func findVPCEndpointByID(ctx context.Context, conn *opensearchservice.OpenSearchService, id string) (*opensearchservice.VpcEndpoint, error) { + input := &opensearchservice.DescribeVpcEndpointsInput{ + VpcEndpointIds: aws.StringSlice([]string{id}), + } + + return findVPCEndpoint(ctx, conn, input) +} + +func findVPCEndpoint(ctx context.Context, conn *opensearchservice.OpenSearchService, input *opensearchservice.DescribeVpcEndpointsInput) (*opensearchservice.VpcEndpoint, error) { + output, err := findVPCEndpoints(ctx, conn, input) + + if err != nil { + return nil, err + } + + return tfresource.AssertSinglePtrResult(output) +} + +func findVPCEndpoints(ctx context.Context, conn *opensearchservice.OpenSearchService, input *opensearchservice.DescribeVpcEndpointsInput) ([]*opensearchservice.VpcEndpoint, error) { + output, err := conn.DescribeVpcEndpointsWithContext(ctx, input) + + if err != nil { + return nil, err + } + + if output == nil { + return nil, tfresource.NewEmptyResultError(input) + } + + if errs := output.VpcEndpointErrors; len(errs) > 0 { + return nil, vpcEndpointsError(errs) + } + + return output.VpcEndpoints, nil +} + +func statusVPCEndpoint(ctx context.Context, conn *opensearchservice.OpenSearchService, id string) retry.StateRefreshFunc { + return func() (interface{}, string, error) { + output, err := findVPCEndpointByID(ctx, conn, id) + + if tfresource.NotFound(err) { + return nil, "", nil + } + + if err != nil { + return nil, "", err + } + + return output, aws.StringValue(output.Status), nil + } +} + +func waitVPCEndpointCreated(ctx context.Context, conn *opensearchservice.OpenSearchService, id string, timeout time.Duration) error { + stateConf := &retry.StateChangeConf{ + Pending: []string{opensearchservice.VpcEndpointStatusCreating}, + Target: []string{opensearchservice.VpcEndpointStatusActive}, + Refresh: statusVPCEndpoint(ctx, conn, id), + Timeout: timeout, + } + + _, err := stateConf.WaitForStateContext(ctx) + + return err +} + +func waitVPCEndpointUpdated(ctx context.Context, conn *opensearchservice.OpenSearchService, id string, timeout time.Duration) error { + stateConf := &retry.StateChangeConf{ + Pending: []string{opensearchservice.VpcEndpointStatusUpdating}, + Target: []string{opensearchservice.VpcEndpointStatusActive}, + Refresh: statusVPCEndpoint(ctx, conn, id), + Timeout: timeout, + } + + _, err := stateConf.WaitForStateContext(ctx) + + return err +} + +func waitVPCEndpointDeleted(ctx context.Context, conn *opensearchservice.OpenSearchService, id string, timeout time.Duration) error { + stateConf := &retry.StateChangeConf{ + Pending: []string{opensearchservice.VpcEndpointStatusDeleting}, + Target: []string{}, + Refresh: statusVPCEndpoint(ctx, conn, id), + Timeout: timeout, + } + + _, err := stateConf.WaitForStateContext(ctx) + + return err +} diff --git a/internal/service/opensearch/vpc_endpoint_test.go b/internal/service/opensearch/vpc_endpoint_test.go new file mode 100644 index 000000000000..427037d1fc53 --- /dev/null +++ b/internal/service/opensearch/vpc_endpoint_test.go @@ -0,0 +1,348 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package opensearch_test + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/opensearchservice" + sdkacctest "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfopensearch "github.com/hashicorp/terraform-provider-aws/internal/service/opensearch" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" +) + +func TestVPCEndpointErrorsNotFound(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + apiObjects []*opensearchservice.VpcEndpointError + notFound bool + }{ + { + name: "nil input", + }, + { + name: "slice of nil input", + apiObjects: []*opensearchservice.VpcEndpointError{nil, nil}, + }, + { + name: "single SERVER_ERROR", + apiObjects: []*opensearchservice.VpcEndpointError{{ + ErrorCode: aws.String(opensearchservice.VpcEndpointErrorCodeServerError), + ErrorMessage: aws.String("fail"), + VpcEndpointId: aws.String("aos-12345678"), + }}, + }, + { + name: "single ENDPOINT_NOT_FOUND", + apiObjects: []*opensearchservice.VpcEndpointError{{ + ErrorCode: aws.String(opensearchservice.VpcEndpointErrorCodeEndpointNotFound), + ErrorMessage: aws.String("Endpoint does not exist"), + VpcEndpointId: aws.String("aos-12345678"), + }}, + notFound: true, + }, + { + name: "no ENDPOINT_NOT_FOUND in many", + apiObjects: []*opensearchservice.VpcEndpointError{ + { + ErrorCode: aws.String(opensearchservice.VpcEndpointErrorCodeServerError), + ErrorMessage: aws.String("fail"), + VpcEndpointId: aws.String("aos-abcd0123"), + }, + { + ErrorCode: aws.String(opensearchservice.VpcEndpointErrorCodeServerError), + ErrorMessage: aws.String("crash"), + VpcEndpointId: aws.String("aos-12345678"), + }, + }, + }, + { + name: "single ENDPOINT_NOT_FOUND in many", + apiObjects: []*opensearchservice.VpcEndpointError{ + { + ErrorCode: aws.String(opensearchservice.VpcEndpointErrorCodeServerError), + ErrorMessage: aws.String("fail"), + VpcEndpointId: aws.String("aos-abcd0123"), + }, + { + ErrorCode: aws.String(opensearchservice.VpcEndpointErrorCodeEndpointNotFound), + ErrorMessage: aws.String("Endpoint does not exist"), + VpcEndpointId: aws.String("aos-12345678"), + }, + }, + notFound: true, + }, + } + + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + if got, want := tfresource.NotFound(tfopensearch.VPCEndpointsError(testCase.apiObjects)), testCase.notFound; got != want { + t.Errorf("NotFound = %v, want %v", got, want) + } + }) + } +} + +func TestAccOpenSearchVPCEndpoint_basic(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v opensearchservice.VpcEndpoint + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + domainName := testAccRandomDomainName() + resourceName := "aws_opensearch_vpc_endpoint.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, opensearchservice.EndpointsID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckVPCEndpointDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccVPCEndpointConfig_basic(rName, domainName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckVPCEndpointExists(ctx, resourceName, &v), + resource.TestCheckResourceAttrSet(resourceName, "endpoint"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.#", "1"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.availability_zones.#", "2"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.security_group_ids.#", "1"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.subnet_ids.#", "2"), + resource.TestCheckResourceAttrSet(resourceName, "vpc_options.0.vpc_id"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccOpenSearchVPCEndpoint_disappears(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v opensearchservice.VpcEndpoint + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + domainName := testAccRandomDomainName() + resourceName := "aws_opensearch_vpc_endpoint.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, opensearchservice.EndpointsID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckVPCEndpointDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccVPCEndpointConfig_basic(rName, domainName), + Check: resource.ComposeTestCheckFunc( + testAccCheckVPCEndpointExists(ctx, resourceName, &v), + acctest.CheckResourceDisappears(ctx, acctest.Provider, tfopensearch.ResourceVPCEndpoint(), resourceName), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccOpenSearchVPCEndpoint_update(t *testing.T) { + ctx := acctest.Context(t) + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + var v opensearchservice.VpcEndpoint + rName := sdkacctest.RandomWithPrefix(acctest.ResourcePrefix) + domainName := testAccRandomDomainName() + resourceName := "aws_opensearch_vpc_endpoint.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(ctx, t) }, + ErrorCheck: acctest.ErrorCheck(t, opensearchservice.EndpointsID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckVPCEndpointDestroy(ctx), + Steps: []resource.TestStep{ + { + Config: testAccVPCEndpointConfig_basic(rName, domainName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckVPCEndpointExists(ctx, resourceName, &v), + resource.TestCheckResourceAttrSet(resourceName, "endpoint"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.#", "1"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.availability_zones.#", "2"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.security_group_ids.#", "1"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.subnet_ids.#", "2"), + resource.TestCheckResourceAttrSet(resourceName, "vpc_options.0.vpc_id"), + ), + }, + { + Config: testAccVPCEndpointConfig_updated(rName, domainName), + Check: resource.ComposeAggregateTestCheckFunc( + testAccCheckVPCEndpointExists(ctx, resourceName, &v), + resource.TestCheckResourceAttrSet(resourceName, "endpoint"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.#", "1"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.availability_zones.#", "2"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.security_group_ids.#", "2"), + resource.TestCheckResourceAttr(resourceName, "vpc_options.0.subnet_ids.#", "2"), + resource.TestCheckResourceAttrSet(resourceName, "vpc_options.0.vpc_id"), + ), + }, + }, + }) +} + +func testAccCheckVPCEndpointExists(ctx context.Context, n string, v *opensearchservice.VpcEndpoint) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).OpenSearchConn(ctx) + + output, err := tfopensearch.FindVPCEndpointByID(ctx, conn, rs.Primary.ID) + + if err != nil { + return err + } + + *v = *output + + return nil + } +} + +func testAccCheckVPCEndpointDestroy(ctx context.Context) resource.TestCheckFunc { + return func(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_opensearch_vpc_endpoint" { + continue + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).OpenSearchConn(ctx) + + _, err := tfopensearch.FindVPCEndpointByID(ctx, conn, rs.Primary.ID) + + if tfresource.NotFound(err) { + continue + } + + if err != nil { + return err + } + + return fmt.Errorf("OpenSearch VPC Endpoint %s still exists", rs.Primary.ID) + } + + return nil + } +} + +func testAccVPCEndpointConfig_base(rName, domainName string) string { + return acctest.ConfigCompose(acctest.ConfigVPCWithSubnets(rName, 2), fmt.Sprintf(` +resource "aws_security_group" "test" { + name = %[1]q + vpc_id = aws_vpc.test.id + + tags = { + Name = %[1]q + } +} + +resource "aws_opensearch_domain" "test" { + domain_name = %[2]q + + ebs_options { + ebs_enabled = true + volume_size = 10 + } + + cluster_config { + instance_count = 2 + zone_awareness_enabled = true + instance_type = "t2.small.search" + } + + vpc_options { + security_group_ids = [aws_security_group.test.id] + subnet_ids = aws_subnet.test[*].id + } +} + +resource "aws_vpc" "client" { + cidr_block = "10.0.0.0/16" + + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = %[1]q + } +} + +resource "aws_subnet" "client" { + count = 2 + + vpc_id = aws_vpc.client.id + availability_zone = data.aws_availability_zones.available.names[count.index] + cidr_block = cidrsubnet(aws_vpc.client.cidr_block, 8, count.index) + + tags = { + Name = %[1]q + } +} + +resource "aws_security_group" "client" { + count = 2 + + name = "%[1]s-client-${count.index}" + vpc_id = aws_vpc.client.id + + tags = { + Name = %[1]q + } +} +`, rName, domainName)) +} + +func testAccVPCEndpointConfig_basic(rName, domainName string) string { + return acctest.ConfigCompose(testAccVPCEndpointConfig_base(rName, domainName), ` +resource "aws_opensearch_vpc_endpoint" "test" { + domain_arn = aws_opensearch_domain.test.arn + + vpc_options { + subnet_ids = aws_subnet.client[*].id + } +} +`) +} + +func testAccVPCEndpointConfig_updated(rName, domainName string) string { + return acctest.ConfigCompose(testAccVPCEndpointConfig_base(rName, domainName), ` +resource "aws_opensearch_vpc_endpoint" "test" { + domain_arn = aws_opensearch_domain.test.arn + + vpc_options { + subnet_ids = aws_subnet.client[*].id + security_group_ids = aws_security_group.client[*].id + } +} +`) +} diff --git a/website/docs/r/opensearch_vpc_endpoint.html.markdown b/website/docs/r/opensearch_vpc_endpoint.html.markdown new file mode 100644 index 000000000000..2c812e3b1de6 --- /dev/null +++ b/website/docs/r/opensearch_vpc_endpoint.html.markdown @@ -0,0 +1,71 @@ +--- +subcategory: "OpenSearch" +layout: "aws" +page_title: "AWS: aws_opensearch_vpc_endpoint" +description: |- + Terraform resource for managing an AWS OpenSearch VPC Endpoint. +--- + +# Resource: aws_opensearch_vpc_endpoint + +Manages an [AWS Opensearch VPC Endpoint](https://docs.aws.amazon.com/opensearch-service/latest/APIReference/API_CreateVpcEndpoint.html). Creates an Amazon OpenSearch Service-managed VPC endpoint. + +## Example Usage + +### Basic Usage + +```terraform + +resource "aws_opensearch_vpc_endpoint" "foo" { + domain_arn = aws_opensearch_domain.domain_1.arn + vpc_options { + security_group_ids = [aws_security_group.test.id, aws_security_group.test2.id] + subnet_ids = [aws_subnet.test.id, aws_subnet.test2.id] + } +} + +``` + +## Argument Reference + +The following arguments are supported: + +* `domain_arn` - (Required, Forces new resource) Specifies the Amazon Resource Name (ARN) of the domain to create the endpoint for +* `vpc_options` - (Required) Options to specify the subnets and security groups for the endpoint. + +### vpc_options + +* `security_group_ids` - (Optional) The list of security group IDs associated with the VPC endpoints for the domain. If you do not provide a security group ID, OpenSearch Service uses the default security group for the VPC. +* `subnet_ids` - (Required) A list of subnet IDs associated with the VPC endpoints for the domain. If your domain uses multiple Availability Zones, you need to provide two subnet IDs, one per zone. Otherwise, provide only one. + +## Attribute Reference + +This resource exports the following attributes in addition to the arguments above: + +* `id` - The unique identifier of the endpoint. +* `endpoint` - The connection endpoint ID for connecting to the domain. + +## Timeouts + +[Configuration options](https://developer.hashicorp.com/terraform/language/resources/syntax#operation-timeouts): + +* `create` - (Default `60m`) +* `update` - (Default `60m`) +* `delete` - (Default `90m`) + +## Import + +In Terraform v1.5.0 and later, use an [`import` block](https://developer.hashicorp.com/terraform/language/import) to import OpenSearch VPC endpoint connections using the `id`. For example: + +```terraform +import { + to = aws_opensearch_vpc_endpoint_connection.example + id = "endpoint-id" +} +``` + +Using `terraform import`, import OpenSearch VPC endpoint connections using the `id`. For example: + +```console +% terraform import aws_opensearch_vpc_endpoint_connection.example endpoint-id +```