diff --git a/openstack/blockstorage/v2/apiversions/doc.go b/openstack/blockstorage/v2/apiversions/doc.go new file mode 100644 index 00000000..e3af39f5 --- /dev/null +++ b/openstack/blockstorage/v2/apiversions/doc.go @@ -0,0 +1,3 @@ +// Package apiversions provides information and interaction with the different +// API versions for the OpenStack Block Storage service, code-named Cinder. +package apiversions diff --git a/openstack/blockstorage/v2/apiversions/requests.go b/openstack/blockstorage/v2/apiversions/requests.go new file mode 100644 index 00000000..bb2c2591 --- /dev/null +++ b/openstack/blockstorage/v2/apiversions/requests.go @@ -0,0 +1,21 @@ +package apiversions + +import ( + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" +) + +// List lists all the Cinder API versions available to end-users. +func List(c *gophercloud.ServiceClient) pagination.Pager { + return pagination.NewPager(c, listURL(c), func(r pagination.PageResult) pagination.Page { + return APIVersionPage{pagination.SinglePageBase(r)} + }) +} + +// Get will retrieve the volume type with the provided ID. To extract the volume +// type from the result, call the Extract method on the GetResult. +func Get(client *gophercloud.ServiceClient, v string) GetResult { + var res GetResult + _, res.Err = client.Get(getURL(client, v), &res.Body, nil) + return res +} diff --git a/openstack/blockstorage/v2/apiversions/requests_test.go b/openstack/blockstorage/v2/apiversions/requests_test.go new file mode 100644 index 00000000..c90fb68c --- /dev/null +++ b/openstack/blockstorage/v2/apiversions/requests_test.go @@ -0,0 +1,145 @@ +package apiversions + +import ( + "fmt" + "net/http" + "testing" + + "github.com/rackspace/gophercloud/pagination" + th "github.com/rackspace/gophercloud/testhelper" + "github.com/rackspace/gophercloud/testhelper/client" +) + +func TestListVersions(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/", 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") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, `{ + "versions": [ + { + "status": "CURRENT", + "updated": "2012-01-04T11:33:21Z", + "id": "v2.0", + "links": [ + { + "href": "http://23.253.228.211:8776/v2/", + "rel": "self" + } + ] + }, + { + "status": "CURRENT", + "updated": "2012-11-21T11:33:21Z", + "id": "v2.0", + "links": [ + { + "href": "http://23.253.228.211:8776/v2/", + "rel": "self" + } + ] + } + ] + }`) + }) + + count := 0 + + List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) { + count++ + actual, err := ExtractAPIVersions(page) + if err != nil { + t.Errorf("Failed to extract API versions: %v", err) + return false, err + } + + expected := []APIVersion{ + { + ID: "v2.0", + Status: "CURRENT", + Updated: "2012-01-04T11:33:21Z", + }, + { + ID: "v2.0", + Status: "CURRENT", + Updated: "2012-11-21T11:33:21Z", + }, + } + + th.AssertDeepEquals(t, expected, actual) + + return true, nil + }) + + if count != 1 { + t.Errorf("Expected 1 page, got %d", count) + } +} + +func TestAPIInfo(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/v2/", 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") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, `{ + "version": { + "status": "CURRENT", + "updated": "2012-01-04T11:33:21Z", + "media-types": [ + { + "base": "application/xml", + "type": "application/vnd.openstack.volume+xml;version=1" + }, + { + "base": "application/json", + "type": "application/vnd.openstack.volume+json;version=1" + } + ], + "id": "v2.0", + "links": [ + { + "href": "http://23.253.228.211:8776/v2/", + "rel": "self" + }, + { + "href": "http://jorgew.github.com/block-storage-api/content/os-block-storage-2.0.pdf", + "type": "application/pdf", + "rel": "describedby" + }, + { + "href": "http://docs.rackspacecloud.com/servers/api/v2.1/application.wadl", + "type": "application/vnd.sun.wadl+xml", + "rel": "describedby" + } + ] + } + }`) + }) + + actual, err := Get(client.ServiceClient(), "v2").Extract() + if err != nil { + t.Errorf("Failed to extract version: %v", err) + } + + expected := APIVersion{ + ID: "v2.0", + Status: "CURRENT", + Updated: "2012-01-04T11:33:21Z", + } + + th.AssertEquals(t, actual.ID, expected.ID) + th.AssertEquals(t, actual.Status, expected.Status) + th.AssertEquals(t, actual.Updated, expected.Updated) +} diff --git a/openstack/blockstorage/v2/apiversions/results.go b/openstack/blockstorage/v2/apiversions/results.go new file mode 100644 index 00000000..7b0df115 --- /dev/null +++ b/openstack/blockstorage/v2/apiversions/results.go @@ -0,0 +1,58 @@ +package apiversions + +import ( + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" + + "github.com/mitchellh/mapstructure" +) + +// APIVersion represents an API version for Cinder. +type APIVersion struct { + ID string `json:"id" mapstructure:"id"` // unique identifier + Status string `json:"status" mapstructure:"status"` // current status + Updated string `json:"updated" mapstructure:"updated"` // date last updated +} + +// APIVersionPage is the page returned by a pager when traversing over a +// collection of API versions. +type APIVersionPage struct { + pagination.SinglePageBase +} + +// IsEmpty checks whether an APIVersionPage struct is empty. +func (r APIVersionPage) IsEmpty() (bool, error) { + is, err := ExtractAPIVersions(r) + if err != nil { + return true, err + } + return len(is) == 0, nil +} + +// ExtractAPIVersions takes a collection page, extracts all of the elements, +// and returns them a slice of APIVersion structs. It is effectively a cast. +func ExtractAPIVersions(page pagination.Page) ([]APIVersion, error) { + var resp struct { + Versions []APIVersion `mapstructure:"versions"` + } + + err := mapstructure.Decode(page.(APIVersionPage).Body, &resp) + + return resp.Versions, err +} + +// GetResult represents the result of a get operation. +type GetResult struct { + gophercloud.Result +} + +// Extract is a function that accepts a result and extracts an API version resource. +func (r GetResult) Extract() (*APIVersion, error) { + var resp struct { + Version *APIVersion `mapstructure:"version"` + } + + err := mapstructure.Decode(r.Body, &resp) + + return resp.Version, err +} diff --git a/openstack/blockstorage/v2/apiversions/urls.go b/openstack/blockstorage/v2/apiversions/urls.go new file mode 100644 index 00000000..56f8260a --- /dev/null +++ b/openstack/blockstorage/v2/apiversions/urls.go @@ -0,0 +1,15 @@ +package apiversions + +import ( + "strings" + + "github.com/rackspace/gophercloud" +) + +func getURL(c *gophercloud.ServiceClient, version string) string { + return c.ServiceURL(strings.TrimRight(version, "/") + "/") +} + +func listURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("") +} diff --git a/openstack/blockstorage/v2/apiversions/urls_test.go b/openstack/blockstorage/v2/apiversions/urls_test.go new file mode 100644 index 00000000..34081e89 --- /dev/null +++ b/openstack/blockstorage/v2/apiversions/urls_test.go @@ -0,0 +1,26 @@ +package apiversions + +import ( + "testing" + + "github.com/rackspace/gophercloud" + th "github.com/rackspace/gophercloud/testhelper" +) + +const endpoint = "http://localhost:57909/" + +func endpointClient() *gophercloud.ServiceClient { + return &gophercloud.ServiceClient{Endpoint: endpoint} +} + +func TestGetURL(t *testing.T) { + actual := getURL(endpointClient(), "v2") + expected := endpoint + "v2/" + th.AssertEquals(t, expected, actual) +} + +func TestListURL(t *testing.T) { + actual := listURL(endpointClient()) + expected := endpoint + th.AssertEquals(t, expected, actual) +} diff --git a/openstack/blockstorage/v2/snapshots/doc.go b/openstack/blockstorage/v2/snapshots/doc.go new file mode 100644 index 00000000..198f8307 --- /dev/null +++ b/openstack/blockstorage/v2/snapshots/doc.go @@ -0,0 +1,5 @@ +// Package snapshots provides information and interaction with snapshots in the +// OpenStack Block Storage service. A snapshot is a point in time copy of the +// data contained in an external storage volume, and can be controlled +// programmatically. +package snapshots diff --git a/openstack/blockstorage/v2/snapshots/fixtures.go b/openstack/blockstorage/v2/snapshots/fixtures.go new file mode 100644 index 00000000..78302152 --- /dev/null +++ b/openstack/blockstorage/v2/snapshots/fixtures.go @@ -0,0 +1,114 @@ +package snapshots + +import ( + "fmt" + "net/http" + "testing" + + th "github.com/rackspace/gophercloud/testhelper" + fake "github.com/rackspace/gophercloud/testhelper/client" +) + +func MockListResponse(t *testing.T) { + th.Mux.HandleFunc("/snapshots", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, ` + { + "snapshots": [ + { + "id": "289da7f8-6440-407c-9fb4-7db01ec49164", + "display_name": "snapshot-001" + }, + { + "id": "96c3bda7-c82a-4f50-be73-ca7621794835", + "display_name": "snapshot-002" + } + ] + } + `) + }) +} + +func MockGetResponse(t *testing.T) { + th.Mux.HandleFunc("/snapshots/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, ` +{ + "snapshot": { + "display_name": "snapshot-001", + "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22" + } +} + `) + }) +} + +func MockCreateResponse(t *testing.T) { + th.Mux.HandleFunc("/snapshots", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "snapshot": { + "volume_id": "1234", + "display_name": "snapshot-001" + } +} + `) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + fmt.Fprintf(w, ` +{ + "snapshot": { + "volume_id": "1234", + "display_name": "snapshot-001", + "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22" + } +} + `) + }) +} + +func MockUpdateMetadataResponse(t *testing.T) { + th.Mux.HandleFunc("/snapshots/123/metadata", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestJSONRequest(t, r, ` + { + "metadata": { + "key": "v2" + } + } + `) + + fmt.Fprintf(w, ` + { + "metadata": { + "key": "v2" + } + } + `) + }) +} + +func MockDeleteResponse(t *testing.T) { + th.Mux.HandleFunc("/snapshots/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "DELETE") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + w.WriteHeader(http.StatusNoContent) + }) +} diff --git a/openstack/blockstorage/v2/snapshots/requests.go b/openstack/blockstorage/v2/snapshots/requests.go new file mode 100644 index 00000000..71936e51 --- /dev/null +++ b/openstack/blockstorage/v2/snapshots/requests.go @@ -0,0 +1,206 @@ +package snapshots + +import ( + "fmt" + + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" +) + +// CreateOptsBuilder allows extensions to add additional parameters to the +// Create request. +type CreateOptsBuilder interface { + ToSnapshotCreateMap() (map[string]interface{}, error) +} + +// CreateOpts contains options for creating a Snapshot. This object is passed to +// the snapshots.Create function. For more information about these parameters, +// see the Snapshot object. +type CreateOpts struct { + // OPTIONAL + Description string + // OPTIONAL + Force bool + // OPTIONAL + Metadata map[string]interface{} + // OPTIONAL + Name string + // REQUIRED + VolumeID string +} + +// ToSnapshotCreateMap assembles a request body based on the contents of a +// CreateOpts. +func (opts CreateOpts) ToSnapshotCreateMap() (map[string]interface{}, error) { + s := make(map[string]interface{}) + + if opts.VolumeID == "" { + return nil, fmt.Errorf("Required CreateOpts field 'VolumeID' not set.") + } + s["volume_id"] = opts.VolumeID + + if opts.Description != "" { + s["display_description"] = opts.Description + } + if opts.Force == true { + s["force"] = opts.Force + } + if opts.Metadata != nil { + s["metadata"] = opts.Metadata + } + if opts.Name != "" { + s["display_name"] = opts.Name + } + + return map[string]interface{}{"snapshot": s}, nil +} + +// Create will create a new Snapshot based on the values in CreateOpts. To +// extract the Snapshot object from the response, call the Extract method on the +// CreateResult. +func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult { + var res CreateResult + + reqBody, err := opts.ToSnapshotCreateMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201}, + }) + return res +} + +// Delete will delete the existing Snapshot with the provided ID. +func Delete(client *gophercloud.ServiceClient, id string) DeleteResult { + var res DeleteResult + _, res.Err = client.Delete(deleteURL(client, id), nil) + return res +} + +// Get retrieves the Snapshot with the provided ID. To extract the Snapshot +// object from the response, call the Extract method on the GetResult. +func Get(client *gophercloud.ServiceClient, id string) GetResult { + var res GetResult + _, res.Err = client.Get(getURL(client, id), &res.Body, nil) + return res +} + +// ListOptsBuilder allows extensions to add additional parameters to the List +// request. +type ListOptsBuilder interface { + ToSnapshotListQuery() (string, error) +} + +// ListOpts hold options for listing Snapshots. It is passed to the +// snapshots.List function. +type ListOpts struct { + Name string `q:"display_name"` + Status string `q:"status"` + VolumeID string `q:"volume_id"` +} + +// ToSnapshotListQuery formats a ListOpts into a query string. +func (opts ListOpts) ToSnapshotListQuery() (string, error) { + q, err := gophercloud.BuildQueryString(opts) + if err != nil { + return "", err + } + return q.String(), nil +} + +// List returns Snapshots optionally limited by the conditions provided in +// ListOpts. +func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager { + url := listURL(client) + if opts != nil { + query, err := opts.ToSnapshotListQuery() + if err != nil { + return pagination.Pager{Err: err} + } + url += query + } + + createPage := func(r pagination.PageResult) pagination.Page { + return ListResult{pagination.SinglePageBase(r)} + } + return pagination.NewPager(client, url, createPage) +} + +// UpdateMetadataOptsBuilder allows extensions to add additional parameters to +// the Update request. +type UpdateMetadataOptsBuilder interface { + ToSnapshotUpdateMetadataMap() (map[string]interface{}, error) +} + +// UpdateMetadataOpts contain options for updating an existing Snapshot. This +// object is passed to the snapshots.Update function. For more information +// about the parameters, see the Snapshot object. +type UpdateMetadataOpts struct { + Metadata map[string]interface{} +} + +// ToSnapshotUpdateMetadataMap assembles a request body based on the contents of +// an UpdateMetadataOpts. +func (opts UpdateMetadataOpts) ToSnapshotUpdateMetadataMap() (map[string]interface{}, error) { + v := make(map[string]interface{}) + + if opts.Metadata != nil { + v["metadata"] = opts.Metadata + } + + return v, nil +} + +// UpdateMetadata will update the Snapshot with provided information. To +// extract the updated Snapshot from the response, call the ExtractMetadata +// method on the UpdateMetadataResult. +func UpdateMetadata(client *gophercloud.ServiceClient, id string, opts UpdateMetadataOptsBuilder) UpdateMetadataResult { + var res UpdateMetadataResult + + reqBody, err := opts.ToSnapshotUpdateMetadataMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = client.Put(updateMetadataURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + return res +} + +// IDFromName is a convienience function that returns a snapshot's ID given its name. +func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) { + snapshotCount := 0 + snapshotID := "" + if name == "" { + return "", fmt.Errorf("A snapshot name must be provided.") + } + pager := List(client, nil) + pager.EachPage(func(page pagination.Page) (bool, error) { + snapshotList, err := ExtractSnapshots(page) + if err != nil { + return false, err + } + + for _, s := range snapshotList { + if s.Name == name { + snapshotCount++ + snapshotID = s.ID + } + } + return true, nil + }) + + switch snapshotCount { + case 0: + return "", fmt.Errorf("Unable to find snapshot: %s", name) + case 1: + return snapshotID, nil + default: + return "", fmt.Errorf("Found %d snapshots matching %s", snapshotCount, name) + } +} diff --git a/openstack/blockstorage/v2/snapshots/requests_test.go b/openstack/blockstorage/v2/snapshots/requests_test.go new file mode 100644 index 00000000..418032f3 --- /dev/null +++ b/openstack/blockstorage/v2/snapshots/requests_test.go @@ -0,0 +1,104 @@ +package snapshots + +import ( + "testing" + + "github.com/rackspace/gophercloud/pagination" + th "github.com/rackspace/gophercloud/testhelper" + "github.com/rackspace/gophercloud/testhelper/client" +) + +func TestList(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockListResponse(t) + + count := 0 + + List(client.ServiceClient(), &ListOpts{}).EachPage(func(page pagination.Page) (bool, error) { + count++ + actual, err := ExtractSnapshots(page) + if err != nil { + t.Errorf("Failed to extract snapshots: %v", err) + return false, err + } + + expected := []Snapshot{ + { + ID: "289da7f8-6440-407c-9fb4-7db01ec49164", + Name: "snapshot-001", + }, + { + ID: "96c3bda7-c82a-4f50-be73-ca7621794835", + Name: "snapshot-002", + }, + } + + th.CheckDeepEquals(t, expected, actual) + + return true, nil + }) + + if count != 1 { + t.Errorf("Expected 1 page, got %d", count) + } +} + +func TestGet(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockGetResponse(t) + + v, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, v.Name, "snapshot-001") + th.AssertEquals(t, v.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22") +} + +func TestCreate(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockCreateResponse(t) + + options := CreateOpts{VolumeID: "1234", Name: "snapshot-001"} + n, err := Create(client.ServiceClient(), options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, n.VolumeID, "1234") + th.AssertEquals(t, n.Name, "snapshot-001") + th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22") +} + +func TestUpdateMetadata(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockUpdateMetadataResponse(t) + + expected := map[string]interface{}{"key": "v2"} + + options := &UpdateMetadataOpts{ + Metadata: map[string]interface{}{ + "key": "v2", + }, + } + + actual, err := UpdateMetadata(client.ServiceClient(), "123", options).ExtractMetadata() + + th.AssertNoErr(t, err) + th.AssertDeepEquals(t, actual, expected) +} + +func TestDelete(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockDeleteResponse(t) + + res := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22") + th.AssertNoErr(t, res.Err) +} diff --git a/openstack/blockstorage/v2/snapshots/results.go b/openstack/blockstorage/v2/snapshots/results.go new file mode 100644 index 00000000..e595798e --- /dev/null +++ b/openstack/blockstorage/v2/snapshots/results.go @@ -0,0 +1,123 @@ +package snapshots + +import ( + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" + + "github.com/mitchellh/mapstructure" +) + +// Snapshot contains all the information associated with an OpenStack Snapshot. +type Snapshot struct { + // Currect status of the Snapshot. + Status string `mapstructure:"status"` + + // Display name. + Name string `mapstructure:"display_name"` + + // Instances onto which the Snapshot is attached. + Attachments []string `mapstructure:"attachments"` + + // Logical group. + AvailabilityZone string `mapstructure:"availability_zone"` + + // Is the Snapshot bootable? + Bootable string `mapstructure:"bootable"` + + // Date created. + CreatedAt string `mapstructure:"created_at"` + + // Display description. + Description string `mapstructure:"display_discription"` + + // See VolumeType object for more information. + VolumeType string `mapstructure:"volume_type"` + + // ID of the Snapshot from which this Snapshot was created. + SnapshotID string `mapstructure:"snapshot_id"` + + // ID of the Volume from which this Snapshot was created. + VolumeID string `mapstructure:"volume_id"` + + // User-defined key-value pairs. + Metadata map[string]string `mapstructure:"metadata"` + + // Unique identifier. + ID string `mapstructure:"id"` + + // Size of the Snapshot, in GB. + Size int `mapstructure:"size"` +} + +// CreateResult contains the response body and error from a Create request. +type CreateResult struct { + commonResult +} + +// GetResult contains the response body and error from a Get request. +type GetResult struct { + commonResult +} + +// DeleteResult contains the response body and error from a Delete request. +type DeleteResult struct { + gophercloud.ErrResult +} + +// ListResult is a pagination.Pager that is returned from a call to the List function. +type ListResult struct { + pagination.SinglePageBase +} + +// IsEmpty returns true if a ListResult contains no Snapshots. +func (r ListResult) IsEmpty() (bool, error) { + volumes, err := ExtractSnapshots(r) + if err != nil { + return true, err + } + return len(volumes) == 0, nil +} + +// ExtractSnapshots extracts and returns Snapshots. It is used while iterating over a snapshots.List call. +func ExtractSnapshots(page pagination.Page) ([]Snapshot, error) { + var response struct { + Snapshots []Snapshot `json:"snapshots"` + } + + err := mapstructure.Decode(page.(ListResult).Body, &response) + return response.Snapshots, err +} + +// UpdateMetadataResult contains the response body and error from an UpdateMetadata request. +type UpdateMetadataResult struct { + commonResult +} + +// ExtractMetadata returns the metadata from a response from snapshots.UpdateMetadata. +func (r UpdateMetadataResult) ExtractMetadata() (map[string]interface{}, error) { + if r.Err != nil { + return nil, r.Err + } + + m := r.Body.(map[string]interface{})["metadata"] + return m.(map[string]interface{}), nil +} + +type commonResult struct { + gophercloud.Result +} + +// Extract will get the Snapshot object out of the commonResult object. +func (r commonResult) Extract() (*Snapshot, error) { + if r.Err != nil { + return nil, r.Err + } + + var res struct { + Snapshot *Snapshot `json:"snapshot"` + } + + err := mapstructure.Decode(r.Body, &res) + + return res.Snapshot, err +} diff --git a/openstack/blockstorage/v2/snapshots/urls.go b/openstack/blockstorage/v2/snapshots/urls.go new file mode 100644 index 00000000..4d635e8d --- /dev/null +++ b/openstack/blockstorage/v2/snapshots/urls.go @@ -0,0 +1,27 @@ +package snapshots + +import "github.com/rackspace/gophercloud" + +func createURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("snapshots") +} + +func deleteURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL("snapshots", id) +} + +func getURL(c *gophercloud.ServiceClient, id string) string { + return deleteURL(c, id) +} + +func listURL(c *gophercloud.ServiceClient) string { + return createURL(c) +} + +func metadataURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL("snapshots", id, "metadata") +} + +func updateMetadataURL(c *gophercloud.ServiceClient, id string) string { + return metadataURL(c, id) +} diff --git a/openstack/blockstorage/v2/snapshots/urls_test.go b/openstack/blockstorage/v2/snapshots/urls_test.go new file mode 100644 index 00000000..feacf7f6 --- /dev/null +++ b/openstack/blockstorage/v2/snapshots/urls_test.go @@ -0,0 +1,50 @@ +package snapshots + +import ( + "testing" + + "github.com/rackspace/gophercloud" + th "github.com/rackspace/gophercloud/testhelper" +) + +const endpoint = "http://localhost:57909" + +func endpointClient() *gophercloud.ServiceClient { + return &gophercloud.ServiceClient{Endpoint: endpoint} +} + +func TestCreateURL(t *testing.T) { + actual := createURL(endpointClient()) + expected := endpoint + "snapshots" + th.AssertEquals(t, expected, actual) +} + +func TestDeleteURL(t *testing.T) { + actual := deleteURL(endpointClient(), "foo") + expected := endpoint + "snapshots/foo" + th.AssertEquals(t, expected, actual) +} + +func TestGetURL(t *testing.T) { + actual := getURL(endpointClient(), "foo") + expected := endpoint + "snapshots/foo" + th.AssertEquals(t, expected, actual) +} + +func TestListURL(t *testing.T) { + actual := listURL(endpointClient()) + expected := endpoint + "snapshots" + th.AssertEquals(t, expected, actual) +} + +func TestMetadataURL(t *testing.T) { + actual := metadataURL(endpointClient(), "foo") + expected := endpoint + "snapshots/foo/metadata" + th.AssertEquals(t, expected, actual) +} + +func TestUpdateMetadataURL(t *testing.T) { + actual := updateMetadataURL(endpointClient(), "foo") + expected := endpoint + "snapshots/foo/metadata" + th.AssertEquals(t, expected, actual) +} diff --git a/openstack/blockstorage/v2/snapshots/util.go b/openstack/blockstorage/v2/snapshots/util.go new file mode 100644 index 00000000..64cdc607 --- /dev/null +++ b/openstack/blockstorage/v2/snapshots/util.go @@ -0,0 +1,22 @@ +package snapshots + +import ( + "github.com/rackspace/gophercloud" +) + +// WaitForStatus will continually poll the resource, checking for a particular +// status. It will do this for the amount of seconds defined. +func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error { + return gophercloud.WaitFor(secs, func() (bool, error) { + current, err := Get(c, id).Extract() + if err != nil { + return false, err + } + + if current.Status == status { + return true, nil + } + + return false, nil + }) +} diff --git a/openstack/blockstorage/v2/volumes/doc.go b/openstack/blockstorage/v2/volumes/doc.go new file mode 100644 index 00000000..307b8b12 --- /dev/null +++ b/openstack/blockstorage/v2/volumes/doc.go @@ -0,0 +1,5 @@ +// Package volumes provides information and interaction with volumes in the +// OpenStack Block Storage service. A volume is a detachable block storage +// device, akin to a USB hard drive. It can only be attached to one instance at +// a time. +package volumes diff --git a/openstack/blockstorage/v2/volumes/fixtures.go b/openstack/blockstorage/v2/volumes/fixtures.go new file mode 100644 index 00000000..3efdd696 --- /dev/null +++ b/openstack/blockstorage/v2/volumes/fixtures.go @@ -0,0 +1,113 @@ +package volumes + +import ( + "fmt" + "net/http" + "testing" + + th "github.com/rackspace/gophercloud/testhelper" + fake "github.com/rackspace/gophercloud/testhelper/client" +) + +func MockListResponse(t *testing.T) { + th.Mux.HandleFunc("/volumes/detail", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, ` + { + "volumes": [ + { + "id": "289da7f8-6440-407c-9fb4-7db01ec49164", + "name": "vol-001" + }, + { + "id": "96c3bda7-c82a-4f50-be73-ca7621794835", + "name": "vol-002" + } + ] + } + `) + }) +} + +func MockGetResponse(t *testing.T) { + th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, ` +{ + "volume": { + "name": "vol-001", + "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22", + "attachments": [ + { + "device": "/dev/vde", + "server_id": "a740d24b-dc5b-4d59-ac75-53971c2920ba", + "id": "d6da11e5-2ed3-413e-88d8-b772ba62193d", + "volume_id": "d6da11e5-2ed3-413e-88d8-b772ba62193d" + } + ] + } +} + `) + }) +} + +func MockCreateResponse(t *testing.T) { + th.Mux.HandleFunc("/volumes", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "volume": { + "size": 75 + } +} + `) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + fmt.Fprintf(w, ` +{ + "volume": { + "size": 4, + "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22" + } +} + `) + }) +} + +func MockDeleteResponse(t *testing.T) { + th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "DELETE") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + w.WriteHeader(http.StatusNoContent) + }) +} + +func MockUpdateResponse(t *testing.T) { + th.Mux.HandleFunc("/volumes/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "PUT") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, ` + { + "volume": { + "name": "vol-002", + "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22" + } + } + `) + }) +} diff --git a/openstack/blockstorage/v2/volumes/requests.go b/openstack/blockstorage/v2/volumes/requests.go new file mode 100644 index 00000000..ef56969d --- /dev/null +++ b/openstack/blockstorage/v2/volumes/requests.go @@ -0,0 +1,403 @@ +package volumes + +import ( + "fmt" + + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" +) + +// CreateOptsBuilder allows extensions to add additional parameters to the +// Create request. +type CreateOptsBuilder interface { + ToVolumeCreateMap() (map[string]interface{}, error) +} + +// CreateOpts contains options for creating a Volume. This object is passed to +// the volumes.Create function. For more information about these parameters, +// see the Volume object. +type CreateOpts struct { + // OPTIONAL + Availability string + // OPTIONAL + Description string + // OPTIONAL + Metadata map[string]string + // OPTIONAL + Name string + // REQUIRED + Size int + // OPTIONAL + SnapshotID, SourceVolID, ImageID string + // OPTIONAL + VolumeType string +} + +// ToVolumeCreateMap assembles a request body based on the contents of a +// CreateOpts. +func (opts CreateOpts) ToVolumeCreateMap() (map[string]interface{}, error) { + v := make(map[string]interface{}) + + if opts.Size == 0 { + return nil, fmt.Errorf("Required CreateOpts field 'Size' not set.") + } + v["size"] = opts.Size + + if opts.Availability != "" { + v["availability_zone"] = opts.Availability + } + if opts.Description != "" { + v["display_description"] = opts.Description + } + if opts.ImageID != "" { + v["imageRef"] = opts.ImageID + } + if opts.Metadata != nil { + v["metadata"] = opts.Metadata + } + if opts.Name != "" { + v["display_name"] = opts.Name + } + if opts.SourceVolID != "" { + v["source_volid"] = opts.SourceVolID + } + if opts.SnapshotID != "" { + v["snapshot_id"] = opts.SnapshotID + } + if opts.VolumeType != "" { + v["volume_type"] = opts.VolumeType + } + + return map[string]interface{}{"volume": v}, nil +} + +// Create will create a new Volume based on the values in CreateOpts. To extract +// the Volume object from the response, call the Extract method on the +// CreateResult. +func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult { + var res CreateResult + + reqBody, err := opts.ToVolumeCreateMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201, 202}, + }) + return res +} + +// Delete will delete the existing Volume with the provided ID. +func Delete(client *gophercloud.ServiceClient, id string) DeleteResult { + var res DeleteResult + _, res.Err = client.Delete(deleteURL(client, id), nil) + return res +} + +// Get retrieves the Volume with the provided ID. To extract the Volume object +// from the response, call the Extract method on the GetResult. +func Get(client *gophercloud.ServiceClient, id string) GetResult { + var res GetResult + _, res.Err = client.Get(getURL(client, id), &res.Body, nil) + return res +} + +// ListOptsBuilder allows extensions to add additional parameters to the List +// request. +type ListOptsBuilder interface { + ToVolumeListQuery() (string, error) +} + +// ListOpts holds options for listing Volumes. It is passed to the volumes.List +// function. +type ListOpts struct { + // admin-only option. Set it to true to see all tenant volumes. + AllTenants bool `q:"all_tenants"` + // List only volumes that contain Metadata. + Metadata map[string]string `q:"metadata"` + // List only volumes that have Name as the display name. + Name string `q:"name"` + // List only volumes that have a status of Status. + Status string `q:"status"` +} + +// ToVolumeListQuery formats a ListOpts into a query string. +func (opts ListOpts) ToVolumeListQuery() (string, error) { + q, err := gophercloud.BuildQueryString(opts) + if err != nil { + return "", err + } + return q.String(), nil +} + +// List returns Volumes optionally limited by the conditions provided in ListOpts. +func List(client *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager { + url := listURL(client) + if opts != nil { + query, err := opts.ToVolumeListQuery() + if err != nil { + return pagination.Pager{Err: err} + } + url += query + } + createPage := func(r pagination.PageResult) pagination.Page { + return ListResult{pagination.SinglePageBase(r)} + } + + return pagination.NewPager(client, url, createPage) +} + +// UpdateOptsBuilder allows extensions to add additional parameters to the +// Update request. +type UpdateOptsBuilder interface { + ToVolumeUpdateMap() (map[string]interface{}, error) +} + +// UpdateOpts contain options for updating an existing Volume. This object is passed +// to the volumes.Update function. For more information about the parameters, see +// the Volume object. +type UpdateOpts struct { + // OPTIONAL + Name string + // OPTIONAL + Description string + // OPTIONAL + Metadata map[string]string +} + +// ToVolumeUpdateMap assembles a request body based on the contents of an +// UpdateOpts. +func (opts UpdateOpts) ToVolumeUpdateMap() (map[string]interface{}, error) { + v := make(map[string]interface{}) + + if opts.Description != "" { + v["display_description"] = opts.Description + } + if opts.Metadata != nil { + v["metadata"] = opts.Metadata + } + if opts.Name != "" { + v["display_name"] = opts.Name + } + + return map[string]interface{}{"volume": v}, nil +} + +// Update will update the Volume with provided information. To extract the updated +// Volume from the response, call the Extract method on the UpdateResult. +func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) UpdateResult { + var res UpdateResult + + reqBody, err := opts.ToVolumeUpdateMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = client.Put(updateURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200}, + }) + return res +} + +// IDFromName is a convienience function that returns a server's ID given its name. +func IDFromName(client *gophercloud.ServiceClient, name string) (string, error) { + volumeCount := 0 + volumeID := "" + if name == "" { + return "", fmt.Errorf("A volume name must be provided.") + } + pager := List(client, nil) + pager.EachPage(func(page pagination.Page) (bool, error) { + volumeList, err := ExtractVolumes(page) + if err != nil { + return false, err + } + + for _, s := range volumeList { + if s.Name == name { + volumeCount++ + volumeID = s.ID + } + } + return true, nil + }) + + switch volumeCount { + case 0: + return "", fmt.Errorf("Unable to find volume: %s", name) + case 1: + return volumeID, nil + default: + return "", fmt.Errorf("Found %d volumes matching %s", volumeCount, name) + } +} + +type AttachOptsBuilder interface { + ToVolumeAttachMap() (map[string]interface{}, error) +} + +type AttachOpts struct { + MountPoint string + InstanceUUID string + HostName string + Mode string +} + +func (opts AttachOpts) ToVolumeAttachMap() (map[string]interface{}, error) { + v := make(map[string]interface{}) + + if opts.MountPoint != "" { + v["mountpoint"] = opts.MountPoint + } + if opts.Mode != "" { + v["mode"] = opts.Mode + } + if opts.InstanceUUID != "" { + v["instance_uuid"] = opts.InstanceUUID + } + if opts.HostName != "" { + v["host_name"] = opts.HostName + } + + return map[string]interface{}{"os-attach": v}, nil +} + +func Attach(client *gophercloud.ServiceClient, id string, opts AttachOptsBuilder) AttachResult { + var res AttachResult + + reqBody, err := opts.ToVolumeAttachMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = client.Post(attachURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201, 202}, + }) + + return res +} + +func Detach(client *gophercloud.ServiceClient, id string) DetachResult { + var res DetachResult + + v := make(map[string]interface{}) + reqBody := map[string]interface{}{"os-detach": v} + + _, res.Err = client.Post(detachURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201, 202}, + }) + + return res +} + +func Reserve(client *gophercloud.ServiceClient, id string) ReserveResult { + var res ReserveResult + + v := make(map[string]interface{}) + reqBody := map[string]interface{}{"os-reserve": v} + + _, res.Err = client.Post(reserveURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201, 202}, + }) + + return res +} + +func Unreserve(client *gophercloud.ServiceClient, id string) UnreserveResult { + var res UnreserveResult + + v := make(map[string]interface{}) + reqBody := map[string]interface{}{"os-unreserve": v} + + _, res.Err = client.Post(unreserveURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201, 202}, + }) + + return res +} + +type ConnectorOptsBuilder interface { + ToConnectorMap() (map[string]interface{}, error) +} + +type ConnectorOpts struct { + IP string + Host string + Initiator string + Wwpns string + Wwnns string + Multipath bool + Platform string + OSType string +} + +func (opts ConnectorOpts) ToConnectorMap() (map[string]interface{}, error) { + v := make(map[string]interface{}) + + if opts.IP != "" { + v["ip"] = opts.IP + } + if opts.Host != "" { + v["host"] = opts.Host + } + if opts.Initiator != "" { + v["initiator"] = opts.Initiator + } + if opts.Wwpns != "" { + v["wwpns"] = opts.Wwpns + } + if opts.Wwnns != "" { + v["wwnns"] = opts.Wwnns + } + + v["multipath"] = opts.Multipath + + if opts.Platform != "" { + v["platform"] = opts.Platform + } + if opts.OSType != "" { + v["os_type"] = opts.OSType + } + + return map[string]interface{}{"connector": v}, nil +} + +func InitializeConnection(client *gophercloud.ServiceClient, id string, opts ConnectorOpts) InitializeConnectionResult { + var res InitializeConnectionResult + + connctorMap, err := opts.ToConnectorMap() + if err != nil { + res.Err = err + return res + } + + reqBody := map[string]interface{}{"os-initialize_connection": connctorMap} + + _, res.Err = client.Post(attachURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201, 202}, + }) + + return res +} + +func TerminateConnection(client *gophercloud.ServiceClient, id string, opts ConnectorOpts) TerminateConnectionResult { + var res TerminateConnectionResult + + connctorMap, err := opts.ToConnectorMap() + if err != nil { + res.Err = err + return res + } + + reqBody := map[string]interface{}{"os-terminate_connection": connctorMap} + + _, res.Err = client.Post(attachURL(client, id), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201, 202}, + }) + + return res +} diff --git a/openstack/blockstorage/v2/volumes/requests_test.go b/openstack/blockstorage/v2/volumes/requests_test.go new file mode 100644 index 00000000..c484cf08 --- /dev/null +++ b/openstack/blockstorage/v2/volumes/requests_test.go @@ -0,0 +1,122 @@ +package volumes + +import ( + "testing" + + "github.com/rackspace/gophercloud/pagination" + th "github.com/rackspace/gophercloud/testhelper" + "github.com/rackspace/gophercloud/testhelper/client" +) + +func TestList(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockListResponse(t) + + count := 0 + + List(client.ServiceClient(), &ListOpts{}).EachPage(func(page pagination.Page) (bool, error) { + count++ + actual, err := ExtractVolumes(page) + if err != nil { + t.Errorf("Failed to extract volumes: %v", err) + return false, err + } + + expected := []Volume{ + Volume{ + ID: "289da7f8-6440-407c-9fb4-7db01ec49164", + Name: "vol-001", + }, + Volume{ + ID: "96c3bda7-c82a-4f50-be73-ca7621794835", + Name: "vol-002", + }, + } + + th.CheckDeepEquals(t, expected, actual) + + return true, nil + }) + + if count != 1 { + t.Errorf("Expected 1 page, got %d", count) + } +} + +func TestListAll(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockListResponse(t) + + allPages, err := List(client.ServiceClient(), &ListOpts{}).AllPages() + th.AssertNoErr(t, err) + actual, err := ExtractVolumes(allPages) + th.AssertNoErr(t, err) + + expected := []Volume{ + Volume{ + ID: "289da7f8-6440-407c-9fb4-7db01ec49164", + Name: "vol-001", + }, + Volume{ + ID: "96c3bda7-c82a-4f50-be73-ca7621794835", + Name: "vol-002", + }, + } + + th.CheckDeepEquals(t, expected, actual) + +} + +func TestGet(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockGetResponse(t) + + v, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, v.Name, "vol-001") + th.AssertEquals(t, v.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22") + th.AssertEquals(t, v.Attachments[0]["device"], "/dev/vde") +} + +func TestCreate(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockCreateResponse(t) + + options := &CreateOpts{Size: 75} + n, err := Create(client.ServiceClient(), options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, n.Size, 4) + th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22") +} + +func TestDelete(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockDeleteResponse(t) + + res := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22") + th.AssertNoErr(t, res.Err) +} + +func TestUpdate(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockUpdateResponse(t) + + options := UpdateOpts{Name: "vol-002"} + v, err := Update(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22", options).Extract() + th.AssertNoErr(t, err) + th.CheckEquals(t, "vol-002", v.Name) +} diff --git a/openstack/blockstorage/v2/volumes/results.go b/openstack/blockstorage/v2/volumes/results.go new file mode 100644 index 00000000..c2c459c2 --- /dev/null +++ b/openstack/blockstorage/v2/volumes/results.go @@ -0,0 +1,143 @@ +package volumes + +import ( + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" + + "github.com/mitchellh/mapstructure" +) + +// Volume contains all the information associated with an OpenStack Volume. +type Volume struct { + // Current status of the volume. + Status string `mapstructure:"status"` + + // Human-readable display name for the volume. + Name string `mapstructure:"name"` + + // Instances onto which the volume is attached. + Attachments []map[string]interface{} `mapstructure:"attachments"` + + // This parameter is no longer used. + AvailabilityZone string `mapstructure:"availability_zone"` + + // Indicates whether this is a bootable volume. + Bootable string `mapstructure:"bootable"` + + // The date when this volume was created. + CreatedAt string `mapstructure:"created_at"` + + // Human-readable description for the volume. + Description string `mapstructure:"display_description"` + + // The type of volume to create, either SATA or SSD. + VolumeType string `mapstructure:"volume_type"` + + // The ID of the snapshot from which the volume was created + SnapshotID string `mapstructure:"snapshot_id"` + + // The ID of another block storage volume from which the current volume was created + SourceVolID string `mapstructure:"source_volid"` + + // Arbitrary key-value pairs defined by the user. + Metadata map[string]string `mapstructure:"metadata"` + + // Unique identifier for the volume. + ID string `mapstructure:"id"` + + // Size of the volume in GB. + Size int `mapstructure:"size"` +} + +// CreateResult contains the response body and error from a Create request. +type CreateResult struct { + commonResult +} + +// GetResult contains the response body and error from a Get request. +type GetResult struct { + commonResult +} + +// DeleteResult contains the response body and error from a Delete request. +type DeleteResult struct { + gophercloud.ErrResult +} + +// ListResult is a pagination.pager that is returned from a call to the List function. +type ListResult struct { + pagination.SinglePageBase +} + +// IsEmpty returns true if a ListResult contains no Volumes. +func (r ListResult) IsEmpty() (bool, error) { + volumes, err := ExtractVolumes(r) + if err != nil { + return true, err + } + return len(volumes) == 0, nil +} + +// ExtractVolumes extracts and returns Volumes. It is used while iterating over a volumes.List call. +func ExtractVolumes(page pagination.Page) ([]Volume, error) { + var response struct { + Volumes []Volume `json:"volumes"` + } + + err := mapstructure.Decode(page.(ListResult).Body, &response) + return response.Volumes, err +} + +// UpdateResult contains the response body and error from an Update request. +type UpdateResult struct { + commonResult +} + +// AttachResult contains the response body and error from a Get request. +type AttachResult struct { + commonResult +} + +// DetachResult contains the response body and error from a Get request. +type DetachResult struct { + commonResult +} + +// ReserveResult contains the response body and error from a Get request. +type ReserveResult struct { + commonResult +} + +// UnreserveResult contains the response body and error from a Get request. +type UnreserveResult struct { + commonResult +} + +// InitializeConnectionResult contains the response body and error from a Get request. +type InitializeConnectionResult struct { + commonResult +} + +// TerminateConnectionResult contains the response body and error from a Get request. +type TerminateConnectionResult struct { + commonResult +} + +type commonResult struct { + gophercloud.Result +} + +// Extract will get the Volume object out of the commonResult object. +func (r commonResult) Extract() (*Volume, error) { + if r.Err != nil { + return nil, r.Err + } + + var res struct { + Volume *Volume `json:"volume"` + } + + err := mapstructure.Decode(r.Body, &res) + + return res.Volume, err +} diff --git a/openstack/blockstorage/v2/volumes/urls.go b/openstack/blockstorage/v2/volumes/urls.go new file mode 100644 index 00000000..0085ea3d --- /dev/null +++ b/openstack/blockstorage/v2/volumes/urls.go @@ -0,0 +1,47 @@ +package volumes + +import "github.com/rackspace/gophercloud" + +func createURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("volumes") +} + +func listURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("volumes", "detail") +} + +func deleteURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL("volumes", id) +} + +func getURL(c *gophercloud.ServiceClient, id string) string { + return deleteURL(c, id) +} + +func updateURL(c *gophercloud.ServiceClient, id string) string { + return deleteURL(c, id) +} + +func attachURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL("volumes", id, "action") +} + +func detachURL(c *gophercloud.ServiceClient, id string) string { + return attachURL(c, id) +} + +func reserveURL(c *gophercloud.ServiceClient, id string) string { + return attachURL(c, id) +} + +func unreserveURL(c *gophercloud.ServiceClient, id string) string { + return attachURL(c, id) +} + +func initializeConnectionURL(c *gophercloud.ServiceClient, id string) string { + return attachURL(c, id) +} + +func teminateConnectionURL(c *gophercloud.ServiceClient, id string) string { + return attachURL(c, id) +} diff --git a/openstack/blockstorage/v2/volumes/urls_test.go b/openstack/blockstorage/v2/volumes/urls_test.go new file mode 100644 index 00000000..792b19b4 --- /dev/null +++ b/openstack/blockstorage/v2/volumes/urls_test.go @@ -0,0 +1,44 @@ +package volumes + +import ( + "testing" + + "github.com/rackspace/gophercloud" + th "github.com/rackspace/gophercloud/testhelper" +) + +const endpoint = "http://localhost:57909" + +func endpointClient() *gophercloud.ServiceClient { + return &gophercloud.ServiceClient{Endpoint: endpoint} +} + +func TestCreateURL(t *testing.T) { + actual := createURL(endpointClient()) + expected := endpoint + "volumes" + th.AssertEquals(t, expected, actual) +} + +func TestListURL(t *testing.T) { + actual := listURL(endpointClient()) + expected := endpoint + "volumes/detail" + th.AssertEquals(t, expected, actual) +} + +func TestDeleteURL(t *testing.T) { + actual := deleteURL(endpointClient(), "foo") + expected := endpoint + "volumes/foo" + th.AssertEquals(t, expected, actual) +} + +func TestGetURL(t *testing.T) { + actual := getURL(endpointClient(), "foo") + expected := endpoint + "volumes/foo" + th.AssertEquals(t, expected, actual) +} + +func TestUpdateURL(t *testing.T) { + actual := updateURL(endpointClient(), "foo") + expected := endpoint + "volumes/foo" + th.AssertEquals(t, expected, actual) +} diff --git a/openstack/blockstorage/v2/volumes/util.go b/openstack/blockstorage/v2/volumes/util.go new file mode 100644 index 00000000..1dda695e --- /dev/null +++ b/openstack/blockstorage/v2/volumes/util.go @@ -0,0 +1,22 @@ +package volumes + +import ( + "github.com/rackspace/gophercloud" +) + +// WaitForStatus will continually poll the resource, checking for a particular +// status. It will do this for the amount of seconds defined. +func WaitForStatus(c *gophercloud.ServiceClient, id, status string, secs int) error { + return gophercloud.WaitFor(secs, func() (bool, error) { + current, err := Get(c, id).Extract() + if err != nil { + return false, err + } + + if current.Status == status { + return true, nil + } + + return false, nil + }) +} diff --git a/openstack/blockstorage/v2/volumetypes/doc.go b/openstack/blockstorage/v2/volumetypes/doc.go new file mode 100644 index 00000000..793084f8 --- /dev/null +++ b/openstack/blockstorage/v2/volumetypes/doc.go @@ -0,0 +1,9 @@ +// Package volumetypes provides information and interaction with volume types +// in the OpenStack Block Storage service. A volume type indicates the type of +// a block storage volume, such as SATA, SCSCI, SSD, etc. These can be +// customized or defined by the OpenStack admin. +// +// You can also define extra_specs associated with your volume types. For +// instance, you could have a VolumeType=SATA, with extra_specs (RPM=10000, +// RAID-Level=5) . Extra_specs are defined and customized by the admin. +package volumetypes diff --git a/openstack/blockstorage/v2/volumetypes/fixtures.go b/openstack/blockstorage/v2/volumetypes/fixtures.go new file mode 100644 index 00000000..e3326eae --- /dev/null +++ b/openstack/blockstorage/v2/volumetypes/fixtures.go @@ -0,0 +1,60 @@ +package volumetypes + +import ( + "fmt" + "net/http" + "testing" + + th "github.com/rackspace/gophercloud/testhelper" + fake "github.com/rackspace/gophercloud/testhelper/client" +) + +func MockListResponse(t *testing.T) { + th.Mux.HandleFunc("/types", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, ` + { + "volume_types": [ + { + "id": "289da7f8-6440-407c-9fb4-7db01ec49164", + "name": "vol-type-001", + "extra_specs": { + "capabilities": "gpu" + } + }, + { + "id": "96c3bda7-c82a-4f50-be73-ca7621794835", + "name": "vol-type-002", + "extra_specs": {} + } + ] + } + `) + }) +} + +func MockGetResponse(t *testing.T) { + th.Mux.HandleFunc("/types/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "GET") + th.TestHeader(t, r, "X-Auth-Token", fake.TokenID) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, ` +{ + "volume_type": { + "name": "vol-type-001", + "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22", + "extra_specs": { + "serverNumber": "2" + } + } +} + `) + }) +} diff --git a/openstack/blockstorage/v2/volumetypes/requests.go b/openstack/blockstorage/v2/volumetypes/requests.go new file mode 100644 index 00000000..1673d13a --- /dev/null +++ b/openstack/blockstorage/v2/volumetypes/requests.go @@ -0,0 +1,76 @@ +package volumetypes + +import ( + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" +) + +// CreateOptsBuilder allows extensions to add additional parameters to the +// Create request. +type CreateOptsBuilder interface { + ToVolumeTypeCreateMap() (map[string]interface{}, error) +} + +// CreateOpts are options for creating a volume type. +type CreateOpts struct { + // OPTIONAL. See VolumeType. + ExtraSpecs map[string]interface{} + // OPTIONAL. See VolumeType. + Name string +} + +// ToVolumeTypeCreateMap casts a CreateOpts struct to a map. +func (opts CreateOpts) ToVolumeTypeCreateMap() (map[string]interface{}, error) { + vt := make(map[string]interface{}) + + if opts.ExtraSpecs != nil { + vt["extra_specs"] = opts.ExtraSpecs + } + if opts.Name != "" { + vt["name"] = opts.Name + } + + return map[string]interface{}{"volume_type": vt}, nil +} + +// Create will create a new volume. To extract the created volume type object, +// call the Extract method on the CreateResult. +func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) CreateResult { + var res CreateResult + + reqBody, err := opts.ToVolumeTypeCreateMap() + if err != nil { + res.Err = err + return res + } + + _, res.Err = client.Post(createURL(client), reqBody, &res.Body, &gophercloud.RequestOpts{ + OkCodes: []int{200, 201}, + }) + return res +} + +// Delete will delete the volume type with the provided ID. +func Delete(client *gophercloud.ServiceClient, id string) DeleteResult { + var res DeleteResult + _, res.Err = client.Delete(deleteURL(client, id), nil) + return res +} + +// Get will retrieve the volume type with the provided ID. To extract the volume +// type from the result, call the Extract method on the GetResult. +func Get(client *gophercloud.ServiceClient, id string) GetResult { + var res GetResult + _, err := client.Get(getURL(client, id), &res.Body, nil) + res.Err = err + return res +} + +// List returns all volume types. +func List(client *gophercloud.ServiceClient) pagination.Pager { + createPage := func(r pagination.PageResult) pagination.Page { + return ListResult{pagination.SinglePageBase(r)} + } + + return pagination.NewPager(client, listURL(client), createPage) +} diff --git a/openstack/blockstorage/v2/volumetypes/requests_test.go b/openstack/blockstorage/v2/volumetypes/requests_test.go new file mode 100644 index 00000000..8d40bfe1 --- /dev/null +++ b/openstack/blockstorage/v2/volumetypes/requests_test.go @@ -0,0 +1,118 @@ +package volumetypes + +import ( + "fmt" + "net/http" + "testing" + + "github.com/rackspace/gophercloud/pagination" + th "github.com/rackspace/gophercloud/testhelper" + "github.com/rackspace/gophercloud/testhelper/client" +) + +func TestList(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockListResponse(t) + + count := 0 + + List(client.ServiceClient()).EachPage(func(page pagination.Page) (bool, error) { + count++ + actual, err := ExtractVolumeTypes(page) + if err != nil { + t.Errorf("Failed to extract volume types: %v", err) + return false, err + } + + expected := []VolumeType{ + VolumeType{ + ID: "289da7f8-6440-407c-9fb4-7db01ec49164", + Name: "vol-type-001", + ExtraSpecs: map[string]interface{}{ + "capabilities": "gpu", + }, + }, + VolumeType{ + ID: "96c3bda7-c82a-4f50-be73-ca7621794835", + Name: "vol-type-002", + ExtraSpecs: map[string]interface{}{}, + }, + } + + th.CheckDeepEquals(t, expected, actual) + + return true, nil + }) + + if count != 1 { + t.Errorf("Expected 1 page, got %d", count) + } +} + +func TestGet(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + MockGetResponse(t) + + vt, err := Get(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").Extract() + th.AssertNoErr(t, err) + + th.AssertDeepEquals(t, vt.ExtraSpecs, map[string]interface{}{"serverNumber": "2"}) + th.AssertEquals(t, vt.Name, "vol-type-001") + th.AssertEquals(t, vt.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22") +} + +func TestCreate(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/types", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "POST") + th.TestHeader(t, r, "X-Auth-Token", client.TokenID) + th.TestHeader(t, r, "Content-Type", "application/json") + th.TestHeader(t, r, "Accept", "application/json") + th.TestJSONRequest(t, r, ` +{ + "volume_type": { + "name": "vol-type-001" + } +} + `) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + + fmt.Fprintf(w, ` +{ + "volume_type": { + "name": "vol-type-001", + "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22" + } +} + `) + }) + + options := &CreateOpts{Name: "vol-type-001"} + n, err := Create(client.ServiceClient(), options).Extract() + th.AssertNoErr(t, err) + + th.AssertEquals(t, n.Name, "vol-type-001") + th.AssertEquals(t, n.ID, "d32019d3-bc6e-4319-9c1d-6722fc136a22") +} + +func TestDelete(t *testing.T) { + th.SetupHTTP() + defer th.TeardownHTTP() + + th.Mux.HandleFunc("/types/d32019d3-bc6e-4319-9c1d-6722fc136a22", func(w http.ResponseWriter, r *http.Request) { + th.TestMethod(t, r, "DELETE") + th.TestHeader(t, r, "X-Auth-Token", client.TokenID) + w.WriteHeader(http.StatusAccepted) + }) + + err := Delete(client.ServiceClient(), "d32019d3-bc6e-4319-9c1d-6722fc136a22").ExtractErr() + th.AssertNoErr(t, err) +} diff --git a/openstack/blockstorage/v2/volumetypes/results.go b/openstack/blockstorage/v2/volumetypes/results.go new file mode 100644 index 00000000..c049a045 --- /dev/null +++ b/openstack/blockstorage/v2/volumetypes/results.go @@ -0,0 +1,72 @@ +package volumetypes + +import ( + "github.com/mitchellh/mapstructure" + "github.com/rackspace/gophercloud" + "github.com/rackspace/gophercloud/pagination" +) + +// VolumeType contains all information associated with an OpenStack Volume Type. +type VolumeType struct { + ExtraSpecs map[string]interface{} `json:"extra_specs" mapstructure:"extra_specs"` // user-defined metadata + ID string `json:"id" mapstructure:"id"` // unique identifier + Name string `json:"name" mapstructure:"name"` // display name +} + +// CreateResult contains the response body and error from a Create request. +type CreateResult struct { + commonResult +} + +// GetResult contains the response body and error from a Get request. +type GetResult struct { + commonResult +} + +// DeleteResult contains the response error from a Delete request. +type DeleteResult struct { + gophercloud.ErrResult +} + +// ListResult is a pagination.Pager that is returned from a call to the List function. +type ListResult struct { + pagination.SinglePageBase +} + +// IsEmpty returns true if a ListResult contains no Volume Types. +func (r ListResult) IsEmpty() (bool, error) { + volumeTypes, err := ExtractVolumeTypes(r) + if err != nil { + return true, err + } + return len(volumeTypes) == 0, nil +} + +// ExtractVolumeTypes extracts and returns Volume Types. +func ExtractVolumeTypes(page pagination.Page) ([]VolumeType, error) { + var response struct { + VolumeTypes []VolumeType `mapstructure:"volume_types"` + } + + err := mapstructure.Decode(page.(ListResult).Body, &response) + return response.VolumeTypes, err +} + +type commonResult struct { + gophercloud.Result +} + +// Extract will get the Volume Type object out of the commonResult object. +func (r commonResult) Extract() (*VolumeType, error) { + if r.Err != nil { + return nil, r.Err + } + + var res struct { + VolumeType *VolumeType `json:"volume_type" mapstructure:"volume_type"` + } + + err := mapstructure.Decode(r.Body, &res) + + return res.VolumeType, err +} diff --git a/openstack/blockstorage/v2/volumetypes/urls.go b/openstack/blockstorage/v2/volumetypes/urls.go new file mode 100644 index 00000000..cf8367bf --- /dev/null +++ b/openstack/blockstorage/v2/volumetypes/urls.go @@ -0,0 +1,19 @@ +package volumetypes + +import "github.com/rackspace/gophercloud" + +func listURL(c *gophercloud.ServiceClient) string { + return c.ServiceURL("types") +} + +func createURL(c *gophercloud.ServiceClient) string { + return listURL(c) +} + +func getURL(c *gophercloud.ServiceClient, id string) string { + return c.ServiceURL("types", id) +} + +func deleteURL(c *gophercloud.ServiceClient, id string) string { + return getURL(c, id) +} diff --git a/openstack/blockstorage/v2/volumetypes/urls_test.go b/openstack/blockstorage/v2/volumetypes/urls_test.go new file mode 100644 index 00000000..44016e29 --- /dev/null +++ b/openstack/blockstorage/v2/volumetypes/urls_test.go @@ -0,0 +1,38 @@ +package volumetypes + +import ( + "testing" + + "github.com/rackspace/gophercloud" + th "github.com/rackspace/gophercloud/testhelper" +) + +const endpoint = "http://localhost:57909" + +func endpointClient() *gophercloud.ServiceClient { + return &gophercloud.ServiceClient{Endpoint: endpoint} +} + +func TestListURL(t *testing.T) { + actual := listURL(endpointClient()) + expected := endpoint + "types" + th.AssertEquals(t, expected, actual) +} + +func TestCreateURL(t *testing.T) { + actual := createURL(endpointClient()) + expected := endpoint + "types" + th.AssertEquals(t, expected, actual) +} + +func TestGetURL(t *testing.T) { + actual := getURL(endpointClient(), "foo") + expected := endpoint + "types/foo" + th.AssertEquals(t, expected, actual) +} + +func TestDeleteURL(t *testing.T) { + actual := deleteURL(endpointClient(), "foo") + expected := endpoint + "types/foo" + th.AssertEquals(t, expected, actual) +} diff --git a/openstack/client.go b/openstack/client.go index 1193b19a..86cd46b4 100644 --- a/openstack/client.go +++ b/openstack/client.go @@ -3,6 +3,7 @@ package openstack import ( "fmt" "net/url" + "strings" "github.com/rackspace/gophercloud" tokens2 "github.com/rackspace/gophercloud/openstack/identity/v2/tokens" @@ -64,8 +65,8 @@ func AuthenticatedClient(options gophercloud.AuthOptions) (*gophercloud.Provider // Authenticate or re-authenticate against the most recent identity service supported at the provided endpoint. func Authenticate(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error { versions := []*utils.Version{ - &utils.Version{ID: v20, Priority: 20, Suffix: "/v2.0/"}, - &utils.Version{ID: v30, Priority: 30, Suffix: "/v3/"}, + {ID: v20, Priority: 20, Suffix: "/v2.0/"}, + {ID: v30, Priority: 30, Suffix: "/v3/"}, } chosen, endpoint, err := utils.ChooseVersion(client, versions) @@ -241,6 +242,26 @@ func NewBlockStorageV1(client *gophercloud.ProviderClient, eo gophercloud.Endpoi return &gophercloud.ServiceClient{ProviderClient: client, Endpoint: url}, nil } +// NewBlockStorageV2 creates a ServiceClient that may be used to access the v2 block storage service. +func NewBlockStorageV2(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) { + eo.ApplyDefaults("volume") + url, err := client.EndpointLocator(eo) + if err != nil { + return nil, err + } + + // Force using v2 API + if strings.Contains(url, "/v1") { + url = strings.Replace(url, "/v1", "/v2", -1) + } + + return &gophercloud.ServiceClient{ + ProviderClient: client, + Endpoint: url, + ResourceBase: url, + }, nil +} + // NewCDNV1 creates a ServiceClient that may be used to access the OpenStack v1 // CDN service. func NewCDNV1(client *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (*gophercloud.ServiceClient, error) {