diff --git a/acceptance/rackspace/compute/v2/servers_test.go b/acceptance/rackspace/compute/v2/servers_test.go index c3465cd4..af4bbe07 100644 --- a/acceptance/rackspace/compute/v2/servers_test.go +++ b/acceptance/rackspace/compute/v2/servers_test.go @@ -7,14 +7,30 @@ import ( "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 @@ func createServer(t *testing.T, client *gophercloud.ServiceClient) *os.Server { 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 @@ func rebuildServer(t *testing.T, client *gophercloud.ServiceClient, server *os.S 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 @@ func deleteServer(t *testing.T, client *gophercloud.ServiceClient, server *os.Se 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 00000000..06a922ac --- /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 00000000..1f4f6268 --- /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 00000000..10ec2daf --- /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 00000000..dd8d2b7d --- /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 e5f7c4b1..e872b071 100644 --- a/openstack/compute/v2/servers/fixtures.go +++ b/openstack/compute/v2/servers/fixtures.go @@ -359,6 +359,26 @@ func HandleServerCreationSuccessfully(t *testing.T, response string) { }) } +// 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 @@ func HandleServerDeletionSuccessfully(t *testing.T) { }) } +// 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 632ba28b..c6eca11a 100644 --- a/openstack/compute/v2/servers/requests.go +++ b/openstack/compute/v2/servers/requests.go @@ -456,6 +456,28 @@ func Rebuild(client *gophercloud.ServiceClient, id string, opts RebuildOptsBuild 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 @@ func Rebuild(client *gophercloud.ServiceClient, id string, opts RebuildOptsBuild // 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 5b65d865..23fe7813 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 @@ import ( 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 TestDeleteServer(t *testing.T) { 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 TestGetServer(t *testing.T) { 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 @@ func TestResizeServer(t *testing.T) { 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 00000000..b83a8937 --- /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 00000000..ac7058f3 --- /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 ca27cad1..adb77e5a 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 @@ import ( "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 @@ func CheckDeepEquals(t *testing.T, expected, actual interface{}) { }) } +// 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 481a8330..e1f1f9ac 100644 --- a/testhelper/http_responses.go +++ b/testhelper/http_responses.go @@ -81,33 +81,11 @@ func TestJSONRequest(t *testing.T, r *http.Request, expected string) { 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) }