Skip to content

Commit

Permalink
Support VPC configuration of aws_elasticsearch_domain resources. (#1958)
Browse files Browse the repository at this point in the history
* Support VPC configuration of aws_elasticsearch_domain resources.

* Elasticsearch VPC support: creation/endpoint bugfixes; VPC acceptance tests.

* Elasticsearch VPC support: PR feedback

* Elasticsearch VPC support: 2nd PR feedback

* Elasticsearch VPC support: 3rd PR feedback.

- If creating an ES domain in VPC, create IAM service-linked role if
  not already existing.
- Randomize domain name during basic create/destroy test for ES in VPC.
- Add create/update test for ES in VPC.

* Simplify error handling + retry on missing service role
  • Loading branch information
handlerbot authored and radeksimko committed Oct 26, 2017
1 parent 8ef6def commit 5659df1
Show file tree
Hide file tree
Showing 4 changed files with 336 additions and 10 deletions.
123 changes: 113 additions & 10 deletions aws/resource_aws_elasticsearch_domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
elasticsearch "github.com/aws/aws-sdk-go/service/elasticsearchservice"
"github.com/aws/aws-sdk-go/service/iam"
"github.com/hashicorp/errwrap"
"github.com/hashicorp/terraform/helper/resource"
"github.com/hashicorp/terraform/helper/schema"
"strings"
)

func resourceAwsElasticSearchDomain() *schema.Resource {
Expand Down Expand Up @@ -137,6 +137,37 @@ func resourceAwsElasticSearchDomain() *schema.Resource {
},
},
},
"vpc_options": {
Type: schema.TypeList,
Optional: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"availability_zones": {
Type: schema.TypeSet,
Computed: true,
Elem: &schema.Schema{Type: schema.TypeString},
Set: schema.HashString,
},
"security_group_ids": {
Type: schema.TypeSet,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Set: schema.HashString,
},
"subnet_ids": {
Type: schema.TypeSet,
Optional: true,
Elem: &schema.Schema{Type: schema.TypeString},
Set: schema.HashString,
},
"vpc_id": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
"elasticsearch_version": {
Type: schema.TypeString,
Optional: true,
Expand All @@ -155,6 +186,37 @@ func resourceAwsElasticSearchDomainImport(
return []*schema.ResourceData{d}, nil
}

// This would be created automatically if the domain is created via Console
// see http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-vpc.html#es-enabling-slr
func createAwsElasticsearchIAMServiceRoleIfMissing(meta interface{}) error {
serviceRoleName := "AWSServiceRoleForAmazonElasticsearchService"
serviceName := "es.amazonaws.com"

conn := meta.(*AWSClient).iamconn

getRequest := &iam.GetRoleInput{
RoleName: aws.String(serviceRoleName),
}
_, err := conn.GetRole(getRequest)
if err != nil {
if isAWSErr(err, iam.ErrCodeNoSuchEntityException, "Role not found") {
createRequest := &iam.CreateServiceLinkedRoleInput{
AWSServiceName: aws.String(serviceName),
}
_, err := conn.CreateServiceLinkedRole(createRequest)
if err != nil {
if isAWSErr(err, iam.ErrCodeInvalidInputException, "has been taken in this account") {
return nil
}
return fmt.Errorf("Error creating IAM Service-Linked Role %s: %s", serviceRoleName, err)
}
return nil
}
return fmt.Errorf("Error reading IAM Role %s: %s", serviceRoleName, err)
}
return nil
}

func resourceAwsElasticSearchDomainCreate(d *schema.ResourceData, meta interface{}) error {
conn := meta.(*AWSClient).esconn

Expand Down Expand Up @@ -230,6 +292,21 @@ func resourceAwsElasticSearchDomainCreate(d *schema.ResourceData, meta interface
}
}

if v, ok := d.GetOk("vpc_options"); ok {
err = createAwsElasticsearchIAMServiceRoleIfMissing(meta)
if err != nil {
return err
}

options := v.([]interface{})
if options[0] == nil {
return fmt.Errorf("At least one field is expected inside vpc_options")
}

s := options[0].(map[string]interface{})
input.VPCOptions = expandESVPCOptions(s)
}

log.Printf("[DEBUG] Creating ElasticSearch domain: %s", input)

// IAM Roles can take some time to propagate if set in AccessPolicies and created in the same terraform
Expand All @@ -238,12 +315,14 @@ func resourceAwsElasticSearchDomainCreate(d *schema.ResourceData, meta interface
var err error
out, err = conn.CreateElasticsearchDomain(&input)
if err != nil {
if awsErr, ok := err.(awserr.Error); ok {
if awsErr.Code() == "InvalidTypeException" && strings.Contains(awsErr.Message(), "Error setting policy") {
log.Printf("[DEBUG] Retrying creation of ElasticSearch domain %s", *input.DomainName)
return resource.RetryableError(err)
}
if isAWSErr(err, "InvalidTypeException", "Error setting policy") {
log.Printf("[DEBUG] Retrying creation of ElasticSearch domain %s", *input.DomainName)
return resource.RetryableError(err)
}
if isAWSErr(err, "ValidationException", "enable a service-linked role to give Amazon ES permissions") {
return resource.RetryableError(err)
}

return resource.NonRetryableError(err)
}
return nil
Expand Down Expand Up @@ -289,7 +368,7 @@ func waitForElasticSearchDomainCreation(conn *elasticsearch.ElasticsearchService
return resource.NonRetryableError(err)
}

if !*out.DomainStatus.Processing && out.DomainStatus.Endpoint != nil {
if !*out.DomainStatus.Processing && (out.DomainStatus.Endpoint != nil || out.DomainStatus.Endpoints != nil) {
return nil
}

Expand Down Expand Up @@ -332,9 +411,6 @@ func resourceAwsElasticSearchDomainRead(d *schema.ResourceData, meta interface{}
d.Set("domain_id", ds.DomainId)
d.Set("domain_name", ds.DomainName)
d.Set("elasticsearch_version", ds.ElasticsearchVersion)
if ds.Endpoint != nil {
d.Set("endpoint", *ds.Endpoint)
}

err = d.Set("ebs_options", flattenESEBSOptions(ds.EBSOptions))
if err != nil {
Expand All @@ -349,6 +425,27 @@ func resourceAwsElasticSearchDomainRead(d *schema.ResourceData, meta interface{}
"automated_snapshot_start_hour": *ds.SnapshotOptions.AutomatedSnapshotStartHour,
})
}
if ds.VPCOptions != nil {
err = d.Set("vpc_options", flattenESVPCDerivedInfo(ds.VPCOptions))
if err != nil {
return err
}
endpoints := pointersMapToStringList(ds.Endpoints)
err = d.Set("endpoint", endpoints["vpc"])
if err != nil {
return err
}
if ds.Endpoint != nil {
return fmt.Errorf("%q: Elasticsearch domain in VPC expected to have null Endpoint value", d.Id())
}
} else {
if ds.Endpoint != nil {
d.Set("endpoint", *ds.Endpoint)
}
if ds.Endpoints != nil {
return fmt.Errorf("%q: Elasticsearch domain not in VPC expected to have null Endpoints value", d.Id())
}
}

d.Set("arn", ds.ARN)

Expand Down Expand Up @@ -431,6 +528,12 @@ func resourceAwsElasticSearchDomainUpdate(d *schema.ResourceData, meta interface
}
}

if d.HasChange("vpc_options") {
options := d.Get("vpc_options").([]interface{})
s := options[0].(map[string]interface{})
input.VPCOptions = expandESVPCOptions(s)
}

_, err := conn.UpdateElasticsearchDomainConfig(&input)
if err != nil {
return err
Expand Down
179 changes: 179 additions & 0 deletions aws/resource_aws_elasticsearch_domain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,62 @@ func TestAccAWSElasticSearchDomain_complex(t *testing.T) {
})
}

func TestAccAWSElasticSearchDomain_vpc(t *testing.T) {
var domain elasticsearch.ElasticsearchDomainStatus
ri := acctest.RandInt()

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckESDomainDestroy,
Steps: []resource.TestStep{
{
Config: testAccESDomainConfig_vpc(ri),
Check: resource.ComposeTestCheckFunc(
testAccCheckESDomainExists("aws_elasticsearch_domain.example", &domain),
),
},
},
})
}

func TestAccAWSElasticSearchDomain_vpc_update(t *testing.T) {
var domain elasticsearch.ElasticsearchDomainStatus
ri := acctest.RandInt()

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
Providers: testAccProviders,
CheckDestroy: testAccCheckESDomainDestroy,
Steps: []resource.TestStep{
{
Config: testAccESDomainConfig_vpc_update(ri, false),
Check: resource.ComposeTestCheckFunc(
testAccCheckESDomainExists("aws_elasticsearch_domain.example", &domain),
testAccCheckESNumberOfSecurityGroups(1, &domain),
),
},
{
Config: testAccESDomainConfig_vpc_update(ri, true),
Check: resource.ComposeTestCheckFunc(
testAccCheckESDomainExists("aws_elasticsearch_domain.example", &domain),
testAccCheckESNumberOfSecurityGroups(2, &domain),
),
},
},
})
}

func testAccCheckESNumberOfSecurityGroups(numberOfSecurityGroups int, status *elasticsearch.ElasticsearchDomainStatus) resource.TestCheckFunc {
return func(s *terraform.State) error {
count := len(status.VPCOptions.SecurityGroupIds)
if count != numberOfSecurityGroups {
return fmt.Errorf("Number of security groups differ. Given: %d, Expected: %d", count, numberOfSecurityGroups)
}
return nil
}
}

func TestAccAWSElasticSearchDomain_policy(t *testing.T) {
var domain elasticsearch.ElasticsearchDomainStatus

Expand Down Expand Up @@ -448,3 +504,126 @@ resource "aws_elasticsearch_domain" "example" {
}
`, randInt)
}

func testAccESDomainConfig_vpc(randInt int) string {
return fmt.Sprintf(`
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "elasticsearch_in_vpc" {
cidr_block = "192.168.0.0/22"
}
resource "aws_subnet" "first" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
availability_zone = "${data.aws_availability_zones.available.names[0]}"
cidr_block = "192.168.0.0/24"
}
resource "aws_subnet" "second" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
availability_zone = "${data.aws_availability_zones.available.names[1]}"
cidr_block = "192.168.1.0/24"
}
resource "aws_security_group" "first" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
}
resource "aws_security_group" "second" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
}
resource "aws_elasticsearch_domain" "example" {
domain_name = "tf-test-%d"
ebs_options {
ebs_enabled = false
}
cluster_config {
instance_count = 2
zone_awareness_enabled = true
instance_type = "r3.large.elasticsearch"
}
vpc_options {
security_group_ids = ["${aws_security_group.first.id}", "${aws_security_group.second.id}"]
subnet_ids = ["${aws_subnet.first.id}", "${aws_subnet.second.id}"]
}
}
`, randInt)
}

func testAccESDomainConfig_vpc_update(randInt int, update bool) string {
var sg_ids, subnet_string string
if update {
sg_ids = "${aws_security_group.first.id}\", \"${aws_security_group.second.id}"
subnet_string = "second"
} else {
sg_ids = "${aws_security_group.first.id}"
subnet_string = "first"
}

return fmt.Sprintf(`
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "elasticsearch_in_vpc" {
cidr_block = "192.168.0.0/22"
}
resource "aws_subnet" "az1_first" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
availability_zone = "${data.aws_availability_zones.available.names[0]}"
cidr_block = "192.168.0.0/24"
}
resource "aws_subnet" "az2_first" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
availability_zone = "${data.aws_availability_zones.available.names[1]}"
cidr_block = "192.168.1.0/24"
}
resource "aws_subnet" "az1_second" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
availability_zone = "${data.aws_availability_zones.available.names[0]}"
cidr_block = "192.168.2.0/24"
}
resource "aws_subnet" "az2_second" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
availability_zone = "${data.aws_availability_zones.available.names[1]}"
cidr_block = "192.168.3.0/24"
}
resource "aws_security_group" "first" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
}
resource "aws_security_group" "second" {
vpc_id = "${aws_vpc.elasticsearch_in_vpc.id}"
}
resource "aws_elasticsearch_domain" "example" {
domain_name = "tf-test-%d"
ebs_options {
ebs_enabled = false
}
cluster_config {
instance_count = 2
zone_awareness_enabled = true
instance_type = "r3.large.elasticsearch"
}
vpc_options {
security_group_ids = ["%s"]
subnet_ids = ["${aws_subnet.az1_%s.id}", "${aws_subnet.az2_%s.id}"]
}
}
`, randInt, sg_ids, subnet_string, subnet_string)
}
Loading

0 comments on commit 5659df1

Please sign in to comment.