diff --git a/builtin/providers/aws/provider.go b/builtin/providers/aws/provider.go index 0ab2919fd..4963bfa48 100644 --- a/builtin/providers/aws/provider.go +++ b/builtin/providers/aws/provider.go @@ -58,6 +58,7 @@ func Provider() terraform.ResourceProvider { "aws_launch_configuration": resourceAwsLaunchConfiguration(), "aws_main_route_table_association": resourceAwsMainRouteTableAssociation(), "aws_network_acl": resourceAwsNetworkAcl(), + "aws_network_interface": resourceAwsNetworkInterface(), "aws_route53_record": resourceAwsRoute53Record(), "aws_route53_zone": resourceAwsRoute53Zone(), "aws_route_table": resourceAwsRouteTable(), diff --git a/builtin/providers/aws/resource_aws_network_interface.go b/builtin/providers/aws/resource_aws_network_interface.go new file mode 100644 index 000000000..c4829f4b8 --- /dev/null +++ b/builtin/providers/aws/resource_aws_network_interface.go @@ -0,0 +1,263 @@ +package aws + +import ( + "bytes" + "fmt" + "log" + "strconv" + "time" + + "github.com/hashicorp/aws-sdk-go/aws" + "github.com/hashicorp/aws-sdk-go/gen/ec2" + "github.com/hashicorp/terraform/helper/hashcode" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/helper/schema" +) + +func resourceAwsNetworkInterface() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsNetworkInterfaceCreate, + Read: resourceAwsNetworkInterfaceRead, + Update: resourceAwsNetworkInterfaceUpdate, + Delete: resourceAwsNetworkInterfaceDelete, + + Schema: map[string]*schema.Schema{ + + "subnet_id": &schema.Schema{ + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + + "private_ips": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + ForceNew: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: func(v interface{}) int { + return hashcode.String(v.(string)) + }, + }, + + "security_groups": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Set: func(v interface{}) int { + return hashcode.String(v.(string)) + }, + }, + + "attachment": &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "instance": &schema.Schema{ + Type: schema.TypeString, + Required: true, + }, + "device_index": &schema.Schema{ + Type: schema.TypeInt, + Required: true, + }, + "attachment_id": &schema.Schema{ + Type: schema.TypeString, + Computed: true, + }, + }, + }, + Set: resourceAwsEniAttachmentHash, + }, + + "tags": tagsSchema(), + }, + } +} + +func resourceAwsNetworkInterfaceCreate(d *schema.ResourceData, meta interface{}) error { + + ec2conn := meta.(*AWSClient).ec2conn + + request := &ec2.CreateNetworkInterfaceRequest{ + Groups: expandStringList(d.Get("security_groups").(*schema.Set).List()), + SubnetID: aws.String(d.Get("subnet_id").(string)), + PrivateIPAddresses: expandPrivateIPAddesses(d.Get("private_ips").(*schema.Set).List()), + } + + log.Printf("[DEBUG] Creating network interface") + resp, err := ec2conn.CreateNetworkInterface(request) + if err != nil { + return fmt.Errorf("Error creating ENI: %s", err) + } + + d.SetId(*resp.NetworkInterface.NetworkInterfaceID) + log.Printf("[INFO] ENI ID: %s", d.Id()) + + return resourceAwsNetworkInterfaceUpdate(d, meta) +} + +func resourceAwsNetworkInterfaceRead(d *schema.ResourceData, meta interface{}) error { + + ec2conn := meta.(*AWSClient).ec2conn + describe_network_interfaces_request := &ec2.DescribeNetworkInterfacesRequest{ + NetworkInterfaceIDs: []string{d.Id()}, + } + describeResp, err := ec2conn.DescribeNetworkInterfaces(describe_network_interfaces_request) + + if err != nil { + if ec2err, ok := err.(aws.APIError); ok && ec2err.Code == "InvalidNetworkInterfaceID.NotFound" { + // The ENI is gone now, so just remove it from the state + d.SetId("") + return nil + } + + return fmt.Errorf("Error retrieving ENI: %s", err) + } + if len(describeResp.NetworkInterfaces) != 1 { + return fmt.Errorf("Unable to find ENI: %#v", describeResp.NetworkInterfaces) + } + + eni := describeResp.NetworkInterfaces[0] + d.Set("subnet_id", eni.SubnetID) + d.Set("private_ips", flattenNetworkInterfacesPrivateIPAddesses(eni.PrivateIPAddresses)) + d.Set("security_groups", flattenGroupIdentifiers(eni.Groups)) + + if eni.Attachment != nil { + attachment := []map[string]interface{} { flattenAttachment(eni.Attachment) } + d.Set("attachment", attachment) + } else { + d.Set("attachment", nil) + } + + return nil +} + +func networkInterfaceAttachmentRefreshFunc(ec2conn *ec2.EC2, id string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + + describe_network_interfaces_request := &ec2.DescribeNetworkInterfacesRequest{ + NetworkInterfaceIDs: []string{id}, + } + describeResp, err := ec2conn.DescribeNetworkInterfaces(describe_network_interfaces_request) + + if err != nil { + log.Printf("[ERROR] Could not find network interface %s. %s", id, err) + return nil, "", err + } + + eni := describeResp.NetworkInterfaces[0] + hasAttachment := strconv.FormatBool(eni.Attachment != nil) + log.Printf("[DEBUG] ENI %s has attachment state %s", id, hasAttachment) + return eni, hasAttachment, nil + } +} + +func resourceAwsNetworkInterfaceDetach(oa *schema.Set, meta interface{}, eniId string) error { + // if there was an old attachment, remove it + if oa != nil && len(oa.List()) > 0 { + old_attachment := oa.List()[0].(map[string]interface{}) + detach_request := &ec2.DetachNetworkInterfaceRequest{ + AttachmentID: aws.String(old_attachment["attachment_id"].(string)), + Force: aws.Boolean(true), + } + ec2conn := meta.(*AWSClient).ec2conn + detach_err := ec2conn.DetachNetworkInterface(detach_request) + if detach_err != nil { + return fmt.Errorf("Error detaching ENI: %s", detach_err) + } + + log.Printf("[DEBUG] Waiting for ENI (%s) to become dettached", eniId) + stateConf := &resource.StateChangeConf{ + Pending: []string{"true"}, + Target: "false", + Refresh: networkInterfaceAttachmentRefreshFunc(ec2conn, eniId), + Timeout: 10 * time.Minute, + } + if _, err := stateConf.WaitForState(); err != nil { + return fmt.Errorf( + "Error waiting for ENI (%s) to become dettached: %s", eniId, err) + } + } + + return nil +} + +func resourceAwsNetworkInterfaceUpdate(d *schema.ResourceData, meta interface{}) error { + + d.Partial(true) + + if d.HasChange("attachment") { + ec2conn := meta.(*AWSClient).ec2conn + oa, na := d.GetChange("attachment") + + detach_err := resourceAwsNetworkInterfaceDetach(oa.(*schema.Set), meta, d.Id()) + if detach_err != nil { + return detach_err + } + + // if there is a new attachment, attach it + if na != nil && len(na.(*schema.Set).List()) > 0 { + new_attachment := na.(*schema.Set).List()[0].(map[string]interface{}) + attach_request := &ec2.AttachNetworkInterfaceRequest{ + DeviceIndex: aws.Integer(new_attachment["device_index"].(int)), + InstanceID: aws.String(new_attachment["instance"].(string)), + NetworkInterfaceID: aws.String(d.Id()), + } + _, attach_err := ec2conn.AttachNetworkInterface(attach_request) + if attach_err != nil { + return fmt.Errorf("Error attaching ENI: %s", attach_err) + } + } + + d.SetPartial("attachment") + } + + if d.HasChange("security_groups") { + request := &ec2.ModifyNetworkInterfaceAttributeRequest{ + NetworkInterfaceID: aws.String(d.Id()), + Groups: expandStringList(d.Get("security_groups").(*schema.Set).List()), + } + + ec2conn := meta.(*AWSClient).ec2conn + err := ec2conn.ModifyNetworkInterfaceAttribute(request) + if err != nil { + return fmt.Errorf("Failure updating ENI: %s", err) + } + + d.SetPartial("security_groups") + } + + d.Partial(false) + + return resourceAwsNetworkInterfaceRead(d, meta) +} + +func resourceAwsNetworkInterfaceDelete(d *schema.ResourceData, meta interface{}) error { + ec2conn := meta.(*AWSClient).ec2conn + + log.Printf("[INFO] Deleting ENI: %s", d.Id()) + + detach_err := resourceAwsNetworkInterfaceDetach(d.Get("attachment").(*schema.Set), meta, d.Id()) + if detach_err != nil { + return detach_err + } + + deleteEniOpts := ec2.DeleteNetworkInterfaceRequest{ + NetworkInterfaceID: aws.String(d.Id()), + } + if err := ec2conn.DeleteNetworkInterface(&deleteEniOpts); err != nil { + return fmt.Errorf("Error deleting ENI: %s", err) + } + + return nil +} + +func resourceAwsEniAttachmentHash(v interface{}) int { + var buf bytes.Buffer + m := v.(map[string]interface{}) + buf.WriteString(fmt.Sprintf("%s-", m["instance"].(string))) + buf.WriteString(fmt.Sprintf("%d-", m["device_index"].(int))) + return hashcode.String(buf.String()) +} diff --git a/builtin/providers/aws/resource_aws_network_interface_test.go b/builtin/providers/aws/resource_aws_network_interface_test.go new file mode 100644 index 000000000..413533e56 --- /dev/null +++ b/builtin/providers/aws/resource_aws_network_interface_test.go @@ -0,0 +1,235 @@ +package aws + +import ( + "fmt" + "testing" + + "github.com/hashicorp/aws-sdk-go/aws" + "github.com/hashicorp/aws-sdk-go/gen/ec2" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccAWSENI_basic(t *testing.T) { + var conf ec2.NetworkInterface + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSENIDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSENIConfig, + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSENIExists("aws_network_interface.bar", &conf), + testAccCheckAWSENIAttributes(&conf), + resource.TestCheckResourceAttr( + "aws_network_interface.bar", "private_ips.#", "1"), + resource.TestCheckResourceAttr( + "aws_network_interface.bar", "tags.Name", "bar_interface"), + ), + }, + }, + }) +} + +func TestAccAWSENI_attached(t *testing.T) { + var conf ec2.NetworkInterface + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSENIDestroy, + Steps: []resource.TestStep{ + resource.TestStep{ + Config: testAccAWSENIConfigWithAttachment, + Check: resource.ComposeTestCheckFunc( + testAccCheckAWSENIExists("aws_network_interface.bar", &conf), + testAccCheckAWSENIAttributesWithAttachment(&conf), + resource.TestCheckResourceAttr( + "aws_network_interface.bar", "private_ips.#", "1"), + resource.TestCheckResourceAttr( + "aws_network_interface.bar", "tags.Name", "bar_interface"), + ), + }, + }, + }) +} + +func testAccCheckAWSENIExists(n string, res *ec2.NetworkInterface) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not found: %s", n) + } + + if rs.Primary.ID == "" { + return fmt.Errorf("No ENI ID is set") + } + + ec2conn := testAccProvider.Meta().(*AWSClient).ec2conn + describe_network_interfaces_request := &ec2.DescribeNetworkInterfacesRequest{ + NetworkInterfaceIDs: []string{rs.Primary.ID}, + } + describeResp, err := ec2conn.DescribeNetworkInterfaces(describe_network_interfaces_request) + + if err != nil { + return err + } + + if len(describeResp.NetworkInterfaces) != 1 || + *describeResp.NetworkInterfaces[0].NetworkInterfaceID != rs.Primary.ID { + return fmt.Errorf("ENI not found") + } + + *res = describeResp.NetworkInterfaces[0] + + return nil + } +} + +func testAccCheckAWSENIAttributes(conf *ec2.NetworkInterface) resource.TestCheckFunc { + return func(s *terraform.State) error { + + if conf.Attachment != nil { + return fmt.Errorf("expected attachment to be nil") + } + + if *conf.AvailabilityZone != "us-west-2a" { + return fmt.Errorf("expected availability_zone to be us-west-2a, but was %s", *conf.AvailabilityZone) + } + + if len(conf.Groups) != 1 && *conf.Groups[0].GroupName != "foo" { + return fmt.Errorf("expected security group to be foo, but was %#v", conf.Groups) + } + + if *conf.PrivateIPAddress != "172.16.10.100" { + return fmt.Errorf("expected private ip to be 172.16.10.100, but was %s", *conf.PrivateIPAddress) + } + + return nil + } +} + +func testAccCheckAWSENIAttributesWithAttachment(conf *ec2.NetworkInterface) resource.TestCheckFunc { + return func(s *terraform.State) error { + + if conf.Attachment == nil { + return fmt.Errorf("expected attachment to be set, but was nil") + } + + if *conf.Attachment.DeviceIndex != 1 { + return fmt.Errorf("expected attachment device index to be 1, but was %d", *conf.Attachment.DeviceIndex) + } + + if *conf.AvailabilityZone != "us-west-2a" { + return fmt.Errorf("expected availability_zone to be us-west-2a, but was %s", *conf.AvailabilityZone) + } + + if len(conf.Groups) != 1 && *conf.Groups[0].GroupName != "foo" { + return fmt.Errorf("expected security group to be foo, but was %#v", conf.Groups) + } + + if *conf.PrivateIPAddress != "172.16.10.100" { + return fmt.Errorf("expected private ip to be 172.16.10.100, but was %s", *conf.PrivateIPAddress) + } + + return nil + } +} + +func testAccCheckAWSENIDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_network_interface" { + continue + } + + ec2conn := testAccProvider.Meta().(*AWSClient).ec2conn + describe_network_interfaces_request := &ec2.DescribeNetworkInterfacesRequest{ + NetworkInterfaceIDs: []string{rs.Primary.ID}, + } + _, err := ec2conn.DescribeNetworkInterfaces(describe_network_interfaces_request) + + if err != nil { + if ec2err, ok := err.(aws.APIError); ok && ec2err.Code == "InvalidNetworkInterfaceID.NotFound" { + return nil + } + + return err + } + } + + return nil +} + +const testAccAWSENIConfig = ` +resource "aws_vpc" "foo" { + cidr_block = "172.16.0.0/16" +} + +resource "aws_subnet" "foo" { + vpc_id = "${aws_vpc.foo.id}" + cidr_block = "172.16.10.0/24" + availability_zone = "us-west-2a" +} + +resource "aws_security_group" "foo" { + vpc_id = "${aws_vpc.foo.id}" + description = "foo" + name = "foo" +} + +resource "aws_network_interface" "bar" { + subnet_id = "${aws_subnet.foo.id}" + private_ips = ["172.16.10.100"] + security_groups = ["${aws_security_group.foo.id}"] + tags { + Name = "bar_interface" + } +} +` + +const testAccAWSENIConfigWithAttachment = ` +resource "aws_vpc" "foo" { + cidr_block = "172.16.0.0/16" +} + +resource "aws_subnet" "foo" { + vpc_id = "${aws_vpc.foo.id}" + cidr_block = "172.16.10.0/24" + availability_zone = "us-west-2a" +} + +resource "aws_subnet" "bar" { + vpc_id = "${aws_vpc.foo.id}" + cidr_block = "172.16.11.0/24" + availability_zone = "us-west-2a" +} + +resource "aws_security_group" "foo" { + vpc_id = "${aws_vpc.foo.id}" + description = "foo" + name = "foo" +} + +resource "aws_instance" "foo" { + ami = "ami-c5eabbf5" + instance_type = "t2.micro" + subnet_id = "${aws_subnet.bar.id}" + associate_public_ip_address = false + private_ip = "172.16.11.50" +} + +resource "aws_network_interface" "bar" { + subnet_id = "${aws_subnet.foo.id}" + private_ips = ["172.16.10.100"] + security_groups = ["${aws_security_group.foo.id}"] + attachment { + instance = "${aws_instance.foo.id}" + device_index = 1 + } + tags { + Name = "bar_interface" + } +} +` diff --git a/builtin/providers/aws/structure.go b/builtin/providers/aws/structure.go index 3880f3e82..7a36f5304 100644 --- a/builtin/providers/aws/structure.go +++ b/builtin/providers/aws/structure.go @@ -207,3 +207,47 @@ func expandStringList(configured []interface{}) []string { } return vs } + +//Flattens an array of private ip addresses into a []string, where the elements returned are the IP strings e.g. "192.168.0.0" +func flattenNetworkInterfacesPrivateIPAddesses(dtos []ec2.NetworkInterfacePrivateIPAddress) []string { + ips := make([]string, 0, len(dtos)) + for _, v := range dtos { + ip := *v.PrivateIPAddress + ips = append(ips, ip) + } + return ips +} + +//Flattens security group identifiers into a []string, where the elements returned are the GroupIDs +func flattenGroupIdentifiers(dtos []ec2.GroupIdentifier) []string { + ids := make([]string, 0, len(dtos)) + for _, v := range dtos { + group_id := *v.GroupID + ids = append(ids, group_id) + } + return ids +} + +//Expands an array of IPs into a ec2 Private IP Address Spec +func expandPrivateIPAddesses(ips []interface{}) []ec2.PrivateIPAddressSpecification { + dtos := make([]ec2.PrivateIPAddressSpecification, 0, len(ips)) + for i, v := range ips { + new_private_ip := ec2.PrivateIPAddressSpecification{ + PrivateIPAddress: aws.String(v.(string)), + } + + new_private_ip.Primary = aws.Boolean(i == 0) + + dtos = append(dtos, new_private_ip) + } + return dtos +} + +//Flattens network interface attachment into a map[string]interface +func flattenAttachment(a *ec2.NetworkInterfaceAttachment) map[string]interface{} { + att := make(map[string]interface{}) + att["instance"] = *a.InstanceID + att["device_index"] = *a.DeviceIndex + att["attachment_id"] = *a.AttachmentID + return att +} diff --git a/builtin/providers/aws/structure_test.go b/builtin/providers/aws/structure_test.go index 12af95328..ff3d20305 100644 --- a/builtin/providers/aws/structure_test.go +++ b/builtin/providers/aws/structure_test.go @@ -346,3 +346,99 @@ func TestExpandInstanceString(t *testing.T) { t.Fatalf("Expand Instance String output did not match.\nGot:\n%#v\n\nexpected:\n%#v", expanded, expected) } } + +func TestFlattenNetworkInterfacesPrivateIPAddesses(t *testing.T) { + expanded := []ec2.NetworkInterfacePrivateIPAddress{ + ec2.NetworkInterfacePrivateIPAddress{PrivateIPAddress: aws.String("192.168.0.1")}, + ec2.NetworkInterfacePrivateIPAddress{PrivateIPAddress: aws.String("192.168.0.2")}, + } + + result := flattenNetworkInterfacesPrivateIPAddesses(expanded) + + if result == nil { + t.Fatal("result was nil") + } + + if len(result) != 2 { + t.Fatalf("expected result had %d elements, but got %d", 2, len(result)) + } + + if result[0] != "192.168.0.1" { + t.Fatalf("expected ip to be 192.168.0.1, but was %s", result[0]) + } + + if result[1] != "192.168.0.2" { + t.Fatalf("expected ip to be 192.168.0.2, but was %s", result[1]) + } +} + +func TestFlattenGroupIdentifiers(t *testing.T) { + expanded := []ec2.GroupIdentifier{ + ec2.GroupIdentifier{GroupID: aws.String("sg-001")}, + ec2.GroupIdentifier{GroupID: aws.String("sg-002")}, + } + + result := flattenGroupIdentifiers(expanded) + + if len(result) != 2 { + t.Fatalf("expected result had %d elements, but got %d", 2, len(result)) + } + + if result[0] != "sg-001" { + t.Fatalf("expected id to be sg-001, but was %s", result[0]) + } + + if result[1] != "sg-002" { + t.Fatalf("expected id to be sg-002, but was %s", result[1]) + } +} + +func TestExpandPrivateIPAddesses(t *testing.T) { + + ip1 := "192.168.0.1" + ip2 := "192.168.0.2" + flattened := []interface{}{ + ip1, + ip2, + } + + result := expandPrivateIPAddesses(flattened) + + if len(result) != 2 { + t.Fatalf("expected result had %d elements, but got %d", 2, len(result)) + } + + if *result[0].PrivateIPAddress != "192.168.0.1" || !*result[0].Primary { + t.Fatalf("expected ip to be 192.168.0.1 and Primary, but got %v, %b", *result[0].PrivateIPAddress, *result[0].Primary) + } + + if *result[1].PrivateIPAddress != "192.168.0.2" || *result[1].Primary { + t.Fatalf("expected ip to be 192.168.0.2 and not Primary, but got %v, %b", *result[1].PrivateIPAddress, *result[1].Primary) + } +} + +func TestFlattenAttachment(t *testing.T) { + expanded := &ec2.NetworkInterfaceAttachment{ + InstanceID: aws.String("i-00001"), + DeviceIndex: aws.Integer(1), + AttachmentID: aws.String("at-002"), + } + + result := flattenAttachment(expanded) + + if result == nil { + t.Fatal("expected result to have value, but got nil") + } + + if result["instance"] != "i-00001" { + t.Fatalf("expected instance to be i-00001, but got %s", result["instance"]) + } + + if result["device_index"] != 1 { + t.Fatalf("expected device_index to be 1, but got %d", result["device_index"]) + } + + if result["attachment_id"] != "at-002" { + t.Fatalf("expected attachment_id to be at-002, but got %s", result["attachment_id"]) + } +}