Merge pull request #475 from feiskyer/neutronports

[rfr] Allow to specify hostid when creating/updating a port
diff --git a/acceptance/openstack/networking/v2/extensions/portsbinding/pkg.go b/acceptance/openstack/networking/v2/extensions/portsbinding/pkg.go
new file mode 100644
index 0000000..5dae1b1
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/portsbinding/pkg.go
@@ -0,0 +1 @@
+package portsbinding
diff --git a/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding_test.go b/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding_test.go
new file mode 100644
index 0000000..5a79945
--- /dev/null
+++ b/acceptance/openstack/networking/v2/extensions/portsbinding/portsbinding_test.go
@@ -0,0 +1,129 @@
+// +build acceptance networking portsbinding
+
+package portsbinding
+
+import (
+	"testing"
+
+	base "github.com/rackspace/gophercloud/acceptance/openstack/networking/v2"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/extensions/portsbinding"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/networks"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/subnets"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestPortBinding(t *testing.T) {
+	base.Setup(t)
+	defer base.Teardown()
+
+	// Setup network
+	t.Log("Setting up network")
+	networkID, err := createNetwork()
+	th.AssertNoErr(t, err)
+	defer networks.Delete(base.Client, networkID)
+
+	// Setup subnet
+	t.Logf("Setting up subnet on network %s", networkID)
+	subnetID, err := createSubnet(networkID)
+	th.AssertNoErr(t, err)
+	defer subnets.Delete(base.Client, subnetID)
+
+	// Create port
+	t.Logf("Create port based on subnet %s", subnetID)
+	hostID := "localhost"
+	portID := createPort(t, networkID, subnetID, hostID)
+
+	// Get port
+	if portID == "" {
+		t.Fatalf("In order to retrieve a port, the portID must be set")
+	}
+	p, err := portsbinding.Get(base.Client, portID).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.ID, portID)
+	th.AssertEquals(t, p.HostID, hostID)
+
+	// Update port
+	newHostID := "openstack"
+	updateOpts := portsbinding.UpdateOpts{
+		HostID: newHostID,
+	}
+	p, err = portsbinding.Update(base.Client, portID, updateOpts).Extract()
+
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.HostID, newHostID)
+
+	// List ports
+	t.Logf("Listing all ports")
+	listPorts(t)
+
+	// Delete port
+	res := ports.Delete(base.Client, portID)
+	th.AssertNoErr(t, res.Err)
+}
+
+func listPorts(t *testing.T) {
+	count := 0
+	pager := ports.List(base.Client, ports.ListOpts{})
+	err := pager.EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		t.Logf("--- Page ---")
+
+		portList, err := portsbinding.ExtractPorts(page)
+		th.AssertNoErr(t, err)
+
+		for _, p := range portList {
+			t.Logf("Port: ID [%s] Name [%s] HostID [%s] VNICType [%s] VIFType [%s]",
+				p.ID, p.Name, p.HostID, p.VNICType, p.VIFType)
+		}
+
+		return true, nil
+	})
+
+	th.CheckNoErr(t, err)
+
+	if count == 0 {
+		t.Logf("No pages were iterated over when listing ports")
+	}
+}
+
+func createPort(t *testing.T, networkID, subnetID, hostID string) string {
+	enable := false
+	opts := portsbinding.CreateOpts{
+		CreateOptsBuilder: ports.CreateOpts{
+			NetworkID:    networkID,
+			Name:         "my_port",
+			AdminStateUp: &enable,
+			FixedIPs:     []ports.IP{{SubnetID: subnetID}},
+		},
+		HostID: hostID,
+	}
+
+	p, err := portsbinding.Create(base.Client, opts).Extract()
+	th.AssertNoErr(t, err)
+	th.AssertEquals(t, p.NetworkID, networkID)
+	th.AssertEquals(t, p.Name, "my_port")
+	th.AssertEquals(t, p.AdminStateUp, false)
+
+	return p.ID
+}
+
+func createNetwork() (string, error) {
+	res, err := networks.Create(base.Client, networks.CreateOpts{Name: "tmp_network", AdminStateUp: networks.Up}).Extract()
+	return res.ID, err
+}
+
+func createSubnet(networkID string) (string, error) {
+	s, err := subnets.Create(base.Client, subnets.CreateOpts{
+		NetworkID:  networkID,
+		CIDR:       "192.168.199.0/24",
+		IPVersion:  subnets.IPv4,
+		Name:       "my_subnet",
+		EnableDHCP: subnets.Down,
+		AllocationPools: []subnets.AllocationPool{
+			{Start: "192.168.199.2", End: "192.168.199.200"},
+		},
+	}).Extract()
+	return s.ID, err
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/doc.go b/openstack/networking/v2/extensions/portsbinding/doc.go
new file mode 100644
index 0000000..0d2ed58
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/doc.go
@@ -0,0 +1,3 @@
+// Package portsbinding provides information and interaction with the port
+// binding extension for the OpenStack Networking service.
+package portsbinding
diff --git a/openstack/networking/v2/extensions/portsbinding/fixtures.go b/openstack/networking/v2/extensions/portsbinding/fixtures.go
new file mode 100644
index 0000000..9f7bd08
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/fixtures.go
@@ -0,0 +1,206 @@
+package portsbinding
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func HandleListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/v2.0/ports", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "ports": [
+        {
+            "status": "ACTIVE",
+            "binding:host_id": "devstack",
+            "name": "",
+            "admin_state_up": true,
+            "network_id": "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+            "tenant_id": "",
+            "device_owner": "network:router_gateway",
+            "mac_address": "fa:16:3e:58:42:ed",
+            "fixed_ips": [
+                {
+                    "subnet_id": "008ba151-0b8c-4a67-98b5-0d2b87666062",
+                    "ip_address": "172.24.4.2"
+                }
+            ],
+            "id": "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+            "security_groups": [],
+            "device_id": "9ae135f4-b6e0-4dad-9e91-3c223e385824",
+            "binding:vnic_type": "normal"
+        }
+    ]
+}
+      `)
+	})
+}
+
+func HandleGet(t *testing.T) {
+	th.Mux.HandleFunc("/v2.0/ports/46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "ACTIVE",
+        "binding:host_id": "devstack",
+        "name": "",
+        "allowed_address_pairs": [],
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "7e02058126cc4950b75f9970368ba177",
+        "extra_dhcp_opts": [],
+        "binding:vif_details": {
+            "port_filter": true,
+            "ovs_hybrid_plug": true
+        },
+        "binding:vif_type": "ovs",
+        "device_owner": "network:router_interface",
+        "port_security_enabled": false,
+        "mac_address": "fa:16:3e:23:fd:d7",
+        "binding:profile": {},
+        "binding:vnic_type": "normal",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.1"
+            }
+        ],
+        "id": "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2",
+        "security_groups": [],
+        "device_id": "5e3898d7-11be-483e-9732-b2f5eccd2b2e"
+    }
+}
+    `)
+	})
+}
+
+func HandleCreate(t *testing.T) {
+	th.Mux.HandleFunc("/v2.0/ports", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "POST")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+    "port": {
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "name": "private-port",
+        "admin_state_up": true,
+		"fixed_ips": [
+				{
+						"subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+						"ip_address": "10.0.0.2"
+				}
+		],
+		"security_groups": ["foo"],
+		"binding:host_id": "HOST1",
+        "binding:vnic_type": "normal"
+    }
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "DOWN",
+        "name": "private-port",
+        "allowed_address_pairs": [],
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+        "device_owner": "",
+        "mac_address": "fa:16:3e:c9:cb:f0",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.2"
+            }
+        ],
+        "binding:host_id": "HOST1",
+        "binding:vnic_type": "normal",
+        "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+        "security_groups": [
+            "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        ],
+        "device_id": ""
+    }
+}
+		`)
+	})
+}
+
+func HandleUpdate(t *testing.T) {
+	th.Mux.HandleFunc("/v2.0/ports/65c0ee9f-d634-4522-8954-51021b570b0d", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", fake.TokenID)
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestJSONRequest(t, r, `
+{
+		"port": {
+			"name": "new_port_name",
+			"fixed_ips": [
+				{
+					"subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+					"ip_address": "10.0.0.3"
+				}
+			],
+			"security_groups": [
+            	"f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        	],
+        	"binding:host_id": "HOST1",
+        	"binding:vnic_type": "normal"
+		}
+}
+			`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusOK)
+
+		fmt.Fprintf(w, `
+{
+    "port": {
+        "status": "DOWN",
+        "name": "new_port_name",
+        "admin_state_up": true,
+        "network_id": "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+        "tenant_id": "d6700c0c9ffa4f1cb322cd4a1f3906fa",
+        "device_owner": "",
+        "mac_address": "fa:16:3e:c9:cb:f0",
+        "fixed_ips": [
+            {
+                "subnet_id": "a0304c3a-4f08-4c43-88af-d796509c97d2",
+                "ip_address": "10.0.0.3"
+            }
+        ],
+        "id": "65c0ee9f-d634-4522-8954-51021b570b0d",
+        "security_groups": [
+            "f0ac4394-7e4a-4409-9701-ba8be283dbc3"
+        ],
+        "device_id": "",
+        "binding:host_id": "HOST1",
+        "binding:vnic_type": "normal"
+    }
+}
+		`)
+	})
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/requests.go b/openstack/networking/v2/extensions/portsbinding/requests.go
new file mode 100644
index 0000000..4d4300a
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/requests.go
@@ -0,0 +1,129 @@
+package portsbinding
+
+import (
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+)
+
+// Get retrieves a specific port based on its unique ID.
+func Get(c *gophercloud.ServiceClient, id string) GetResult {
+	var res GetResult
+	_, res.Err = c.Get(getURL(c, id), &res.Body, nil)
+	return res
+}
+
+// CreateOpts represents the attributes used when creating a new
+// port with extended attributes.
+type CreateOpts struct {
+	// CreateOptsBuilder is the interface options structs have to satisfy in order
+	// to be used in the main Create operation in this package.
+	ports.CreateOptsBuilder
+	// The ID of the host where the port is allocated
+	HostID string
+	// The virtual network interface card (vNIC) type that is bound to the
+	// neutron port
+	VNICType string
+	// A dictionary that enables the application running on the specified
+	// host to pass and receive virtual network interface (VIF) port-specific
+	// information to the plug-in
+	Profile map[string]string
+}
+
+// ToPortCreateMap casts a CreateOpts struct to a map.
+func (opts CreateOpts) ToPortCreateMap() (map[string]interface{}, error) {
+	p, err := opts.CreateOptsBuilder.ToPortCreateMap()
+	if err != nil {
+		return nil, err
+	}
+
+	port := p["port"].(map[string]interface{})
+
+	if opts.HostID != "" {
+		port["binding:host_id"] = opts.HostID
+	}
+	if opts.VNICType != "" {
+		port["binding:vnic_type"] = opts.VNICType
+	}
+	if opts.Profile != nil {
+		port["binding:profile"] = opts.Profile
+	}
+
+	return map[string]interface{}{"port": port}, nil
+}
+
+// Create accepts a CreateOpts struct and creates a new port with extended attributes.
+// You must remember to provide a NetworkID value.
+func Create(c *gophercloud.ServiceClient, opts ports.CreateOptsBuilder) CreateResult {
+	var res CreateResult
+
+	reqBody, err := opts.ToPortCreateMap()
+	if err != nil {
+		res.Err = err
+		return res
+	}
+
+	_, res.Err = c.Post(createURL(c), reqBody, &res.Body, nil)
+	return res
+}
+
+// UpdateOpts represents the attributes used when updating an existing port.
+type UpdateOpts struct {
+	// UpdateOptsBuilder is the interface options structs have to satisfy in order
+	// to be used in the main Update operation in this package.
+	ports.UpdateOptsBuilder
+	// The ID of the host where the port is allocated
+	HostID string
+	// The virtual network interface card (vNIC) type that is bound to the
+	// neutron port
+	VNICType string
+	// A dictionary that enables the application running on the specified
+	// host to pass and receive virtual network interface (VIF) port-specific
+	// information to the plug-in
+	Profile map[string]string
+}
+
+// ToPortUpdateMap casts an UpdateOpts struct to a map.
+func (opts UpdateOpts) ToPortUpdateMap() (map[string]interface{}, error) {
+	var port map[string]interface{}
+	if opts.UpdateOptsBuilder != nil {
+		p, err := opts.UpdateOptsBuilder.ToPortUpdateMap()
+		if err != nil {
+			return nil, err
+		}
+
+		port = p["port"].(map[string]interface{})
+	}
+
+	if port == nil {
+		port = make(map[string]interface{})
+	}
+
+	if opts.HostID != "" {
+		port["binding:host_id"] = opts.HostID
+	}
+	if opts.VNICType != "" {
+		port["binding:vnic_type"] = opts.VNICType
+	}
+	if opts.Profile != nil {
+		port["binding:profile"] = opts.Profile
+	}
+
+	return map[string]interface{}{"port": port}, nil
+}
+
+// Update accepts a UpdateOpts struct and updates an existing port using the
+// values provided.
+func Update(c *gophercloud.ServiceClient, id string, opts ports.UpdateOptsBuilder) UpdateResult {
+	var res UpdateResult
+
+	reqBody, err := opts.ToPortUpdateMap()
+	if err != nil {
+		res.Err = err
+		return res
+	}
+
+	_, res.Err = c.Put(updateURL(c, id), reqBody, &res.Body, &gophercloud.RequestOpts{
+		OkCodes: []int{200, 201},
+	})
+	return res
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/requests_test.go b/openstack/networking/v2/extensions/portsbinding/requests_test.go
new file mode 100644
index 0000000..a226031
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/requests_test.go
@@ -0,0 +1,163 @@
+package portsbinding
+
+import (
+	"testing"
+
+	fake "github.com/rackspace/gophercloud/openstack/networking/v2/common"
+	"github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleListSuccessfully(t)
+
+	count := 0
+
+	ports.List(fake.ServiceClient(), ports.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+		count++
+		actual, err := ExtractPorts(page)
+		th.AssertNoErr(t, err)
+
+		expected := []Port{
+			Port{
+				Port: ports.Port{
+					Status:       "ACTIVE",
+					Name:         "",
+					AdminStateUp: true,
+					NetworkID:    "70c1db1f-b701-45bd-96e0-a313ee3430b3",
+					TenantID:     "",
+					DeviceOwner:  "network:router_gateway",
+					MACAddress:   "fa:16:3e:58:42:ed",
+					FixedIPs: []ports.IP{
+						ports.IP{
+							SubnetID:  "008ba151-0b8c-4a67-98b5-0d2b87666062",
+							IPAddress: "172.24.4.2",
+						},
+					},
+					ID:             "d80b1a3b-4fc1-49f3-952e-1e2ab7081d8b",
+					SecurityGroups: []string{},
+					DeviceID:       "9ae135f4-b6e0-4dad-9e91-3c223e385824",
+				},
+				VNICType: "normal",
+				HostID:   "devstack",
+			},
+		}
+
+		th.CheckDeepEquals(t, expected, actual)
+
+		return true, nil
+	})
+
+	if count != 1 {
+		t.Errorf("Expected 1 page, got %d", count)
+	}
+}
+
+func TestGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleGet(t)
+
+	n, err := Get(fake.ServiceClient(), "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2").Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "ACTIVE")
+	th.AssertEquals(t, n.Name, "")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+	th.AssertEquals(t, n.TenantID, "7e02058126cc4950b75f9970368ba177")
+	th.AssertEquals(t, n.DeviceOwner, "network:router_interface")
+	th.AssertEquals(t, n.MACAddress, "fa:16:3e:23:fd:d7")
+	th.AssertDeepEquals(t, n.FixedIPs, []ports.IP{
+		{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.1"},
+	})
+	th.AssertEquals(t, n.ID, "46d4bfb9-b26e-41f3-bd2e-e6dcc1ccedb2")
+	th.AssertDeepEquals(t, n.SecurityGroups, []string{})
+	th.AssertEquals(t, n.DeviceID, "5e3898d7-11be-483e-9732-b2f5eccd2b2e")
+
+	th.AssertEquals(t, n.HostID, "devstack")
+	th.AssertEquals(t, n.VNICType, "normal")
+	th.AssertEquals(t, n.VIFType, "ovs")
+	th.AssertDeepEquals(t, n.VIFDetails, map[string]interface{}{"port_filter": true, "ovs_hybrid_plug": true})
+}
+
+func TestCreate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleCreate(t)
+
+	asu := true
+	options := CreateOpts{
+		CreateOptsBuilder: ports.CreateOpts{
+			Name:         "private-port",
+			AdminStateUp: &asu,
+			NetworkID:    "a87cc70a-3e15-4acf-8205-9b711a3531b7",
+			FixedIPs: []ports.IP{
+				{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+			},
+			SecurityGroups: []string{"foo"},
+		},
+		HostID:   "HOST1",
+		VNICType: "normal",
+	}
+	n, err := Create(fake.ServiceClient(), options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, n.Status, "DOWN")
+	th.AssertEquals(t, n.Name, "private-port")
+	th.AssertEquals(t, n.AdminStateUp, true)
+	th.AssertEquals(t, n.NetworkID, "a87cc70a-3e15-4acf-8205-9b711a3531b7")
+	th.AssertEquals(t, n.TenantID, "d6700c0c9ffa4f1cb322cd4a1f3906fa")
+	th.AssertEquals(t, n.DeviceOwner, "")
+	th.AssertEquals(t, n.MACAddress, "fa:16:3e:c9:cb:f0")
+	th.AssertDeepEquals(t, n.FixedIPs, []ports.IP{
+		{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.2"},
+	})
+	th.AssertEquals(t, n.ID, "65c0ee9f-d634-4522-8954-51021b570b0d")
+	th.AssertDeepEquals(t, n.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+	th.AssertEquals(t, n.HostID, "HOST1")
+	th.AssertEquals(t, n.VNICType, "normal")
+}
+
+func TestRequiredCreateOpts(t *testing.T) {
+	res := Create(fake.ServiceClient(), CreateOpts{CreateOptsBuilder: ports.CreateOpts{}})
+	if res.Err == nil {
+		t.Fatalf("Expected error, got none")
+	}
+}
+
+func TestUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+
+	HandleUpdate(t)
+
+	options := UpdateOpts{
+		UpdateOptsBuilder: ports.UpdateOpts{
+			Name: "new_port_name",
+			FixedIPs: []ports.IP{
+				{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+			},
+			SecurityGroups: []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"},
+		},
+		HostID:   "HOST1",
+		VNICType: "normal",
+	}
+
+	s, err := Update(fake.ServiceClient(), "65c0ee9f-d634-4522-8954-51021b570b0d", options).Extract()
+	th.AssertNoErr(t, err)
+
+	th.AssertEquals(t, s.Name, "new_port_name")
+	th.AssertDeepEquals(t, s.FixedIPs, []ports.IP{
+		{SubnetID: "a0304c3a-4f08-4c43-88af-d796509c97d2", IPAddress: "10.0.0.3"},
+	})
+	th.AssertDeepEquals(t, s.SecurityGroups, []string{"f0ac4394-7e4a-4409-9701-ba8be283dbc3"})
+	th.AssertEquals(t, s.HostID, "HOST1")
+	th.AssertEquals(t, s.VNICType, "normal")
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/results.go b/openstack/networking/v2/extensions/portsbinding/results.go
new file mode 100644
index 0000000..356e51c
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/results.go
@@ -0,0 +1,81 @@
+package portsbinding
+
+import (
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+
+	"github.com/rackspace/gophercloud/openstack/networking/v2/ports"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+type commonResult struct {
+	gophercloud.Result
+}
+
+// Extract is a function that accepts a result and extracts a port resource.
+func (r commonResult) Extract() (*Port, error) {
+	if r.Err != nil {
+		return nil, r.Err
+	}
+
+	var res struct {
+		Port *Port `json:"port"`
+	}
+
+	err := mapstructure.Decode(r.Body, &res)
+
+	return res.Port, err
+}
+
+// CreateResult represents the result of a create operation.
+type CreateResult struct {
+	commonResult
+}
+
+// GetResult represents the result of a get operation.
+type GetResult struct {
+	commonResult
+}
+
+// UpdateResult represents the result of an update operation.
+type UpdateResult struct {
+	commonResult
+}
+
+// IP is a sub-struct that represents an individual IP.
+type IP struct {
+	SubnetID  string `mapstructure:"subnet_id" json:"subnet_id"`
+	IPAddress string `mapstructure:"ip_address" json:"ip_address,omitempty"`
+}
+
+// Port represents a Neutron port. See package documentation for a top-level
+// description of what this is.
+type Port struct {
+	ports.Port `mapstructure:",squash"`
+	// The ID of the host where the port is allocated
+	HostID string `mapstructure:"binding:host_id" json:"binding:host_id"`
+	// A dictionary that enables the application to pass information about
+	// functions that the Networking API provides.
+	VIFDetails map[string]interface{} `mapstructure:"binding:vif_details" json:"binding:vif_details"`
+	// The VIF type for the port.
+	VIFType string `mapstructure:"binding:vif_type" json:"binding:vif_type"`
+	// The virtual network interface card (vNIC) type that is bound to the
+	// neutron port
+	VNICType string `mapstructure:"binding:vnic_type" json:"binding:vnic_type"`
+	// A dictionary that enables the application running on the specified
+	// host to pass and receive virtual network interface (VIF) port-specific
+	// information to the plug-in
+	Profile map[string]string `mapstructure:"binding:profile" json:"binding:profile"`
+}
+
+// ExtractPorts accepts a Page struct, specifically a PortPage struct,
+// and extracts the elements into a slice of Port structs. In other words,
+// a generic collection is mapped into a relevant slice.
+func ExtractPorts(page pagination.Page) ([]Port, error) {
+	var resp struct {
+		Ports []Port `mapstructure:"ports" json:"ports"`
+	}
+
+	err := mapstructure.Decode(page.(ports.PortPage).Body, &resp)
+	return resp.Ports, err
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/urls.go b/openstack/networking/v2/extensions/portsbinding/urls.go
new file mode 100644
index 0000000..55307f4
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/urls.go
@@ -0,0 +1,23 @@
+package portsbinding
+
+import "github.com/rackspace/gophercloud"
+
+func resourceURL(c *gophercloud.ServiceClient, id string) string {
+	return c.ServiceURL("ports", id)
+}
+
+func rootURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("ports")
+}
+
+func getURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+	return rootURL(c)
+}
+
+func updateURL(c *gophercloud.ServiceClient, id string) string {
+	return resourceURL(c, id)
+}
diff --git a/openstack/networking/v2/extensions/portsbinding/urls_test.go b/openstack/networking/v2/extensions/portsbinding/urls_test.go
new file mode 100644
index 0000000..f9359ce
--- /dev/null
+++ b/openstack/networking/v2/extensions/portsbinding/urls_test.go
@@ -0,0 +1,32 @@
+package portsbinding
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+const endpoint = "http://localhost:57909/"
+
+func endpointClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{Endpoint: endpoint, ResourceBase: endpoint + "v2.0/"}
+}
+
+func TestGetURL(t *testing.T) {
+	actual := getURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/ports/foo"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestCreateURL(t *testing.T) {
+	actual := createURL(endpointClient())
+	expected := endpoint + "v2.0/ports"
+	th.AssertEquals(t, expected, actual)
+}
+
+func TestUpdateURL(t *testing.T) {
+	actual := updateURL(endpointClient(), "foo")
+	expected := endpoint + "v2.0/ports/foo"
+	th.AssertEquals(t, expected, actual)
+}