Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support VPC configuration of aws_elasticsearch_domain resources. #1958

Merged
merged 6 commits into from
Oct 26, 2017
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 107 additions & 4 deletions aws/resource_aws_elasticsearch_domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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"
Expand Down Expand Up @@ -137,6 +138,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 +187,38 @@ func resourceAwsElasticSearchDomainImport(
return []*schema.ResourceData{d}, nil
}

func createAwsElasticsearchIAMServiceRoleIfMissing(meta interface{}) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I first thought this is a bad idea, until I took time and read http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-vpc.html#es-enabling-slr 😢

I wish there was an ES service method to do this... but that's not the reality, so good call 👍

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 iamerr, ok := err.(awserr.Error); ok {
switch iamerr.Code() {
case iam.ErrCodeNoSuchEntityException:
createRequest := &iam.CreateServiceLinkedRoleInput{
AWSServiceName: aws.String(serviceName),
}
_, err := conn.CreateServiceLinkedRole(createRequest)
if err != nil {
return fmt.Errorf("Error creating IAM Service-Linked Role %s: %s", serviceRoleName, err)
}
time.Sleep(20 * time.Second) // Give time for new IAM service-linked role to propagate globally
default:
return fmt.Errorf("Error reading IAM Role %s: %s", serviceRoleName, iamerr.Error())
}
} else {
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 +294,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 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),
),
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add another step, where we would update the VPC configuration, so that we ensure the update works as expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ninir Yeah, I can do that, once I sort the larger IAM problem for the test (update re: that coming next).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disregard "larger IAM problem", I figured it out. 🎉

},
})
}

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}"
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid sharing any resources and allow running multiple tests in parallel do you mind building custom VPC & subnets here, instead of creating default ones?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done & re-running this test as I write this. It hasn't completed yet, but it brought up the VPC and subnets and SGs, and the ES domain is creating, so I have very high confidence the tear-down will work. :-)


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)
}
32 changes: 32 additions & 0 deletions aws/structure.go
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,38 @@ func expandESEBSOptions(m map[string]interface{}) *elasticsearch.EBSOptions {
return &options
}

func flattenESVPCDerivedInfo(o *elasticsearch.VPCDerivedInfo) []map[string]interface{} {
m := map[string]interface{}{}

if o.AvailabilityZones != nil {
m["availability_zones"] = schema.NewSet(schema.HashString, flattenStringList(o.AvailabilityZones))
}
if o.SecurityGroupIds != nil {
m["security_group_ids"] = schema.NewSet(schema.HashString, flattenStringList(o.SecurityGroupIds))
}
if o.SubnetIds != nil {
m["subnet_ids"] = schema.NewSet(schema.HashString, flattenStringList(o.SubnetIds))
}
if o.VPCId != nil {
m["vpc_id"] = *o.VPCId
}

return []map[string]interface{}{m}
}

func expandESVPCOptions(m map[string]interface{}) *elasticsearch.VPCOptions {
options := elasticsearch.VPCOptions{}

if v, ok := m["security_group_ids"]; ok {
options.SecurityGroupIds = expandStringList(v.(*schema.Set).List())
}
if v, ok := m["subnet_ids"]; ok {
options.SubnetIds = expandStringList(v.(*schema.Set).List())
}

return &options
}

func expandConfigRecordingGroup(configured []interface{}) *configservice.RecordingGroup {
recordingGroup := configservice.RecordingGroup{}
group := configured[0].(map[string]interface{})
Expand Down
Loading