diff --git a/acceptance/openstack/compute/v2/bootfromvolume_test.go b/acceptance/openstack/compute/v2/bootfromvolume_test.go new file mode 100644 index 00000000..d08abe6a --- /dev/null +++ b/acceptance/openstack/compute/v2/bootfromvolume_test.go @@ -0,0 +1,50 @@ +// +build acceptance + +package v2 + +import ( + "testing" + + "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume" + "github.com/rackspace/gophercloud/openstack/compute/v2/servers" + th "github.com/rackspace/gophercloud/testhelper" + "github.com/smashwilson/gophercloud/acceptance/tools" +) + +func TestBootFromVolume(t *testing.T) { + client, err := newClient() + th.AssertNoErr(t, err) + + if testing.Short() { + t.Skip("Skipping test that requires server creation in short mode.") + } + + choices, err := ComputeChoicesFromEnv() + if err != nil { + t.Fatal(err) + } + + name := tools.RandomString("Gophercloud-", 8) + t.Logf("Creating server [%s].", name) + + bd := []bootfromvolume.BlockDevice{ + bootfromvolume.BlockDevice{ + UUID: choices.ImageID, + SourceType: bootfromvolume.Image, + VolumeSize: 10, + }, + } + + serverCreateOpts := servers.CreateOpts{ + Name: name, + FlavorRef: "3", + } + server, err := bootfromvolume.Create(client, bootfromvolume.CreateOptsExt{ + serverCreateOpts, + bd, + }).Extract() + th.AssertNoErr(t, err) + t.Logf("Created server: %+v\n", server) + //defer deleteServer(t, client, server) + t.Logf("Deleting server [%s]...", name) +} diff --git a/acceptance/rackspace/compute/v2/bootfromvolume_test.go b/acceptance/rackspace/compute/v2/bootfromvolume_test.go new file mode 100644 index 00000000..010bf427 --- /dev/null +++ b/acceptance/rackspace/compute/v2/bootfromvolume_test.go @@ -0,0 +1,46 @@ +// +build acceptance + +package v2 + +import ( + "testing" + + osBFV "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume" + "github.com/rackspace/gophercloud/rackspace/compute/v2/bootfromvolume" + "github.com/rackspace/gophercloud/rackspace/compute/v2/servers" + th "github.com/rackspace/gophercloud/testhelper" + "github.com/smashwilson/gophercloud/acceptance/tools" +) + +func TestBootFromVolume(t *testing.T) { + client, err := newClient() + th.AssertNoErr(t, err) + + if testing.Short() { + t.Skip("Skipping test that requires server creation in short mode.") + } + + options, err := optionsFromEnv() + th.AssertNoErr(t, err) + + name := tools.RandomString("Gophercloud-", 8) + t.Logf("Creating server [%s].", name) + + bd := []osBFV.BlockDevice{ + osBFV.BlockDevice{ + UUID: options.imageID, + SourceType: osBFV.Image, + VolumeSize: 10, + }, + } + + server, err := bootfromvolume.Create(client, servers.CreateOpts{ + Name: name, + FlavorRef: "performance1-1", + BlockDevice: bd, + }).Extract() + th.AssertNoErr(t, err) + t.Logf("Created server: %+v\n", server) + //defer deleteServer(t, client, server) + t.Logf("Deleting server [%s]...", name) +} diff --git a/openstack/compute/v2/extensions/bootfromvolume/requests.go b/openstack/compute/v2/extensions/bootfromvolume/requests.go new file mode 100644 index 00000000..5a976d11 --- /dev/null +++ b/openstack/compute/v2/extensions/bootfromvolume/requests.go @@ -0,0 +1,111 @@ +package bootfromvolume + +import ( + "errors" + "strconv" + + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/openstack/compute/v2/servers" + + "github.com/racker/perigee" +) + +// SourceType represents the type of medium being used to create the volume. +type SourceType string + +const ( + Volume SourceType = "volume" + Snapshot SourceType = "snapshot" + Image SourceType = "image" +) + +// BlockDevice is a structure with options for booting a server instance +// from a volume. The volume may be created from an image, snapshot, or another +// volume. +type BlockDevice struct { + // BootIndex [optional] is the boot index. It defaults to 0. + BootIndex int `json:"boot_index"` + + // DeleteOnTermination [optional] specifies whether or not to delete the attached volume + // when the server is deleted. Defaults to `false`. + DeleteOnTermination bool `json:"delete_on_termination"` + + // DestinationType [optional] is the type that gets created. Possible values are "volume" + // and "local". + DestinationType string `json:"destination_type"` + + // SourceType [required] must be one of: "volume", "snapshot", "image". + SourceType SourceType `json:"source_type"` + + // UUID [required] is the unique identifier for the volume, snapshot, or image (see above) + UUID string `json:"uuid"` + + // VolumeSize [optional] is the size of the volume to create (in gigabytes). + VolumeSize int `json:"volume_size"` +} + +// CreateOptsExt is a structure that extends the server `CreateOpts` structure +// by allowing for a block device mapping. +type CreateOptsExt struct { + servers.CreateOptsBuilder + BlockDevice []BlockDevice `json:"block_device_mapping_v2,omitempty"` +} + +// ToServerCreateMap adds the block device mapping 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 + } + + if len(opts.BlockDevice) == 0 { + return nil, errors.New("Required fields UUID and SourceType not set.") + } + + serverMap := base["server"].(map[string]interface{}) + + blockDevice := make([]map[string]interface{}, len(opts.BlockDevice)) + + for i, bd := range opts.BlockDevice { + if string(bd.SourceType) == "" { + return nil, errors.New("SourceType must be one of: volume, image, snapshot.") + } + + blockDevice[i] = make(map[string]interface{}) + + blockDevice[i]["source_type"] = bd.SourceType + blockDevice[i]["boot_index"] = strconv.Itoa(bd.BootIndex) + blockDevice[i]["delete_on_termination"] = strconv.FormatBool(bd.DeleteOnTermination) + blockDevice[i]["volume_size"] = strconv.Itoa(bd.VolumeSize) + if bd.UUID != "" { + blockDevice[i]["uuid"] = bd.UUID + } + if bd.DestinationType != "" { + blockDevice[i]["destination_type"] = bd.DestinationType + } + + } + serverMap["block_device_mapping_v2"] = blockDevice + + return base, nil +} + +// Create requests the creation of a server from the given block device mapping. +func Create(client *gophercloud.ServiceClient, opts servers.CreateOptsBuilder) servers.CreateResult { + var res servers.CreateResult + + reqBody, err := opts.ToServerCreateMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = perigee.Request("POST", createURL(client), perigee.Options{ + MoreHeaders: client.AuthenticatedHeaders(), + ReqBody: reqBody, + Results: &res.Body, + OkCodes: []int{200, 202}, + }) + return res +} diff --git a/openstack/compute/v2/extensions/bootfromvolume/requests_test.go b/openstack/compute/v2/extensions/bootfromvolume/requests_test.go new file mode 100644 index 00000000..5bf91379 --- /dev/null +++ b/openstack/compute/v2/extensions/bootfromvolume/requests_test.go @@ -0,0 +1,51 @@ +package bootfromvolume + +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, + BlockDevice: []BlockDevice{ + BlockDevice{ + UUID: "123456", + SourceType: Image, + DestinationType: "volume", + VolumeSize: 10, + }, + }, + } + + expected := ` + { + "server": { + "name": "createdserver", + "imageRef": "asdfasdfasdf", + "flavorRef": "performance1-1", + "block_device_mapping_v2":[ + { + "uuid":"123456", + "source_type":"image", + "destination_type":"volume", + "boot_index": "0", + "delete_on_termination": "false", + "volume_size": "10" + } + ] + } + } + ` + actual, err := ext.ToServerCreateMap() + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, expected, actual) +} diff --git a/openstack/compute/v2/extensions/bootfromvolume/results.go b/openstack/compute/v2/extensions/bootfromvolume/results.go new file mode 100644 index 00000000..f60329f0 --- /dev/null +++ b/openstack/compute/v2/extensions/bootfromvolume/results.go @@ -0,0 +1,10 @@ +package bootfromvolume + +import ( + os "github.com/rackspace/gophercloud/openstack/compute/v2/servers" +) + +// CreateResult temporarily contains the response from a Create call. +type CreateResult struct { + os.CreateResult +} diff --git a/openstack/compute/v2/extensions/bootfromvolume/urls.go b/openstack/compute/v2/extensions/bootfromvolume/urls.go new file mode 100644 index 00000000..0cffe25f --- /dev/null +++ b/openstack/compute/v2/extensions/bootfromvolume/urls.go @@ -0,0 +1,7 @@ +package bootfromvolume + +import "github.com/rackspace/gophercloud" + +func createURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("os-volumes_boot") +} diff --git a/openstack/compute/v2/extensions/bootfromvolume/urls_test.go b/openstack/compute/v2/extensions/bootfromvolume/urls_test.go new file mode 100644 index 00000000..6ee64773 --- /dev/null +++ b/openstack/compute/v2/extensions/bootfromvolume/urls_test.go @@ -0,0 +1,16 @@ +package bootfromvolume + +import ( + "testing" + + th "github.com/rackspace/gophercloud/testhelper" + "github.com/rackspace/gophercloud/testhelper/client" +) + +func TestCreateURL(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + c := client.ServiceClient() + + th.CheckEquals(t, c.Endpoint+"os-volumes_boot", createURL(c)) +} diff --git a/openstack/compute/v2/extensions/diskconfig/requests.go b/openstack/compute/v2/extensions/diskconfig/requests.go index 06a922ac..7407e0d1 100644 --- a/openstack/compute/v2/extensions/diskconfig/requests.go +++ b/openstack/compute/v2/extensions/diskconfig/requests.go @@ -41,17 +41,24 @@ type CreateOptsExt struct { servers.CreateOptsBuilder // DiskConfig [optional] controls how the created server's disk is partitioned. - DiskConfig DiskConfig + DiskConfig DiskConfig `json:"OS-DCF:diskConfig,omitempty"` } // ToServerCreateMap adds the diskconfig option to the base server creation options. -func (opts CreateOptsExt) ToServerCreateMap() map[string]interface{} { - base := opts.CreateOptsBuilder.ToServerCreateMap() +func (opts CreateOptsExt) ToServerCreateMap() (map[string]interface{}, error) { + base, err := opts.CreateOptsBuilder.ToServerCreateMap() + if err != nil { + return nil, err + } + + if string(opts.DiskConfig) == "" { + return base, nil + } serverMap := base["server"].(map[string]interface{}) serverMap["OS-DCF:diskConfig"] = string(opts.DiskConfig) - return base + return base, nil } // RebuildOptsExt adds a DiskConfig option to the base RebuildOpts. diff --git a/openstack/compute/v2/extensions/diskconfig/requests_test.go b/openstack/compute/v2/extensions/diskconfig/requests_test.go index 1f4f6268..e3c26d49 100644 --- a/openstack/compute/v2/extensions/diskconfig/requests_test.go +++ b/openstack/compute/v2/extensions/diskconfig/requests_test.go @@ -29,7 +29,9 @@ func TestCreateOpts(t *testing.T) { } } ` - th.CheckJSONEquals(t, expected, ext.ToServerCreateMap()) + actual, err := ext.ToServerCreateMap() + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, expected, actual) } func TestRebuildOpts(t *testing.T) { diff --git a/openstack/compute/v2/servers/requests.go b/openstack/compute/v2/servers/requests.go index c6eca11a..544f816a 100644 --- a/openstack/compute/v2/servers/requests.go +++ b/openstack/compute/v2/servers/requests.go @@ -79,7 +79,7 @@ func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pa // CreateOptsBuilder describes struct types that can be accepted by the Create call. // The CreateOpts struct in this package does. type CreateOptsBuilder interface { - ToServerCreateMap() map[string]interface{} + ToServerCreateMap() (map[string]interface{}, error) } // Network is used within CreateOpts to control a new server's network attachments. @@ -134,7 +134,7 @@ type CreateOpts struct { } // ToServerCreateMap assembles a request body based on the contents of a CreateOpts. -func (opts CreateOpts) ToServerCreateMap() map[string]interface{} { +func (opts CreateOpts) ToServerCreateMap() (map[string]interface{}, error) { server := make(map[string]interface{}) server["name"] = opts.Name @@ -183,19 +183,26 @@ func (opts CreateOpts) ToServerCreateMap() map[string]interface{} { server["networks"] = networks } - return map[string]interface{}{"server": server} + return map[string]interface{}{"server": server}, nil } // Create requests a server to be provisioned to the user in the current tenant. func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult { - var result CreateResult - _, result.Err = perigee.Request("POST", listURL(client), perigee.Options{ - Results: &result.Body, - ReqBody: opts.ToServerCreateMap(), + var res CreateResult + + reqBody, err := opts.ToServerCreateMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = perigee.Request("POST", listURL(client), perigee.Options{ + Results: &res.Body, + ReqBody: reqBody, MoreHeaders: client.AuthenticatedHeaders(), OkCodes: []int{202}, }) - return result + return res } // Delete requests that a server previously provisioned be removed from your account. diff --git a/rackspace/compute/v2/bootfromvolume/delegate.go b/rackspace/compute/v2/bootfromvolume/delegate.go new file mode 100644 index 00000000..2580459f --- /dev/null +++ b/rackspace/compute/v2/bootfromvolume/delegate.go @@ -0,0 +1,12 @@ +package bootfromvolume + +import ( + "github.com/rackspace/gophercloud" + osBFV "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume" + osServers "github.com/rackspace/gophercloud/openstack/compute/v2/servers" +) + +// Create requests the creation of a server from the given block device mapping. +func Create(client *gophercloud.ServiceClient, opts osServers.CreateOptsBuilder) osServers.CreateResult { + return osBFV.Create(client, opts) +} diff --git a/rackspace/compute/v2/bootfromvolume/delegate_test.go b/rackspace/compute/v2/bootfromvolume/delegate_test.go new file mode 100644 index 00000000..0b535275 --- /dev/null +++ b/rackspace/compute/v2/bootfromvolume/delegate_test.go @@ -0,0 +1,52 @@ +package bootfromvolume + +import ( + "testing" + + osBFV "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume" + "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 := osBFV.CreateOptsExt{ + CreateOptsBuilder: base, + BlockDevice: []osBFV.BlockDevice{ + osBFV.BlockDevice{ + UUID: "123456", + SourceType: osBFV.Image, + DestinationType: "volume", + VolumeSize: 10, + }, + }, + } + + expected := ` + { + "server": { + "name": "createdserver", + "imageRef": "asdfasdfasdf", + "flavorRef": "performance1-1", + "block_device_mapping_v2":[ + { + "uuid":"123456", + "source_type":"image", + "destination_type":"volume", + "boot_index": "0", + "delete_on_termination": "false", + "volume_size": "10" + } + ] + } + } + ` + actual, err := ext.ToServerCreateMap() + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, expected, actual) +} diff --git a/rackspace/compute/v2/servers/requests.go b/rackspace/compute/v2/servers/requests.go index b83a8937..884b9cb1 100644 --- a/rackspace/compute/v2/servers/requests.go +++ b/rackspace/compute/v2/servers/requests.go @@ -1,6 +1,7 @@ package servers import ( + "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/bootfromvolume" "github.com/rackspace/gophercloud/openstack/compute/v2/extensions/diskconfig" os "github.com/rackspace/gophercloud/openstack/compute/v2/servers" ) @@ -51,11 +52,15 @@ type CreateOpts struct { // DiskConfig [optional] controls how the created server's disk is partitioned. See the "diskconfig" // extension in OpenStack compute v2. DiskConfig diskconfig.DiskConfig + + // BlockDevice [optional] will create the server from a volume, which is created from an image, + // a snapshot, or an another volume. + BlockDevice []bootfromvolume.BlockDevice } // ToServerCreateMap constructs a request body using all of the OpenStack extensions that are // active on Rackspace. -func (opts CreateOpts) ToServerCreateMap() map[string]interface{} { +func (opts CreateOpts) ToServerCreateMap() (map[string]interface{}, error) { base := os.CreateOpts{ Name: opts.Name, ImageRef: opts.ImageRef, @@ -74,14 +79,29 @@ func (opts CreateOpts) ToServerCreateMap() map[string]interface{} { DiskConfig: opts.DiskConfig, } - result := drive.ToServerCreateMap() + res, err := drive.ToServerCreateMap() + if err != nil { + return nil, err + } + + if len(opts.BlockDevice) != 0 { + bfv := bootfromvolume.CreateOptsExt{ + CreateOptsBuilder: drive, + BlockDevice: opts.BlockDevice, + } + + res, err = bfv.ToServerCreateMap() + if err != nil { + return nil, err + } + } // 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 := res["server"].(map[string]interface{}) serverMap["key_name"] = opts.KeyPair - return result + return res, nil } // RebuildOpts represents all of the configuration options used in a server rebuild operation that diff --git a/rackspace/compute/v2/servers/requests_test.go b/rackspace/compute/v2/servers/requests_test.go index ac7058f3..3c0f8069 100644 --- a/rackspace/compute/v2/servers/requests_test.go +++ b/rackspace/compute/v2/servers/requests_test.go @@ -27,7 +27,9 @@ func TestCreateOpts(t *testing.T) { } } ` - th.CheckJSONEquals(t, expected, opts.ToServerCreateMap()) + actual, err := opts.ToServerCreateMap() + th.AssertNoErr(t, err) + th.CheckJSONEquals(t, expected, actual) } func TestRebuildOpts(t *testing.T) { diff --git a/testhelper/convenience.go b/testhelper/convenience.go index adb77e5a..cf33e1ad 100644 --- a/testhelper/convenience.go +++ b/testhelper/convenience.go @@ -259,14 +259,19 @@ 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{} + var parsedExpected, parsedActual 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) { + jsonActual, err := json.Marshal(actual) + AssertNoErr(t, err) + err = json.Unmarshal(jsonActual, &parsedActual) + AssertNoErr(t, err) + + if !reflect.DeepEqual(parsedExpected, parsedActual) { prettyExpected, err := json.MarshalIndent(parsedExpected, "", " ") if err != nil { t.Logf("Unable to pretty-print expected JSON: %v\n%s", err, expectedJSON)