Fix Floating IP Disassociation (#103)

This commit fixes floating IP disassociation by changing the PortID to a
string pointer rather than a string. This allows a value of "null" to be
passed which is what the Networking API is looking for.
diff --git a/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go b/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go
index cac8983..c20b0d1 100644
--- a/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go
+++ b/acceptance/openstack/networking/v2/extensions/layer3/floatingips_test.go
@@ -8,7 +8,6 @@
 	"github.com/gophercloud/gophercloud/acceptance/clients"
 	networking "github.com/gophercloud/gophercloud/acceptance/openstack/networking/v2"
 	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/floatingips"
-	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/routers"
 )
 
 func TestLayer3FloatingIPsList(t *testing.T) {
@@ -56,28 +55,22 @@
 	}
 	defer DeleteRouter(t, client, router.ID)
 
-	aiOpts := routers.AddInterfaceOpts{
-		SubnetID: subnet.ID,
-	}
-
-	iface, err := routers.AddInterface(client, router.ID, aiOpts).Extract()
-	if err != nil {
-		t.Fatalf("Unable to add interface to router: %v", err)
-	}
-
-	PrintRouter(t, router)
-	PrintRouterInterface(t, iface)
-
 	port, err := networking.CreatePort(t, client, choices.ExternalNetworkID, subnet.ID)
 	if err != nil {
 		t.Fatalf("Unable to create port: %v", err)
 	}
-	defer networking.DeletePort(t, client, port.ID)
+
+	_, err = CreateRouterInterface(t, client, port.ID, router.ID)
+	if err != nil {
+		t.Fatalf("Unable to create router interface: %v", err)
+	}
+	defer DeleteRouterInterface(t, client, port.ID, router.ID)
 
 	fip, err := CreateFloatingIP(t, client, choices.ExternalNetworkID, port.ID)
 	if err != nil {
 		t.Fatalf("Unable to create floating IP: %v", err)
 	}
+	defer DeleteFloatingIP(t, client, fip.ID)
 
 	newFip, err := floatingips.Get(client, fip.ID).Extract()
 	if err != nil {
@@ -86,14 +79,13 @@
 
 	PrintFloatingIP(t, newFip)
 
-	DeleteFloatingIP(t, client, fip.ID)
-
-	riOpts := routers.RemoveInterfaceOpts{
-		SubnetID: subnet.ID,
+	// Disassociate the floating IP
+	updateOpts := floatingips.UpdateOpts{
+		PortID: nil,
 	}
 
-	_, err = routers.RemoveInterface(client, router.ID, riOpts).Extract()
+	newFip, err = floatingips.Update(client, fip.ID, updateOpts).Extract()
 	if err != nil {
-		t.Fatalf("Failed to remove interface from router: %v", err)
+		t.Fatalf("Unable to disassociate floating IP: %v", err)
 	}
 }
diff --git a/acceptance/openstack/networking/v2/extensions/layer3/layer3.go b/acceptance/openstack/networking/v2/extensions/layer3/layer3.go
index 5c6031e..3d2d88f 100644
--- a/acceptance/openstack/networking/v2/extensions/layer3/layer3.go
+++ b/acceptance/openstack/networking/v2/extensions/layer3/layer3.go
@@ -8,6 +8,7 @@
 	"github.com/gophercloud/gophercloud/acceptance/tools"
 	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/floatingips"
 	"github.com/gophercloud/gophercloud/openstack/networking/v2/extensions/layer3/routers"
+	"github.com/gophercloud/gophercloud/openstack/networking/v2/ports"
 )
 
 // CreateFloatingIP creates a floating IP on a given network and port. An error
@@ -42,7 +43,7 @@
 
 	routerName := tools.RandomString("TESTACC-", 8)
 
-	t.Logf("Attempting to create router: %s", routerName)
+	t.Logf("Attempting to create external router: %s", routerName)
 
 	adminStateUp := true
 	gatewayInfo := routers.GatewayInfo{
@@ -60,6 +61,10 @@
 		return router, err
 	}
 
+	if err := WaitForRouterToCreate(client, router.ID, 60); err != nil {
+		return router, err
+	}
+
 	t.Logf("Created router: %s", routerName)
 
 	return router, nil
@@ -88,11 +93,37 @@
 		return router, err
 	}
 
+	if err := WaitForRouterToCreate(client, router.ID, 60); err != nil {
+		return router, err
+	}
+
 	t.Logf("Created router: %s", routerName)
 
 	return router, nil
 }
 
+// CreateRouterInterface will attach a subnet to a router. An error will be
+// returned if the operation fails.
+func CreateRouterInterface(t *testing.T, client *gophercloud.ServiceClient, portID, routerID string) (*routers.InterfaceInfo, error) {
+	t.Logf("Attempting to add port %s to router %s", portID, routerID)
+
+	aiOpts := routers.AddInterfaceOpts{
+		PortID: portID,
+	}
+
+	iface, err := routers.AddInterface(client, routerID, aiOpts).Extract()
+	if err != nil {
+		return iface, err
+	}
+
+	if err := WaitForRouterInterfaceToAttach(client, portID, 60); err != nil {
+		return iface, err
+	}
+
+	t.Logf("Successfully added port %s to router %s", portID, routerID)
+	return iface, nil
+}
+
 // DeleteRouter deletes a router of a specified ID. A fatal error will occur
 // if the deletion failed. This works best when used as a deferred function.
 func DeleteRouter(t *testing.T, client *gophercloud.ServiceClient, routerID string) {
@@ -103,9 +134,35 @@
 		t.Fatalf("Error deleting router: %v", err)
 	}
 
+	if err := WaitForRouterToDelete(client, routerID, 60); err != nil {
+		t.Fatalf("Error waiting for router to delete: %v", err)
+	}
+
 	t.Logf("Deleted router: %s", routerID)
 }
 
+// DeleteRouterInterface will detach a subnet to a router. A fatal error will
+// occur if the deletion failed. This works best when used as a deferred
+// function.
+func DeleteRouterInterface(t *testing.T, client *gophercloud.ServiceClient, portID, routerID string) {
+	t.Logf("Attempting to detach port %s from router %s", portID, routerID)
+
+	riOpts := routers.RemoveInterfaceOpts{
+		PortID: portID,
+	}
+
+	_, err := routers.RemoveInterface(client, routerID, riOpts).Extract()
+	if err != nil {
+		t.Fatalf("Failed to detach port %s from router %s", portID, routerID)
+	}
+
+	if err := WaitForRouterInterfaceToDetach(client, portID, 60); err != nil {
+		t.Fatalf("Failed to wait for port %s to detach from router %s", portID, routerID)
+	}
+
+	t.Logf("Successfully detached port %s from router %s", portID, routerID)
+}
+
 // DeleteFloatingIP deletes a floatingIP of a specified ID. A fatal error will
 // occur if the deletion failed. This works best when used as a deferred
 // function.
@@ -155,3 +212,73 @@
 		t.Logf("\tDestinationCIDR: %s", route.DestinationCIDR)
 	}
 }
+
+func WaitForRouterToCreate(client *gophercloud.ServiceClient, routerID string, secs int) error {
+	return gophercloud.WaitFor(secs, func() (bool, error) {
+		r, err := routers.Get(client, routerID).Extract()
+		if err != nil {
+			return false, err
+		}
+
+		if r.Status == "ACTIVE" {
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
+
+func WaitForRouterToDelete(client *gophercloud.ServiceClient, routerID string, secs int) error {
+	return gophercloud.WaitFor(secs, func() (bool, error) {
+		_, err := routers.Get(client, routerID).Extract()
+		if err != nil {
+			if _, ok := err.(gophercloud.ErrDefault404); ok {
+				return true, nil
+			}
+
+			return false, err
+		}
+
+		return false, nil
+	})
+}
+
+func WaitForRouterInterfaceToAttach(client *gophercloud.ServiceClient, routerInterfaceID string, secs int) error {
+	return gophercloud.WaitFor(secs, func() (bool, error) {
+		r, err := ports.Get(client, routerInterfaceID).Extract()
+		if err != nil {
+			return false, err
+		}
+
+		if r.Status == "ACTIVE" {
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
+
+func WaitForRouterInterfaceToDetach(client *gophercloud.ServiceClient, routerInterfaceID string, secs int) error {
+	return gophercloud.WaitFor(secs, func() (bool, error) {
+		r, err := ports.Get(client, routerInterfaceID).Extract()
+		if err != nil {
+			if _, ok := err.(gophercloud.ErrDefault404); ok {
+				return true, nil
+			}
+
+			if errCode, ok := err.(gophercloud.ErrUnexpectedResponseCode); ok {
+				if errCode.Actual == 409 {
+					return false, nil
+				}
+			}
+
+			return false, err
+		}
+
+		if r.Status == "ACTIVE" {
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
diff --git a/acceptance/openstack/networking/v2/networking.go b/acceptance/openstack/networking/v2/networking.go
index 86c3545..9432dda 100644
--- a/acceptance/openstack/networking/v2/networking.go
+++ b/acceptance/openstack/networking/v2/networking.go
@@ -42,7 +42,7 @@
 	createOpts := ports.CreateOpts{
 		NetworkID:    networkID,
 		Name:         portName,
-		AdminStateUp: gophercloud.Disabled,
+		AdminStateUp: gophercloud.Enabled,
 		FixedIPs:     []ports.IP{ports.IP{SubnetID: subnetID}},
 	}
 
@@ -51,9 +51,18 @@
 		return port, err
 	}
 
+	if err := WaitForPortToCreate(client, port.ID, 60); err != nil {
+		return port, err
+	}
+
+	newPort, err := ports.Get(client, port.ID).Extract()
+	if err != nil {
+		return newPort, err
+	}
+
 	t.Logf("Successfully created port: %s", portName)
 
-	return port, nil
+	return newPort, nil
 }
 
 // CreateSubnet will create a subnet on the specified Network ID. An error
@@ -180,3 +189,18 @@
 	t.Logf("Name: %s", versionResource.Name)
 	t.Logf("Collection: %s", versionResource.Collection)
 }
+
+func WaitForPortToCreate(client *gophercloud.ServiceClient, portID string, secs int) error {
+	return gophercloud.WaitFor(secs, func() (bool, error) {
+		p, err := ports.Get(client, portID).Extract()
+		if err != nil {
+			return false, err
+		}
+
+		if p.Status == "ACTIVE" || p.Status == "DOWN" {
+			return true, nil
+		}
+
+		return false, nil
+	})
+}
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/requests.go b/openstack/networking/v2/extensions/layer3/floatingips/requests.go
index ed6b263..21a3b26 100644
--- a/openstack/networking/v2/extensions/layer3/floatingips/requests.go
+++ b/openstack/networking/v2/extensions/layer3/floatingips/requests.go
@@ -111,7 +111,7 @@
 // linked to. To associate the floating IP with a new internal port, provide its
 // ID. To disassociate the floating IP from all ports, provide an empty string.
 type UpdateOpts struct {
-	PortID string `json:"port_id"`
+	PortID *string `json:"port_id"`
 }
 
 // ToFloatingIPUpdateMap allows UpdateOpts to satisfy the UpdateOptsBuilder
diff --git a/openstack/networking/v2/extensions/layer3/floatingips/testing/requests_test.go b/openstack/networking/v2/extensions/layer3/floatingips/testing/requests_test.go
index 45f27eb..d7bb043 100644
--- a/openstack/networking/v2/extensions/layer3/floatingips/testing/requests_test.go
+++ b/openstack/networking/v2/extensions/layer3/floatingips/testing/requests_test.go
@@ -293,10 +293,11 @@
 	`)
 	})
 
-	ip, err := floatingips.Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", floatingips.UpdateOpts{PortID: "423abc8d-2991-4a55-ba98-2aaea84cc72e"}).Extract()
+	portID := "423abc8d-2991-4a55-ba98-2aaea84cc72e"
+	ip, err := floatingips.Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", floatingips.UpdateOpts{PortID: &portID}).Extract()
 	th.AssertNoErr(t, err)
 
-	th.AssertDeepEquals(t, "423abc8d-2991-4a55-ba98-2aaea84cc72e", ip.PortID)
+	th.AssertDeepEquals(t, portID, ip.PortID)
 }
 
 func TestDisassociate(t *testing.T) {
@@ -311,7 +312,7 @@
 		th.TestJSONRequest(t, r, `
 {
     "floatingip": {
-      "port_id": ""
+      "port_id": null
     }
 }
       `)
@@ -334,7 +335,7 @@
     `)
 	})
 
-	ip, err := floatingips.Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", floatingips.UpdateOpts{}).Extract()
+	ip, err := floatingips.Update(fake.ServiceClient(), "2f245a7b-796b-4f26-9cf9-9e82d248fda7", floatingips.UpdateOpts{PortID: nil}).Extract()
 	th.AssertNoErr(t, err)
 
 	th.AssertDeepEquals(t, "", ip.FixedIP)