From b588977c6ec5b0b8d87c009496f0cc17692228d2 Mon Sep 17 00:00:00 2001 From: secustor Date: Fri, 2 Dec 2022 16:17:05 +0100 Subject: [PATCH] feat(elasticsearch): add vpc_endpoint resource --- internal/provider/provider.go | 1 + .../elasticsearch/domain_vpc_endpoint.go | 211 ++++++++++++++++++ .../elasticsearch/domain_vpc_endpoint_test.go | 174 +++++++++++++++ internal/service/elasticsearch/find.go | 21 ++ 4 files changed, 407 insertions(+) create mode 100644 internal/service/elasticsearch/domain_vpc_endpoint.go create mode 100644 internal/service/elasticsearch/domain_vpc_endpoint_test.go diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 014a3aa964b4..26e1ca6aaf67 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -1500,6 +1500,7 @@ func New(_ context.Context) (*schema.Provider, error) { "aws_elasticsearch_domain": elasticsearch.ResourceDomain(), "aws_elasticsearch_domain_policy": elasticsearch.ResourceDomainPolicy(), "aws_elasticsearch_domain_saml_options": elasticsearch.ResourceDomainSAMLOptions(), + "aws_elasticsearch_domain_vpc_endpoint": elasticsearch.ResourceDomainVpcEndpoint(), "aws_elastictranscoder_pipeline": elastictranscoder.ResourcePipeline(), "aws_elastictranscoder_preset": elastictranscoder.ResourcePreset(), diff --git a/internal/service/elasticsearch/domain_vpc_endpoint.go b/internal/service/elasticsearch/domain_vpc_endpoint.go new file mode 100644 index 000000000000..eec03df946c6 --- /dev/null +++ b/internal/service/elasticsearch/domain_vpc_endpoint.go @@ -0,0 +1,211 @@ +package elasticsearch + +import ( + "context" + "fmt" + "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" + "log" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + elasticsearch "github.com/aws/aws-sdk-go/service/elasticsearchservice" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "golang.org/x/exp/slices" +) + +var ( + vpcEndpointStillInProgress = []string{"CREATING", "UPDATING", "DELETING"} +) + +func ResourceDomainVpcEndpoint() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceDomainVpcEndpointCreate, + ReadContext: resourceDomainVpcEndpointRead, + UpdateContext: resourceDomainVpcEndpointUpdate, + DeleteContext: resourceDomainVpcEndpointDelete, + + Schema: map[string]*schema.Schema{ + "domain_arn": { + Type: schema.TypeString, + Required: true, + Description: "The Amazon Resource Name (ARN) of the domain associated with the endpoint.", + ForceNew: true, + }, + "vpc_options": { + Type: schema.TypeList, + MaxItems: 1, + Required: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "security_group_ids": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + Description: "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": { + Type: schema.TypeSet, + Required: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: schema.HashString, + Description: "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.", + }, + }, + }, + }, + "owner": { + Type: schema.TypeString, + Computed: true, + Description: "The creator of the endpoint.", + }, + "connection_id": { + Type: schema.TypeString, + Computed: true, + Description: "The connection endpoint ID for connecting to the domain.", + }, + }, + } +} + +func resourceDomainVpcEndpointDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).ElasticsearchConn + + id := d.Id() + _, err := conn.DeleteVpcEndpointWithContext(ctx, &elasticsearch.DeleteVpcEndpointInput{ + VpcEndpointId: aws.String(id), + }) + if tfawserr.ErrCodeEquals(err, elasticsearch.ErrCodeResourceNotFoundException) { + return nil + } + if err != nil { + return diag.FromErr(err) + } + + if err := WaitForDomainVPCEndpoint(ctx, conn, id, d.Timeout(schema.TimeoutDelete)); err != nil { + return diag.FromErr(err) + } + return nil +} + +func resourceDomainVpcEndpointUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).ElasticsearchConn + + if !d.HasChanges() { + return resourceDomainVpcEndpointRead(ctx, d, meta) + } + + id := d.Id() + input := &elasticsearch.UpdateVpcEndpointInput{ + VpcEndpointId: aws.String(id), + } + + if !d.HasChange("vpc_options") { + return nil + } + options := d.Get("vpc_options").([]interface{}) + s := options[0].(map[string]interface{}) + input.VpcOptions = expandVPCOptions(s) + + _, err := conn.UpdateVpcEndpointWithContext(ctx, input) + if err != nil { + return diag.FromErr(err) + } + + if err := WaitForDomainVPCEndpoint(ctx, conn, id, d.Timeout(schema.TimeoutUpdate)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceDomainVpcEndpointCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).ElasticsearchConn + + input := &elasticsearch.CreateVpcEndpointInput{ + ClientToken: aws.String(resource.UniqueId()), + DomainArn: aws.String(d.Get("domain_arn").(string)), + } + + if v, ok := d.GetOk("vpc_options"); ok { + options := v.([]interface{}) + if options[0] == nil { + return diag.Errorf("At least one field is expected inside vpc_options") + } + + s := options[0].(map[string]interface{}) + input.VpcOptions = expandVPCOptions(s) + } + + output, err := conn.CreateVpcEndpointWithContext(ctx, input) + if err != nil { + return diag.FromErr(err) + } + + id := aws.ToString(output.VpcEndpoint.VpcEndpointId) + d.SetId(id) + + if err := WaitForDomainVPCEndpoint(ctx, conn, id, d.Timeout(schema.TimeoutCreate)); err != nil { + return diag.Errorf("error waiting for Elasticsearch Domain VPC Endpoint (%s) create: %s", d.Id(), err) + } + + return resourceDomainVpcEndpointRead(ctx, d, meta) +} + +func resourceDomainVpcEndpointRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + conn := meta.(*conns.AWSClient).ElasticsearchConn + + id := d.Id() + + endpoint, err := FindVPCEndpointByID(ctx, conn, id) + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] Elasticsearch VPC Endpoint (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + if err != nil { + return diag.Errorf("error reading Elasticsearch VPC Endpoint (%s): %s", d.Id(), err) + } + + d.Set("domain_arn", endpoint.DomainArn) + d.Set("owner", endpoint.VpcEndpointOwner) + d.Set("connection_id", endpoint.Endpoint) + + if err := d.Set("vpc_options", flattenVPCDerivedInfo(endpoint.VpcOptions)); err != nil { + return diag.Errorf("error setting vpc_options: %s", err) + } + + return nil +} + +func WaitForDomainVPCEndpoint(ctx context.Context, conn *elasticsearch.ElasticsearchService, id string, timeout time.Duration) error { + err := resource.RetryContext(ctx, timeout, func() *resource.RetryError { + vpcEndpoint, err := FindVPCEndpointByID(ctx, conn, id) + if err != nil { + return resource.NonRetryableError(err) + } + if slices.Contains(vpcEndpointStillInProgress, aws.ToString(vpcEndpoint.Status)) { + return resource.RetryableError(fmt.Errorf("waiting for %s to be finished. Current status: %s", aws.ToString(vpcEndpoint.VpcEndpointId), aws.ToString(vpcEndpoint.Status))) + } + return nil + }) + + if tfresource.TimedOut(err) { + vpcEndpoint, err := FindVPCEndpointByID(ctx, conn, id) + if err != nil { + if tfresource.NotFound(err) { + return nil + } + return fmt.Errorf("error describing Elasticsearch domain: %s", err) + } + + if vpcEndpoint != nil && slices.Contains(vpcEndpointStillInProgress, aws.ToString(vpcEndpoint.Status)) { + return nil + } + } + return err +} diff --git a/internal/service/elasticsearch/domain_vpc_endpoint_test.go b/internal/service/elasticsearch/domain_vpc_endpoint_test.go new file mode 100644 index 000000000000..049f5cb217d9 --- /dev/null +++ b/internal/service/elasticsearch/domain_vpc_endpoint_test.go @@ -0,0 +1,174 @@ +package elasticsearch_test + +import ( + "context" + "fmt" + elasticsearch "github.com/aws/aws-sdk-go/service/elasticsearchservice" + sdkacctest "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-provider-aws/internal/acctest" + "github.com/hashicorp/terraform-provider-aws/internal/conns" + tfelasticsearch "github.com/hashicorp/terraform-provider-aws/internal/service/elasticsearch" + "github.com/hashicorp/terraform-provider-aws/internal/tfresource" + "testing" +) + +func TestAccElasticsearchDomainVPCEndpoint_basic(t *testing.T) { + if testing.Short() { + t.Skip("skipping long-running test in short mode") + } + + rName := sdkacctest.RandomWithPrefix("vpc-endpoint") + + var domain elasticsearch.ElasticsearchDomainStatus + resourceName := "aws_elasticsearch_domain_vpc_endpoint.test" + esDomainResourceName := "aws_elasticsearch_domain.example" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, elasticsearch.EndpointsID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDomainVPCEndpointDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDomainVPCEndpointConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDomainExists(esDomainResourceName, &domain), + testAccCheckDomainVPCEndpointExists(resourceName), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateId: rName, + ImportStateVerify: true, + }, + }, + }) +} + +func TestAccElasticsearchDomainVPCEndpoint_disappears(t *testing.T) { + rName := sdkacctest.RandomWithPrefix("vpc-endpoint") + + var domain elasticsearch.ElasticsearchDomainStatus + resourceName := "aws_elasticsearch_domain_vpc_endpoint.test" + esDomainResourceName := "aws_elasticsearch_domain.example" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ErrorCheck: acctest.ErrorCheck(t, elasticsearch.EndpointsID), + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories, + CheckDestroy: testAccCheckDomainVPCEndpointDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDomainVPCEndpointConfig_basic(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckDomainExists(esDomainResourceName, &domain), + testAccCheckDomainVPCEndpointExists(resourceName), + ), + }, + }, + }) +} + +func testAccCheckDomainVPCEndpointDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_elasticsearch_domain_vpc_endpoint" { + continue + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).ElasticsearchConn + _, err := tfelasticsearch.FindVPCEndpointByID(context.Background(), conn, rs.Primary.ID) + if tfresource.NotFound(err) { + continue + } + if err != nil { + return err + } + + return fmt.Errorf("elasticsearch domain vpc endpoint %s still exists", rs.Primary.ID) + } + return nil +} + +func testAccCheckDomainVPCEndpointExists(esResource string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[esResource] + if !ok { + return fmt.Errorf("Not found: %s", esResource) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("no ID is set") + } + + conn := acctest.Provider.Meta().(*conns.AWSClient).ElasticsearchConn + _, err := tfelasticsearch.FindVPCEndpointByID(context.Background(), conn, rs.Primary.ID) + return err + } +} + +func testAccDomainVPCEndpointConfig_basic(domainName string) string { + return acctest.ConfigCompose(acctest.ConfigVPCWithSubnets(domainName, 2), fmt.Sprintf(` +resource "aws_elasticsearch_domain" "example" { + domain_name = %[1]q + elasticsearch_version = "7.10" + + cluster_config { + instance_type = "r5.large.elasticsearch" + } + + # Advanced security option must be enabled to configure SAML. + advanced_security_options { + enabled = true + internal_user_database_enabled = false + master_user_options { + master_user_arn = aws_iam_user.es_master_user.arn + } + } + + # You must enable node-to-node encryption to use advanced security options. + encrypt_at_rest { + enabled = true + } + + domain_endpoint_options { + enforce_https = true + tls_security_policy = "Policy-Min-TLS-1-2-2019-07" + } + + node_to_node_encryption { + enabled = true + } + + ebs_options { + ebs_enabled = true + volume_size = 10 + } +} + +resource "aws_elasticsearch_domain_vpc_endpoint" "test" { + domain_arn = aws_elasticsearch_domain.example.arn + vpc_options = { + security_group_ids = [ aws_security_group.test.id ] + subnet_ids = aws_subnet.test[*].id + } +} + +resource "aws_security_group" "test" { + name = local.random_name + vpc_id = aws_vpc.test.id +} + +resource "aws_security_group_rule" "test" { + type = "ingress" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + + security_group_id = aws_security_group.test.id +} +`, domainName)) +} diff --git a/internal/service/elasticsearch/find.go b/internal/service/elasticsearch/find.go index 8e4dc48055f4..acd12cb636b1 100644 --- a/internal/service/elasticsearch/find.go +++ b/internal/service/elasticsearch/find.go @@ -1,6 +1,9 @@ package elasticsearch import ( + "context" + "fmt" + "github.com/aws/aws-sdk-go/aws" elasticsearch "github.com/aws/aws-sdk-go/service/elasticsearchservice" "github.com/hashicorp/aws-sdk-go-base/v2/awsv1shim/v2/tfawserr" @@ -32,3 +35,21 @@ func FindDomainByName(conn *elasticsearch.ElasticsearchService, name string) (*e return output.DomainStatus, nil } + +func FindVPCEndpointByID(ctx context.Context, conn *elasticsearch.ElasticsearchService, id string) (*elasticsearch.VpcEndpoint, error) { + output, err := conn.DescribeVpcEndpointsWithContext(ctx, &elasticsearch.DescribeVpcEndpointsInput{ + VpcEndpointIds: []*string{&id}, + }) + if err != nil { + return nil, err + } + + countVPCEndpoints := len(output.VpcEndpoints) + if countVPCEndpoints == 0 { + return nil, fmt.Errorf("got more than one VPCEndpoint for id ( %s )", id) + } + if countVPCEndpoints > 1 { + return output.VpcEndpoints[0], fmt.Errorf("got %d instead of one VPCEndpoint for id ( %s )", countVPCEndpoints, id) + } + return output.VpcEndpoints[0], nil +}