Merge pull request #422 from jtopjian/os-scheduler-hints

Scheduler Hints
diff --git a/acceptance/openstack/compute/v2/servergroup_test.go b/acceptance/openstack/compute/v2/servergroup_test.go
index 80015e1..945854e 100644
--- a/acceptance/openstack/compute/v2/servergroup_test.go
+++ b/acceptance/openstack/compute/v2/servergroup_test.go
@@ -3,10 +3,15 @@
 package v2
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/acceptance/tools"
+	"github.com/rackspace/gophercloud/openstack/compute/v2/extensions/schedulerhints"
 	"github.com/rackspace/gophercloud/openstack/compute/v2/extensions/servergroups"
+	"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+	th "github.com/rackspace/gophercloud/testhelper"
 )
 
 func createServerGroup(t *testing.T, computeClient *gophercloud.ServiceClient) (*servergroups.ServerGroup, error) {
@@ -36,7 +41,53 @@
 	return nil
 }
 
+func createServerInGroup(t *testing.T, computeClient *gophercloud.ServiceClient, choices *ComputeChoices, serverGroup *servergroups.ServerGroup) (*servers.Server, error) {
+	if testing.Short() {
+		t.Skip("Skipping test that requires server creation in short mode.")
+	}
+
+	name := tools.RandomString("ACPTTEST", 16)
+	t.Logf("Attempting to create server: %s\n", name)
+
+	pwd := tools.MakeNewPassword("")
+
+	serverCreateOpts := servers.CreateOpts{
+		Name:      name,
+		FlavorRef: choices.FlavorID,
+		ImageRef:  choices.ImageID,
+		AdminPass: pwd,
+	}
+	server, err := servers.Create(computeClient, schedulerhints.CreateOptsExt{
+		serverCreateOpts,
+		schedulerhints.SchedulerHints{
+			Group: serverGroup.ID,
+		},
+	}).Extract()
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+
+	th.AssertEquals(t, pwd, server.AdminPass)
+
+	return server, err
+}
+
+func verifySchedulerWorked(t *testing.T, firstServer, secondServer *servers.Server) error {
+	t.Logf("First server hostID: %v", firstServer.HostID)
+	t.Logf("Second server hostID: %v", secondServer.HostID)
+	if firstServer.HostID == secondServer.HostID {
+		return nil
+	}
+
+	return fmt.Errorf("%s and %s were not scheduled on the same host.", firstServer.ID, secondServer.ID)
+}
+
 func TestServerGroups(t *testing.T) {
+	choices, err := ComputeChoicesFromEnv()
+	if err != nil {
+		t.Fatal(err)
+	}
+
 	computeClient, err := newClient()
 	if err != nil {
 		t.Fatalf("Unable to create a compute client: %v", err)
@@ -48,11 +99,45 @@
 	}
 	defer func() {
 		servergroups.Delete(computeClient, sg.ID)
-		t.Logf("ServerGroup deleted.")
+		t.Logf("Server Group deleted.")
 	}()
 
 	err = getServerGroup(t, computeClient, sg.ID)
 	if err != nil {
 		t.Fatalf("Unable to get server group: %v", err)
 	}
+
+	firstServer, err := createServerInGroup(t, computeClient, choices, sg)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(computeClient, firstServer.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(computeClient, firstServer, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	firstServer, err = servers.Get(computeClient, firstServer.ID).Extract()
+
+	secondServer, err := createServerInGroup(t, computeClient, choices, sg)
+	if err != nil {
+		t.Fatalf("Unable to create server: %v", err)
+	}
+	defer func() {
+		servers.Delete(computeClient, secondServer.ID)
+		t.Logf("Server deleted.")
+	}()
+
+	if err = waitForStatus(computeClient, secondServer, "ACTIVE"); err != nil {
+		t.Fatalf("Unable to wait for server: %v", err)
+	}
+
+	secondServer, err = servers.Get(computeClient, secondServer.ID).Extract()
+
+	if err = verifySchedulerWorked(t, firstServer, secondServer); err != nil {
+		t.Fatalf("Scheduling did not work: %v", err)
+	}
 }
diff --git a/openstack/compute/v2/extensions/schedulerhints/doc.go b/openstack/compute/v2/extensions/schedulerhints/doc.go
new file mode 100644
index 0000000..0bd4566
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/doc.go
@@ -0,0 +1,3 @@
+// Package schedulerhints enables instances to provide the OpenStack scheduler
+// hints about where they should be placed in the cloud.
+package schedulerhints
diff --git a/openstack/compute/v2/extensions/schedulerhints/requests.go b/openstack/compute/v2/extensions/schedulerhints/requests.go
new file mode 100644
index 0000000..c8b8da0
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/requests.go
@@ -0,0 +1,134 @@
+package schedulerhints
+
+import (
+	"fmt"
+	"net"
+	"regexp"
+	"strings"
+
+	"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// SchedulerHints represents a set of scheduling hints that are passed to the
+// OpenStack scheduler
+type SchedulerHints struct {
+	// Group specifies a Server Group to place the instance in.
+	Group string
+
+	// DifferentHost will place the instance on a compute node that does not
+	// host the given instances.
+	DifferentHost []string
+
+	// SameHost will place the instance on a compute node that hosts the given
+	// instances.
+	SameHost []string
+
+	// Query is a conditional statement that results in compute nodes able to
+	// host the instance.
+	Query []string
+
+	// TargetCell specifies a cell name where the instance will be placed.
+	TargetCell string
+
+	// BuildNearHostIP specifies a subnet of compute nodes to host the instance.
+	BuildNearHostIP string
+}
+
+// SchedulerHintsBuilder builds the scheduler hints into a serializable format.
+type SchedulerHintsBuilder interface {
+	ToServerSchedulerHintsMap() (map[string]interface{}, error)
+}
+
+// ToServerSchedulerHintsMap builds the scheduler hints into a serializable format.
+func (opts SchedulerHints) ToServerSchedulerHintsMap() (map[string]interface{}, error) {
+	sh := make(map[string]interface{})
+
+	uuidRegex, _ := regexp.Compile("^[a-z0-9]{8}-[a-z0-9]{4}-[1-5][a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{12}$")
+
+	if opts.Group != "" {
+		if !uuidRegex.MatchString(opts.Group) {
+			return nil, fmt.Errorf("Group must be a UUID")
+		}
+		sh["group"] = opts.Group
+	}
+
+	if len(opts.DifferentHost) > 0 {
+		for _, diffHost := range opts.DifferentHost {
+			if !uuidRegex.MatchString(diffHost) {
+				return nil, fmt.Errorf("The hosts in DifferentHost must be in UUID format.")
+			}
+		}
+		sh["different_host"] = opts.DifferentHost
+	}
+
+	if len(opts.SameHost) > 0 {
+		for _, sameHost := range opts.SameHost {
+			if !uuidRegex.MatchString(sameHost) {
+				return nil, fmt.Errorf("The hosts in SameHost must be in UUID format.")
+			}
+		}
+		sh["same_host"] = opts.SameHost
+	}
+
+	/* Query can be something simple like:
+	     [">=", "$free_ram_mb", 1024]
+
+			Or more complex like:
+				['and',
+					['>=', '$free_ram_mb', 1024],
+					['>=', '$free_disk_mb', 200 * 1024]
+				]
+
+		Because of the possible complexity, just make sure the length is a minimum of 3.
+	*/
+	if len(opts.Query) > 0 {
+		if len(opts.Query) < 3 {
+			return nil, fmt.Errorf("Query must be a conditional statement in the format of [op,variable,value]")
+		}
+		sh["query"] = opts.Query
+	}
+
+	if opts.TargetCell != "" {
+		sh["target_cell"] = opts.TargetCell
+	}
+
+	if opts.BuildNearHostIP != "" {
+		if _, _, err := net.ParseCIDR(opts.BuildNearHostIP); err != nil {
+			return nil, fmt.Errorf("BuildNearHostIP must be a valid subnet in the form 192.168.1.1/24")
+		}
+		ipParts := strings.Split(opts.BuildNearHostIP, "/")
+		sh["build_near_host_ip"] = ipParts[0]
+		sh["cidr"] = "/" + ipParts[1]
+	}
+
+	return sh, nil
+}
+
+// CreateOptsExt adds a SchedulerHints option to the base CreateOpts.
+type CreateOptsExt struct {
+	servers.CreateOptsBuilder
+
+	// SchedulerHints provides a set of hints to the scheduler.
+	SchedulerHints SchedulerHintsBuilder
+}
+
+// ToServerCreateMap adds the SchedulerHints option to the base server creation options.
+func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) {
+	base, err := opts.CreateOptsBuilder.ToServerCreateMap()
+	if err != nil {
+		return nil, err
+	}
+
+	schedulerHints, err := opts.SchedulerHints.ToServerSchedulerHintsMap()
+	if err != nil {
+		return nil, err
+	}
+
+	if len(schedulerHints) == 0 {
+		return base, nil
+	}
+
+	base["os:scheduler_hints"] = schedulerHints
+
+	return base, nil
+}
diff --git a/openstack/compute/v2/extensions/schedulerhints/requests_test.go b/openstack/compute/v2/extensions/schedulerhints/requests_test.go
new file mode 100644
index 0000000..c494fda
--- /dev/null
+++ b/openstack/compute/v2/extensions/schedulerhints/requests_test.go
@@ -0,0 +1,66 @@
+package schedulerhints
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+	base := servers.CreateOpts{
+		Name:      "createdserver",
+		ImageRef:  "asdfasdfasdf",
+		FlavorRef: "performance1-1",
+	}
+
+	schedulerHints := SchedulerHints{
+		Group: "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+		DifferentHost: []string{
+			"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+			"8c19174f-4220-44f0-824a-cd1eeef10287",
+		},
+		SameHost: []string{
+			"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+			"8c19174f-4220-44f0-824a-cd1eeef10287",
+		},
+		Query:           []string{">=", "$free_ram_mb", "1024"},
+		TargetCell:      "foobar",
+		BuildNearHostIP: "192.168.1.1/24",
+	}
+
+	ext := CreateOptsExt{
+		CreateOptsBuilder: base,
+		SchedulerHints:    schedulerHints,
+	}
+
+	expected := `
+		{
+			"server": {
+				"name": "createdserver",
+				"imageRef": "asdfasdfasdf",
+				"flavorRef": "performance1-1"
+			},
+			"os:scheduler_hints": {
+				"group": "101aed42-22d9-4a3e-9ba1-21103b0d1aba",
+				"different_host": [
+					"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+					"8c19174f-4220-44f0-824a-cd1eeef10287"
+				],
+				"same_host": [
+					"a0cf03a5-d921-4877-bb5c-86d26cf818e1",
+					"8c19174f-4220-44f0-824a-cd1eeef10287"
+				],
+				"query": [
+					">=", "$free_ram_mb", "1024"
+				],
+				"target_cell": "foobar",
+				"build_near_host_ip": "192.168.1.1",
+				"cidr": "/24"
+			}
+		}
+	`
+	actual, err := ext.ToServerCreateMap()
+	th.AssertNoErr(t, err)
+	th.CheckJSONEquals(t, expected, actual)
+}