Merge pull request #259 from jrperritt/boot-from-volume
Boot from volume
diff --git a/acceptance/openstack/compute/v2/bootfromvolume_test.go b/acceptance/openstack/compute/v2/bootfromvolume_test.go
new file mode 100644
index 0000000..d08abe6
--- /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 0000000..010bf42
--- /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 0000000..5a976d1
--- /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 0000000..5bf9137
--- /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 0000000..f60329f
--- /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 0000000..0cffe25
--- /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 0000000..6ee6477
--- /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 06a922a..7407e0d 100644
--- a/openstack/compute/v2/extensions/diskconfig/requests.go
+++ b/openstack/compute/v2/extensions/diskconfig/requests.go
@@ -41,17 +41,24 @@
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 1f4f626..e3c26d4 100644
--- a/openstack/compute/v2/extensions/diskconfig/requests_test.go
+++ b/openstack/compute/v2/extensions/diskconfig/requests_test.go
@@ -29,7 +29,9 @@
}
}
`
- 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 c6eca11..544f816 100644
--- a/openstack/compute/v2/servers/requests.go
+++ b/openstack/compute/v2/servers/requests.go
@@ -79,7 +79,7 @@
// 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 @@
}
// 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 @@
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 0000000..2580459
--- /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 0000000..0b53527
--- /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 b83a893..884b9cb 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 @@
// 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 @@
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 ac7058f..3c0f806 100644
--- a/rackspace/compute/v2/servers/requests_test.go
+++ b/rackspace/compute/v2/servers/requests_test.go
@@ -27,7 +27,9 @@
}
}
`
- 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 adb77e5..cf33e1a 100644
--- a/testhelper/convenience.go
+++ b/testhelper/convenience.go
@@ -259,14 +259,19 @@
// 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)