Merge pull request #256 from smashwilson/disk-config

Extension: Disk Config
diff --git a/acceptance/rackspace/compute/v2/servers_test.go b/acceptance/rackspace/compute/v2/servers_test.go
index c3465cd..af4bbe0 100644
--- a/acceptance/rackspace/compute/v2/servers_test.go
+++ b/acceptance/rackspace/compute/v2/servers_test.go
@@ -7,14 +7,30 @@
 
 	"github.com/rackspace/gophercloud"
 	"github.com/rackspace/gophercloud/acceptance/tools"
+	"github.com/rackspace/gophercloud/openstack/compute/v2/extensions/diskconfig"
+	oskey "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/keypairs"
 	os "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
 	"github.com/rackspace/gophercloud/pagination"
+	"github.com/rackspace/gophercloud/rackspace/compute/v2/keypairs"
 	"github.com/rackspace/gophercloud/rackspace/compute/v2/servers"
 	th "github.com/rackspace/gophercloud/testhelper"
 )
 
-func createServer(t *testing.T, client *gophercloud.ServiceClient) *os.Server {
-	if testing.Short(){
+func createServerKeyPair(t *testing.T, client *gophercloud.ServiceClient) *oskey.KeyPair {
+	name := tools.RandomString("importedkey-", 8)
+	pubkey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDlIQ3r+zd97kb9Hzmujd3V6pbO53eb3Go4q2E8iqVGWQfZTrFdL9KACJnqJIm9HmncfRkUTxE37hqeGCCv8uD+ZPmPiZG2E60OX1mGDjbbzAyReRwYWXgXHopggZTLak5k4mwZYaxwaufbVBDRn847e01lZnaXaszEToLM37NLw+uz29sl3TwYy2R0RGHPwPc160aWmdLjSyd1Nd4c9pvvOP/EoEuBjIC6NJJwg2Rvg9sjjx9jYj0QUgc8CqKLN25oMZ69kNJzlFylKRUoeeVr89txlR59yehJWk6Uw6lYFTdJmcmQOFVAJ12RMmS1hLWCM8UzAgtw+EDa0eqBxBDl smash@winter"
+
+	k, err := keypairs.Create(client, oskey.CreateOpts{
+		Name:      name,
+		PublicKey: pubkey,
+	}).Extract()
+	th.AssertNoErr(t, err)
+
+	return k
+}
+
+func createServer(t *testing.T, client *gophercloud.ServiceClient, keyName string) *os.Server {
+	if testing.Short() {
 		t.Skip("Skipping test that requires server creation in short mode.")
 	}
 
@@ -23,10 +39,12 @@
 
 	name := tools.RandomString("Gophercloud-", 8)
 	t.Logf("Creating server [%s].", name)
-	s, err := servers.Create(client, &os.CreateOpts{
-		Name:      name,
-		ImageRef:  options.imageID,
-		FlavorRef: options.flavorID,
+	s, err := servers.Create(client, &servers.CreateOpts{
+		Name:       name,
+		ImageRef:   options.imageID,
+		FlavorRef:  options.flavorID,
+		KeyPair:    keyName,
+		DiskConfig: diskconfig.Manual,
 	}).Extract()
 	th.AssertNoErr(t, err)
 	t.Logf("Creating server.")
@@ -122,10 +140,11 @@
 	options, err := optionsFromEnv()
 	th.AssertNoErr(t, err)
 
-	opts := os.RebuildOpts{
-		Name:      tools.RandomString("RenamedGopher", 16),
-		AdminPass: tools.MakeNewPassword(server.AdminPass),
-		ImageID:   options.imageID,
+	opts := servers.RebuildOpts{
+		Name:       tools.RandomString("RenamedGopher", 16),
+		AdminPass:  tools.MakeNewPassword(server.AdminPass),
+		ImageID:    options.imageID,
+		DiskConfig: diskconfig.Manual,
 	}
 	after, err := servers.Rebuild(client, server.ID, opts).Extract()
 	th.AssertNoErr(t, err)
@@ -147,11 +166,23 @@
 	t.Logf("Server deleted successfully.")
 }
 
+func deleteServerKeyPair(t *testing.T, client *gophercloud.ServiceClient, k *oskey.KeyPair) {
+	t.Logf("> keypairs.Delete")
+
+	err := keypairs.Delete(client, k.Name).Extract()
+	th.AssertNoErr(t, err)
+
+	t.Logf("Keypair deleted successfully.")
+}
+
 func TestServerOperations(t *testing.T) {
 	client, err := newClient()
 	th.AssertNoErr(t, err)
 
-	server := createServer(t, client)
+	kp := createServerKeyPair(t, client)
+	defer deleteServerKeyPair(t, client, kp)
+
+	server := createServer(t, client, kp.Name)
 	defer deleteServer(t, client, server)
 
 	getServer(t, client, server)
diff --git a/openstack/compute/v2/extensions/diskconfig/requests.go b/openstack/compute/v2/extensions/diskconfig/requests.go
new file mode 100644
index 0000000..06a922a
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/requests.go
@@ -0,0 +1,107 @@
+package diskconfig
+
+import (
+	"errors"
+
+	"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// DiskConfig represents one of the two possible settings for the DiskConfig option when creating,
+// rebuilding, or resizing servers: Auto or Manual.
+type DiskConfig string
+
+const (
+	// Auto builds a server with a single partition the size of the target flavor disk and
+	// automatically adjusts the filesystem to fit the entire partition. Auto may only be used with
+	// images and servers that use a single EXT3 partition.
+	Auto DiskConfig = "AUTO"
+
+	// Manual builds a server using whatever partition scheme and filesystem are present in the source
+	// image. If the target flavor disk is larger, the remaining space is left unpartitioned. This
+	// enables images to have non-EXT3 filesystems, multiple partitions, and so on, and enables you
+	// to manage the disk configuration. It also results in slightly shorter boot times.
+	Manual DiskConfig = "MANUAL"
+)
+
+// ErrInvalidDiskConfig is returned if an invalid string is specified for a DiskConfig option.
+var ErrInvalidDiskConfig = errors.New("DiskConfig must be either diskconfig.Auto or diskconfig.Manual.")
+
+// Validate ensures that a DiskConfig contains an appropriate value.
+func (config DiskConfig) validate() error {
+	switch config {
+	case Auto, Manual:
+		return nil
+	default:
+		return ErrInvalidDiskConfig
+	}
+}
+
+// CreateOptsExt adds a DiskConfig option to the base CreateOpts.
+type CreateOptsExt struct {
+	servers.CreateOptsBuilder
+
+	// DiskConfig [optional] controls how the created server's disk is partitioned.
+	DiskConfig DiskConfig
+}
+
+// ToServerCreateMap adds the diskconfig option to the base server creation options.
+func (opts CreateOptsExt) ToServerCreateMap() map[string]interface{} {
+	base := opts.CreateOptsBuilder.ToServerCreateMap()
+
+	serverMap := base["server"].(map[string]interface{})
+	serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+	return base
+}
+
+// RebuildOptsExt adds a DiskConfig option to the base RebuildOpts.
+type RebuildOptsExt struct {
+	servers.RebuildOptsBuilder
+
+	// DiskConfig [optional] controls how the rebuilt server's disk is partitioned.
+	DiskConfig DiskConfig
+}
+
+// ToServerRebuildMap adds the diskconfig option to the base server rebuild options.
+func (opts RebuildOptsExt) ToServerRebuildMap() (map[string]interface{}, error) {
+	err := opts.DiskConfig.validate()
+	if err != nil {
+		return nil, err
+	}
+
+	base, err := opts.RebuildOptsBuilder.ToServerRebuildMap()
+	if err != nil {
+		return nil, err
+	}
+
+	serverMap := base["rebuild"].(map[string]interface{})
+	serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+	return base, nil
+}
+
+// ResizeOptsExt adds a DiskConfig option to the base server resize options.
+type ResizeOptsExt struct {
+	servers.ResizeOptsBuilder
+
+	// DiskConfig [optional] controls how the resized server's disk is partitioned.
+	DiskConfig DiskConfig
+}
+
+// ToServerResizeMap adds the diskconfig option to the base server creation options.
+func (opts ResizeOptsExt) ToServerResizeMap() (map[string]interface{}, error) {
+	err := opts.DiskConfig.validate()
+	if err != nil {
+		return nil, err
+	}
+
+	base, err := opts.ResizeOptsBuilder.ToServerResizeMap()
+	if err != nil {
+		return nil, err
+	}
+
+	serverMap := base["resize"].(map[string]interface{})
+	serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig)
+
+	return base, nil
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/requests_test.go b/openstack/compute/v2/extensions/diskconfig/requests_test.go
new file mode 100644
index 0000000..1f4f626
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/requests_test.go
@@ -0,0 +1,85 @@
+package diskconfig
+
+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",
+	}
+
+	ext := CreateOptsExt{
+		CreateOptsBuilder: base,
+		DiskConfig:        Manual,
+	}
+
+	expected := `
+		{
+			"server": {
+				"name": "createdserver",
+				"imageRef": "asdfasdfasdf",
+				"flavorRef": "performance1-1",
+				"OS-DCF:diskConfig": "MANUAL"
+			}
+		}
+	`
+	th.CheckJSONEquals(t, expected, ext.ToServerCreateMap())
+}
+
+func TestRebuildOpts(t *testing.T) {
+	base := servers.RebuildOpts{
+		Name:      "rebuiltserver",
+		AdminPass: "swordfish",
+		ImageID:   "asdfasdfasdf",
+	}
+
+	ext := RebuildOptsExt{
+		RebuildOptsBuilder: base,
+		DiskConfig:         Auto,
+	}
+
+	actual, err := ext.ToServerRebuildMap()
+	th.AssertNoErr(t, err)
+
+	expected := `
+		{
+			"rebuild": {
+				"name": "rebuiltserver",
+				"imageRef": "asdfasdfasdf",
+				"adminPass": "swordfish",
+				"OS-DCF:diskConfig": "AUTO"
+			}
+		}
+	`
+	th.CheckJSONEquals(t, expected, actual)
+}
+
+func TestResizeOpts(t *testing.T) {
+	base := servers.ResizeOpts{
+		FlavorRef: "performance1-8",
+	}
+
+	ext := ResizeOptsExt{
+		ResizeOptsBuilder: base,
+		DiskConfig:        Auto,
+	}
+
+	actual, err := ext.ToServerResizeMap()
+	th.AssertNoErr(t, err)
+
+	expected := `
+		{
+			"resize": {
+				"flavorRef": "performance1-8",
+				"OS-DCF:diskConfig": "AUTO"
+			}
+		}
+	`
+	th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/results.go b/openstack/compute/v2/extensions/diskconfig/results.go
new file mode 100644
index 0000000..10ec2da
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/results.go
@@ -0,0 +1,60 @@
+package diskconfig
+
+import (
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+	"github.com/rackspace/gophercloud/pagination"
+)
+
+func commonExtract(result gophercloud.Result) (*DiskConfig, error) {
+	var resp struct {
+		Server struct {
+			DiskConfig string `mapstructure:"OS-DCF:diskConfig"`
+		} `mapstructure:"server"`
+	}
+
+	err := mapstructure.Decode(result.Body, &resp)
+	if err != nil {
+		return nil, err
+	}
+
+	config := DiskConfig(resp.Server.DiskConfig)
+	return &config, nil
+}
+
+// ExtractGet returns the disk configuration from a servers.Get call.
+func ExtractGet(result servers.GetResult) (*DiskConfig, error) {
+	return commonExtract(result.Result)
+}
+
+// ExtractUpdate returns the disk configuration from a servers.Update call.
+func ExtractUpdate(result servers.UpdateResult) (*DiskConfig, error) {
+	return commonExtract(result.Result)
+}
+
+// ExtractRebuild returns the disk configuration from a servers.Rebuild call.
+func ExtractRebuild(result servers.RebuildResult) (*DiskConfig, error) {
+	return commonExtract(result.Result)
+}
+
+// ExtractDiskConfig returns the DiskConfig setting for a specific server acquired from an
+// servers.ExtractServers call, while iterating through a Pager.
+func ExtractDiskConfig(page pagination.Page, index int) (*DiskConfig, error) {
+	casted := page.(servers.ServerPage).Body
+
+	type server struct {
+		DiskConfig string `mapstructure:"OS-DCF:diskConfig"`
+	}
+	var response struct {
+		Servers []server `mapstructure:"servers"`
+	}
+
+	err := mapstructure.Decode(casted, &response)
+	if err != nil {
+		return nil, err
+	}
+
+	config := DiskConfig(response.Servers[index].DiskConfig)
+	return &config, nil
+}
diff --git a/openstack/compute/v2/extensions/diskconfig/results_test.go b/openstack/compute/v2/extensions/diskconfig/results_test.go
new file mode 100644
index 0000000..dd8d2b7
--- /dev/null
+++ b/openstack/compute/v2/extensions/diskconfig/results_test.go
@@ -0,0 +1,68 @@
+package diskconfig
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+	"github.com/rackspace/gophercloud/pagination"
+	th "github.com/rackspace/gophercloud/testhelper"
+	"github.com/rackspace/gophercloud/testhelper/client"
+)
+
+func TestExtractGet(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	servers.HandleServerGetSuccessfully(t)
+
+	config, err := ExtractGet(servers.Get(client.ServiceClient(), "1234asdf"))
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, Manual, *config)
+}
+
+func TestExtractUpdate(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	servers.HandleServerUpdateSuccessfully(t)
+
+	r := servers.Update(client.ServiceClient(), "1234asdf", servers.UpdateOpts{
+		Name: "new-name",
+	})
+	config, err := ExtractUpdate(r)
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, Manual, *config)
+}
+
+func TestExtractRebuild(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	servers.HandleRebuildSuccessfully(t, servers.SingleServerBody)
+
+	r := servers.Rebuild(client.ServiceClient(), "1234asdf", servers.RebuildOpts{
+		Name:       "new-name",
+		AdminPass:  "swordfish",
+		ImageID:    "http://104.130.131.164:8774/fcad67a6189847c4aecfa3c81a05783b/images/f90f6034-2570-4974-8351-6b49732ef2eb",
+		AccessIPv4: "1.2.3.4",
+	})
+	config, err := ExtractRebuild(r)
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, Manual, *config)
+}
+
+func TestExtractList(t *testing.T) {
+	th.SetupHTTP()
+	defer th.TeardownHTTP()
+	servers.HandleServerListSuccessfully(t)
+
+	pages := 0
+	err := servers.List(client.ServiceClient(), nil).EachPage(func(page pagination.Page) (bool, error) {
+		pages++
+
+		config, err := ExtractDiskConfig(page, 0)
+		th.AssertNoErr(t, err)
+		th.CheckEquals(t, Manual, *config)
+
+		return true, nil
+	})
+	th.AssertNoErr(t, err)
+	th.CheckEquals(t, pages, 1)
+}
diff --git a/openstack/compute/v2/servers/fixtures.go b/openstack/compute/v2/servers/fixtures.go
index e5f7c4b..e872b07 100644
--- a/openstack/compute/v2/servers/fixtures.go
+++ b/openstack/compute/v2/servers/fixtures.go
@@ -359,6 +359,26 @@
 	})
 }
 
+// HandleServerListSuccessfully sets up the test server to respond to a server List request.
+func HandleServerListSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/detail", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		r.ParseForm()
+		marker := r.Form.Get("marker")
+		switch marker {
+		case "":
+			fmt.Fprintf(w, ServerListBody)
+		case "9e5476bd-a4ec-4653-93d6-72c93aa682ba":
+			fmt.Fprintf(w, `{ "servers": [] }`)
+		default:
+			t.Fatalf("/servers/detail invoked with unexpected marker=[%s]", marker)
+		}
+	})
+}
+
 // HandleServerDeletionSuccessfully sets up the test server to respond to a server deletion request.
 func HandleServerDeletionSuccessfully(t *testing.T) {
 	th.Mux.HandleFunc("/servers/asdfasdfasdf", func(w http.ResponseWriter, r *http.Request) {
@@ -369,6 +389,30 @@
 	})
 }
 
+// HandleServerGetSuccessfully sets up the test server to respond to a server Get request.
+func HandleServerGetSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "GET")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+
+		fmt.Fprintf(w, SingleServerBody)
+	})
+}
+
+// HandleServerUpdateSuccessfully sets up the test server to respond to a server Update request.
+func HandleServerUpdateSuccessfully(t *testing.T) {
+	th.Mux.HandleFunc("/servers/1234asdf", func(w http.ResponseWriter, r *http.Request) {
+		th.TestMethod(t, r, "PUT")
+		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
+		th.TestHeader(t, r, "Accept", "application/json")
+		th.TestHeader(t, r, "Content-Type", "application/json")
+		th.TestJSONRequest(t, r, `{ "server": { "name": "new-name" } }`)
+
+		fmt.Fprintf(w, SingleServerBody)
+	})
+}
+
 // HandleAdminPasswordChangeSuccessfully sets up the test server to respond to a server password
 // change request.
 func HandleAdminPasswordChangeSuccessfully(t *testing.T) {
diff --git a/openstack/compute/v2/servers/requests.go b/openstack/compute/v2/servers/requests.go
index 632ba28..c6eca11 100644
--- a/openstack/compute/v2/servers/requests.go
+++ b/openstack/compute/v2/servers/requests.go
@@ -456,6 +456,28 @@
 	return result
 }
 
+// ResizeOptsBuilder is an interface that allows extensions to override the default structure of
+// a Resize request.
+type ResizeOptsBuilder interface {
+	ToServerResizeMap() (map[string]interface{}, error)
+}
+
+// ResizeOpts represents the configuration options used to control a Resize operation.
+type ResizeOpts struct {
+	// FlavorRef is the ID of the flavor you wish your server to become.
+	FlavorRef string
+}
+
+// ToServerResizeMap formats a ResizeOpts as a map that can be used as a JSON request body to the
+// Resize request.
+func (opts ResizeOpts) ToServerResizeMap() (map[string]interface{}, error) {
+	resize := map[string]interface{}{
+		"flavorRef": opts.FlavorRef,
+	}
+
+	return map[string]interface{}{"resize": resize}, nil
+}
+
 // Resize instructs the provider to change the flavor of the server.
 // Note that this implies rebuilding it.
 // Unfortunately, one cannot pass rebuild parameters to the resize function.
@@ -463,15 +485,16 @@
 // While in this state, you can explore the use of the new server's configuration.
 // If you like it, call ConfirmResize() to commit the resize permanently.
 // Otherwise, call RevertResize() to restore the old configuration.
-func Resize(client *gophercloud.ServiceClient, id, flavorRef string) ActionResult {
+func Resize(client *gophercloud.ServiceClient, id string, opts ResizeOptsBuilder) ActionResult {
 	var res ActionResult
+	reqBody, err := opts.ToServerResizeMap()
+	if err != nil {
+		res.Err = err
+		return res
+	}
 
 	_, res.Err = perigee.Request("POST", actionURL(client, id), perigee.Options{
-		ReqBody: struct {
-			R map[string]interface{} `json:"resize"`
-		}{
-			map[string]interface{}{"flavorRef": flavorRef},
-		},
+		ReqBody:     reqBody,
 		MoreHeaders: client.AuthenticatedHeaders(),
 		OkCodes:     []int{202},
 	})
diff --git a/openstack/compute/v2/servers/requests_test.go b/openstack/compute/v2/servers/requests_test.go
index 5b65d86..23fe781 100644
--- a/openstack/compute/v2/servers/requests_test.go
+++ b/openstack/compute/v2/servers/requests_test.go
@@ -1,7 +1,6 @@
 package servers
 
 import (
-	"fmt"
 	"net/http"
 	"testing"
 
@@ -13,23 +12,7 @@
 func TestListServers(t *testing.T) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
-
-	th.Mux.HandleFunc("/servers/detail", func(w http.ResponseWriter, r *http.Request) {
-		th.TestMethod(t, r, "GET")
-		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
-
-		w.Header().Add("Content-Type", "application/json")
-		r.ParseForm()
-		marker := r.Form.Get("marker")
-		switch marker {
-		case "":
-			fmt.Fprintf(w, ServerListBody)
-		case "9e5476bd-a4ec-4653-93d6-72c93aa682ba":
-			fmt.Fprintf(w, `{ "servers": [] }`)
-		default:
-			t.Fatalf("/servers/detail invoked with unexpected marker=[%s]", marker)
-		}
-	})
+	HandleServerListSuccessfully(t)
 
 	pages := 0
 	err := List(client.ServiceClient(), ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
@@ -83,14 +66,7 @@
 func TestGetServer(t *testing.T) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
-
-	th.Mux.HandleFunc("/servers/1234asdf", func(w http.ResponseWriter, r *http.Request) {
-		th.TestMethod(t, r, "GET")
-		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
-		th.TestHeader(t, r, "Accept", "application/json")
-
-		fmt.Fprintf(w, SingleServerBody)
-	})
+	HandleServerGetSuccessfully(t)
 
 	client := client.ServiceClient()
 	actual, err := Get(client, "1234asdf").Extract()
@@ -104,16 +80,7 @@
 func TestUpdateServer(t *testing.T) {
 	th.SetupHTTP()
 	defer th.TeardownHTTP()
-
-	th.Mux.HandleFunc("/servers/1234asdf", func(w http.ResponseWriter, r *http.Request) {
-		th.TestMethod(t, r, "PUT")
-		th.TestHeader(t, r, "X-Auth-Token", client.TokenID)
-		th.TestHeader(t, r, "Accept", "application/json")
-		th.TestHeader(t, r, "Content-Type", "application/json")
-		th.TestJSONRequest(t, r, `{ "server": { "name": "new-name" } }`)
-
-		fmt.Fprintf(w, SingleServerBody)
-	})
+	HandleServerUpdateSuccessfully(t)
 
 	client := client.ServiceClient()
 	actual, err := Update(client, "1234asdf", UpdateOpts{Name: "new-name"}).Extract()
@@ -172,7 +139,7 @@
 		w.WriteHeader(http.StatusAccepted)
 	})
 
-	res := Resize(client.ServiceClient(), "1234asdf", "2")
+	res := Resize(client.ServiceClient(), "1234asdf", ResizeOpts{FlavorRef: "2"})
 	th.AssertNoErr(t, res.Err)
 }
 
diff --git a/rackspace/compute/v2/servers/requests.go b/rackspace/compute/v2/servers/requests.go
new file mode 100644
index 0000000..b83a893
--- /dev/null
+++ b/rackspace/compute/v2/servers/requests.go
@@ -0,0 +1,138 @@
+package servers
+
+import (
+	"github.com/rackspace/gophercloud/openstack/compute/v2/extensions/diskconfig"
+	os "github.com/rackspace/gophercloud/openstack/compute/v2/servers"
+)
+
+// CreateOpts specifies all of the options that Rackspace accepts in its Create request, including
+// the union of all extensions that Rackspace supports.
+type CreateOpts struct {
+	// Name [required] is the name to assign to the newly launched server.
+	Name string
+
+	// ImageRef [required] is the ID or full URL to the image that contains the server's OS and initial state.
+	// Optional if using the boot-from-volume extension.
+	ImageRef string
+
+	// FlavorRef [required] is the ID or full URL to the flavor that describes the server's specs.
+	FlavorRef string
+
+	// SecurityGroups [optional] lists the names of the security groups to which this server should belong.
+	SecurityGroups []string
+
+	// UserData [optional] contains configuration information or scripts to use upon launch.
+	// Create will base64-encode it for you.
+	UserData []byte
+
+	// AvailabilityZone [optional] in which to launch the server.
+	AvailabilityZone string
+
+	// Networks [optional] dictates how this server will be attached to available networks.
+	// By default, the server will be attached to all isolated networks for the tenant.
+	Networks []os.Network
+
+	// Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the server.
+	Metadata map[string]string
+
+	// Personality [optional] includes the path and contents of a file to inject into the server at launch.
+	// The maximum size of the file is 255 bytes (decoded).
+	Personality []byte
+
+	// ConfigDrive [optional] enables metadata injection through a configuration drive.
+	ConfigDrive bool
+
+	// Rackspace-specific extensions begin here.
+
+	// KeyPair [optional] specifies the name of the SSH KeyPair to be injected into the newly launched
+	// server. See the "keypairs" extension in OpenStack compute v2.
+	KeyPair string
+
+	// DiskConfig [optional] controls how the created server's disk is partitioned. See the "diskconfig"
+	// extension in OpenStack compute v2.
+	DiskConfig diskconfig.DiskConfig
+}
+
+// ToServerCreateMap constructs a request body using all of the OpenStack extensions that are
+// active on Rackspace.
+func (opts CreateOpts) ToServerCreateMap() map[string]interface{} {
+	base := os.CreateOpts{
+		Name:             opts.Name,
+		ImageRef:         opts.ImageRef,
+		FlavorRef:        opts.FlavorRef,
+		SecurityGroups:   opts.SecurityGroups,
+		UserData:         opts.UserData,
+		AvailabilityZone: opts.AvailabilityZone,
+		Networks:         opts.Networks,
+		Metadata:         opts.Metadata,
+		Personality:      opts.Personality,
+		ConfigDrive:      opts.ConfigDrive,
+	}
+
+	drive := diskconfig.CreateOptsExt{
+		CreateOptsBuilder: base,
+		DiskConfig:        opts.DiskConfig,
+	}
+
+	result := drive.ToServerCreateMap()
+
+	// key_name doesn't actually come from the extension (or at least isn't documented there) so
+	// we need to add it manually.
+	serverMap := result["server"].(map[string]interface{})
+	serverMap["key_name"] = opts.KeyPair
+
+	return result
+}
+
+// RebuildOpts represents all of the configuration options used in a server rebuild operation that
+// are supported by Rackspace.
+type RebuildOpts struct {
+	// Required. The ID of the image you want your server to be provisioned on
+	ImageID string
+
+	// Name to set the server to
+	Name string
+
+	// Required. The server's admin password
+	AdminPass string
+
+	// AccessIPv4 [optional] provides a new IPv4 address for the instance.
+	AccessIPv4 string
+
+	// AccessIPv6 [optional] provides a new IPv6 address for the instance.
+	AccessIPv6 string
+
+	// Metadata [optional] contains key-value pairs (up to 255 bytes each) to attach to the server.
+	Metadata map[string]string
+
+	// Personality [optional] includes the path and contents of a file to inject into the server at launch.
+	// The maximum size of the file is 255 bytes (decoded).
+	Personality []byte
+
+	// Rackspace-specific stuff begins here.
+
+	// DiskConfig [optional] controls how the created server's disk is partitioned. See the "diskconfig"
+	// extension in OpenStack compute v2.
+	DiskConfig diskconfig.DiskConfig
+}
+
+// ToServerRebuildMap constructs a request body using all of the OpenStack extensions that are
+// active on Rackspace.
+func (opts RebuildOpts) ToServerRebuildMap() (map[string]interface{}, error) {
+	base := os.RebuildOpts{
+		ImageID:     opts.ImageID,
+		Name:        opts.Name,
+		AdminPass:   opts.AdminPass,
+		AccessIPv4:  opts.AccessIPv4,
+		AccessIPv6:  opts.AccessIPv6,
+		Metadata:    opts.Metadata,
+		Personality: opts.Personality,
+	}
+
+	drive := diskconfig.RebuildOptsExt{
+		RebuildOptsBuilder: base,
+		DiskConfig:         opts.DiskConfig,
+	}
+
+	return drive.ToServerRebuildMap()
+}
diff --git a/rackspace/compute/v2/servers/requests_test.go b/rackspace/compute/v2/servers/requests_test.go
new file mode 100644
index 0000000..ac7058f
--- /dev/null
+++ b/rackspace/compute/v2/servers/requests_test.go
@@ -0,0 +1,55 @@
+package servers
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack/compute/v2/extensions/diskconfig"
+	th "github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestCreateOpts(t *testing.T) {
+	opts := CreateOpts{
+		Name:       "createdserver",
+		ImageRef:   "image-id",
+		FlavorRef:  "flavor-id",
+		KeyPair:    "mykey",
+		DiskConfig: diskconfig.Manual,
+	}
+
+	expected := `
+	{
+		"server": {
+			"name": "createdserver",
+			"imageRef": "image-id",
+			"flavorRef": "flavor-id",
+			"key_name": "mykey",
+			"OS-DCF:diskConfig": "MANUAL"
+		}
+	}
+	`
+	th.CheckJSONEquals(t, expected, opts.ToServerCreateMap())
+}
+
+func TestRebuildOpts(t *testing.T) {
+	opts := RebuildOpts{
+		Name:       "rebuiltserver",
+		AdminPass:  "swordfish",
+		ImageID:    "asdfasdfasdf",
+		DiskConfig: diskconfig.Auto,
+	}
+
+	actual, err := opts.ToServerRebuildMap()
+	th.AssertNoErr(t, err)
+
+	expected := `
+	{
+		"rebuild": {
+			"name": "rebuiltserver",
+			"imageRef": "asdfasdfasdf",
+			"adminPass": "swordfish",
+			"OS-DCF:diskConfig": "AUTO"
+		}
+	}
+	`
+	th.CheckJSONEquals(t, expected, actual)
+}
diff --git a/testhelper/convenience.go b/testhelper/convenience.go
index ca27cad..adb77e5 100644
--- a/testhelper/convenience.go
+++ b/testhelper/convenience.go
@@ -1,6 +1,7 @@
 package testhelper
 
 import (
+	"encoding/json"
 	"fmt"
 	"path/filepath"
 	"reflect"
@@ -9,25 +10,32 @@
 	"testing"
 )
 
+const (
+	logBodyFmt = "\033[1;31m%s %s\033[0m"
+	greenCode  = "\033[0m\033[1;32m"
+	yellowCode = "\033[0m\033[1;33m"
+	resetCode  = "\033[0m\033[1;31m"
+)
+
 func prefix(depth int) string {
 	_, file, line, _ := runtime.Caller(depth)
 	return fmt.Sprintf("Failure in %s, line %d:", filepath.Base(file), line)
 }
 
 func green(str interface{}) string {
-	return fmt.Sprintf("\033[0m\033[1;32m%#v\033[0m\033[1;31m", str)
+	return fmt.Sprintf("%s%#v%s", greenCode, str, resetCode)
 }
 
 func yellow(str interface{}) string {
-	return fmt.Sprintf("\033[0m\033[1;33m%#v\033[0m\033[1;31m", str)
+	return fmt.Sprintf("%s%#v%s", yellowCode, str, resetCode)
 }
 
 func logFatal(t *testing.T, str string) {
-	t.Fatalf("\033[1;31m%s %s\033[0m", prefix(3), str)
+	t.Fatalf(logBodyFmt, prefix(3), str)
 }
 
 func logError(t *testing.T, str string) {
-	t.Errorf("\033[1;31m%s %s\033[0m", prefix(3), str)
+	t.Errorf(logBodyFmt, prefix(3), str)
 }
 
 type diffLogger func([]string, interface{}, interface{})
@@ -248,6 +256,58 @@
 	})
 }
 
+// isJSONEquals is a utility function that implements JSON comparison for AssertJSONEquals and
+// CheckJSONEquals.
+func isJSONEquals(t *testing.T, expectedJSON string, actual interface{}) bool {
+	var parsedExpected interface{}
+	err := json.Unmarshal([]byte(expectedJSON), &parsedExpected)
+	if err != nil {
+		t.Errorf("Unable to parse expected value as JSON: %v", err)
+		return false
+	}
+
+	if !reflect.DeepEqual(parsedExpected, actual) {
+		prettyExpected, err := json.MarshalIndent(parsedExpected, "", "  ")
+		if err != nil {
+			t.Logf("Unable to pretty-print expected JSON: %v\n%s", err, expectedJSON)
+		} else {
+			// We can't use green() here because %#v prints prettyExpected as a byte array literal, which
+			// is... unhelpful. Converting it to a string first leaves "\n" uninterpreted for some reason.
+			t.Logf("Expected JSON:\n%s%s%s", greenCode, prettyExpected, resetCode)
+		}
+
+		prettyActual, err := json.MarshalIndent(actual, "", "  ")
+		if err != nil {
+			t.Logf("Unable to pretty-print actual JSON: %v\n%#v", err, actual)
+		} else {
+			// We can't use yellow() for the same reason.
+			t.Logf("Actual JSON:\n%s%s%s", yellowCode, prettyActual, resetCode)
+		}
+
+		return false
+	}
+	return true
+}
+
+// AssertJSONEquals serializes a value as JSON, parses an expected string as JSON, and ensures that
+// both are consistent. If they aren't, the expected and actual structures are pretty-printed and
+// shown for comparison.
+//
+// This is useful for comparing structures that are built as nested map[string]interface{} values,
+// which are a pain to construct as literals.
+func AssertJSONEquals(t *testing.T, expectedJSON string, actual interface{}) {
+	if !isJSONEquals(t, expectedJSON, actual) {
+		logFatal(t, "The generated JSON structure differed.")
+	}
+}
+
+// CheckJSONEquals is similar to AssertJSONEquals, but nonfatal.
+func CheckJSONEquals(t *testing.T, expectedJSON string, actual interface{}) {
+	if !isJSONEquals(t, expectedJSON, actual) {
+		logError(t, "The generated JSON structure differed.")
+	}
+}
+
 // AssertNoErr is a convenience function for checking whether an error value is
 // an actual error
 func AssertNoErr(t *testing.T, e error) {
diff --git a/testhelper/http_responses.go b/testhelper/http_responses.go
index 481a833..e1f1f9a 100644
--- a/testhelper/http_responses.go
+++ b/testhelper/http_responses.go
@@ -81,33 +81,11 @@
 		t.Errorf("Unable to read request body: %v", err)
 	}
 
-	var expectedJSON interface{}
-	err = json.Unmarshal([]byte(expected), &expectedJSON)
-	if err != nil {
-		t.Errorf("Unable to parse expected value as JSON: %v", err)
-	}
-
 	var actualJSON interface{}
 	err = json.Unmarshal(b, &actualJSON)
 	if err != nil {
 		t.Errorf("Unable to parse request body as JSON: %v", err)
 	}
 
-	if !reflect.DeepEqual(expectedJSON, actualJSON) {
-		prettyExpected, err := json.MarshalIndent(expectedJSON, "", "  ")
-		if err != nil {
-			t.Logf("Unable to pretty-print expected JSON: %v\n%s", err, expected)
-		} else {
-			t.Logf("Expected JSON:\n%s", prettyExpected)
-		}
-
-		prettyActual, err := json.MarshalIndent(actualJSON, "", "  ")
-		if err != nil {
-			t.Logf("Unable to pretty-print actual JSON: %v\n%s", err, b)
-		} else {
-			t.Logf("Actual JSON:\n%s", prettyActual)
-		}
-
-		t.Errorf("Response body did not contain the correct JSON.")
-	}
+	CheckJSONEquals(t, expected, actualJSON)
 }