images service v2 port from rackpsace/gophercloud (#171)
* CheckByteArrayEquals funcs
* direct port from rackspace/gophercloud with minor additions to get unit tests passing
* new package for uploading and downloading image data
* updates to make imageservice v2 consistent with the rest of gophercloud/gophercloud
* add image service v2 client
diff --git a/openstack/imageservice/v2/images/requests.go b/openstack/imageservice/v2/images/requests.go
new file mode 100644
index 0000000..32f09ee
--- /dev/null
+++ b/openstack/imageservice/v2/images/requests.go
@@ -0,0 +1,238 @@
+package images
+
+import (
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// ListOptsBuilder allows extensions to add additional parameters to the
+// List request.
+type ListOptsBuilder interface {
+ ToImageListQuery() (string, error)
+}
+
+// ListOpts allows the filtering and sorting of paginated collections through
+// the API. Filtering is achieved by passing in struct field values that map to
+// the server attributes you want to see returned. Marker and Limit are used
+// for pagination.
+//http://developer.openstack.org/api-ref-image-v2.html
+type ListOpts struct {
+ // Integer value for the limit of values to return.
+ Limit int `q:"limit"`
+
+ // UUID of the server at which you want to set a marker.
+ Marker string `q:"marker"`
+
+ Name string `q:"name"`
+ Visibility ImageVisibility `q:"visibility"`
+ MemberStatus ImageMemberStatus `q:"member_status"`
+ Owner string `q:"owner"`
+ Status ImageStatus `q:"status"`
+ SizeMin int64 `q:"size_min"`
+ SizeMax int64 `q:"size_max"`
+ SortKey string `q:"sort_key"`
+ SortDir string `q:"sort_dir"`
+ Tag string `q:"tag"`
+}
+
+// ToImageListQuery formats a ListOpts into a query string.
+func (opts ListOpts) ToImageListQuery() (string, error) {
+ q, err := gophercloud.BuildQueryString(opts)
+ return q.String(), err
+}
+
+// List implements image list request
+func List(c *gophercloud.ServiceClient, opts ListOptsBuilder) pagination.Pager {
+ url := listURL(c)
+ if opts != nil {
+ query, err := opts.ToImageListQuery()
+ if err != nil {
+ return pagination.Pager{Err: err}
+ }
+ url += query
+ }
+ return pagination.NewPager(c, url, func(r pagination.PageResult) pagination.Page {
+ return ImagePage{pagination.LinkedPageBase{PageResult: r}}
+ })
+}
+
+// CreateOptsBuilder describes struct types that can be accepted by the Create call.
+// The CreateOpts struct in this package does.
+type CreateOptsBuilder interface {
+ // Returns value that can be passed to json.Marshal
+ ToImageCreateMap() (map[string]interface{}, error)
+}
+
+// CreateOpts implements CreateOptsBuilder
+type CreateOpts struct {
+ // Name is the name of the new image.
+ Name string `json:"name" required:"true"`
+
+ // Id is the the image ID.
+ ID string `json:"id,omitempty"`
+
+ // Visibility defines who can see/use the image.
+ Visibility *ImageVisibility `json:"visibility,omitempty"`
+
+ // Tags is a set of image tags.
+ Tags []string `json:"tags,omitempty"`
+
+ // ContainerFormat is the format of the
+ // container. Valid values are ami, ari, aki, bare, and ovf.
+ ContainerFormat string `json:"container_format,omitempty"`
+
+ // DiskFormat is the format of the disk. If set,
+ // valid values are ami, ari, aki, vhd, vmdk, raw, qcow2, vdi,
+ // and iso.
+ DiskFormat string `json:"disk_format,omitempty"`
+
+ // MinDisk is the amount of disk space in
+ // GB that is required to boot the image.
+ MinDisk int `json:"min_disk,omitempty"`
+
+ // MinRAM is the amount of RAM in MB that
+ // is required to boot the image.
+ MinRAM int `json:"min_ram,omitempty"`
+
+ // protected is whether the image is not deletable.
+ Protected *bool `json:"protected,omitempty"`
+
+ // properties is a set of properties, if any, that
+ // are associated with the image.
+ Properties map[string]string `json:"-,omitempty"`
+}
+
+// ToImageCreateMap assembles a request body based on the contents of
+// a CreateOpts.
+func (opts CreateOpts) ToImageCreateMap() (map[string]interface{}, error) {
+ b, err := gophercloud.BuildRequestBody(opts, "")
+ if err != nil {
+ return nil, err
+ }
+
+ if opts.Properties != nil {
+ for k, v := range opts.Properties {
+ b[k] = v
+ }
+ }
+ return b, nil
+}
+
+// Create implements create image request
+func Create(client *gophercloud.ServiceClient, opts CreateOptsBuilder) (r CreateResult) {
+ b, err := opts.ToImageCreateMap()
+ if err != nil {
+ r.Err = err
+ return r
+ }
+ _, r.Err = client.Post(createURL(client), b, &r.Body, &gophercloud.RequestOpts{OkCodes: []int{201}})
+ return
+}
+
+// Delete implements image delete request
+func Delete(client *gophercloud.ServiceClient, id string) (r DeleteResult) {
+ _, r.Err = client.Delete(deleteURL(client, id), nil)
+ return
+}
+
+// Get implements image get request
+func Get(client *gophercloud.ServiceClient, id string) (r GetResult) {
+ _, r.Err = client.Get(getURL(client, id), &r.Body, nil)
+ return
+}
+
+// Update implements image updated request
+func Update(client *gophercloud.ServiceClient, id string, opts UpdateOptsBuilder) (r UpdateResult) {
+ b, err := opts.ToImageUpdateMap()
+ if err != nil {
+ r.Err = err
+ return r
+ }
+ _, r.Err = client.Patch(updateURL(client, id), b, &r.Body, &gophercloud.RequestOpts{
+ OkCodes: []int{200},
+ MoreHeaders: map[string]string{"Content-Type": "application/openstack-images-v2.1-json-patch"},
+ })
+ return
+}
+
+// UpdateOptsBuilder implements UpdateOptsBuilder
+type UpdateOptsBuilder interface {
+ // returns value implementing json.Marshaler which when marshaled matches the patch schema:
+ // http://specs.openstack.org/openstack/glance-specs/specs/api/v2/http-patch-image-api-v2.html
+ ToImageUpdateMap() ([]interface{}, error)
+}
+
+// UpdateOpts implements UpdateOpts
+type UpdateOpts []Patch
+
+// ToImageUpdateMap builder
+func (opts UpdateOpts) ToImageUpdateMap() ([]interface{}, error) {
+ m := make([]interface{}, len(opts))
+ for i, patch := range opts {
+ patchJSON := patch.ToImagePatchMap()
+ m[i] = patchJSON
+ }
+ return m, nil
+}
+
+// Patch represents a single update to an existing image. Multiple updates to an image can be
+// submitted at the same time.
+type Patch interface {
+ ToImagePatchMap() map[string]interface{}
+}
+
+// UpdateVisibility updated visibility
+type UpdateVisibility struct {
+ Visibility ImageVisibility
+}
+
+// ToImagePatchMap builder
+func (u UpdateVisibility) ToImagePatchMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/visibility",
+ "value": u.Visibility,
+ }
+}
+
+// ReplaceImageName implements Patch
+type ReplaceImageName struct {
+ NewName string
+}
+
+// ToImagePatchMap builder
+func (r ReplaceImageName) ToImagePatchMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/name",
+ "value": r.NewName,
+ }
+}
+
+// ReplaceImageChecksum implements Patch
+type ReplaceImageChecksum struct {
+ Checksum string
+}
+
+// ReplaceImageChecksum builder
+func (rc ReplaceImageChecksum) ToImagePatchMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/checksum",
+ "value": rc.Checksum,
+ }
+}
+
+// ReplaceImageTags implements Patch
+type ReplaceImageTags struct {
+ NewTags []string
+}
+
+// ToImagePatchMap builder
+func (r ReplaceImageTags) ToImagePatchMap() map[string]interface{} {
+ return map[string]interface{}{
+ "op": "replace",
+ "path": "/tags",
+ "value": r.NewTags,
+ }
+}
diff --git a/openstack/imageservice/v2/images/results.go b/openstack/imageservice/v2/images/results.go
new file mode 100644
index 0000000..653c68c
--- /dev/null
+++ b/openstack/imageservice/v2/images/results.go
@@ -0,0 +1,176 @@
+package images
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "time"
+
+ "github.com/gophercloud/gophercloud"
+ "github.com/gophercloud/gophercloud/pagination"
+)
+
+// Image model
+// Does not include the literal image data; just metadata.
+// returned by listing images, and by fetching a specific image.
+type Image struct {
+ // ID is the image UUID
+ ID string `json:"id"`
+
+ // Name is the human-readable display name for the image.
+ Name string `json:"name"`
+
+ // Status is the image status. It can be "queued" or "active"
+ // See imageservice/v2/images/type.go
+ Status ImageStatus `json:"status"`
+
+ // Tags is a list of image tags. Tags are arbitrarily defined strings
+ // attached to an image.
+ Tags []string `json:"tags"`
+
+ // ContainerFormat is the format of the container.
+ // Valid values are ami, ari, aki, bare, and ovf.
+ ContainerFormat string `json:"container_format"`
+
+ // DiskFormat is the format of the disk.
+ // If set, valid values are ami, ari, aki, vhd, vmdk, raw, qcow2, vdi, and iso.
+ DiskFormat string `json:"disk_format"`
+
+ // MinDiskGigabytes is the amount of disk space in GB that is required to boot the image.
+ MinDiskGigabytes int `json:"min_disk"`
+
+ // MinRAMMegabytes [optional] is the amount of RAM in MB that is required to boot the image.
+ MinRAMMegabytes int `json:"min_ram"`
+
+ // Owner is the tenant the image belongs to.
+ Owner string `json:"owner"`
+
+ // Protected is whether the image is deletable or not.
+ Protected bool `json:"protected"`
+
+ // Visibility defines who can see/use the image.
+ Visibility ImageVisibility `json:"visibility"`
+
+ // Checksum is the checksum of the data that's associated with the image
+ Checksum string `json:"checksum"`
+
+ // SizeBytes is the size of the data that's associated with the image.
+ SizeBytes int64 `json:"size"`
+
+ // Metadata is a set of metadata associated with the image.
+ // Image metadata allow for meaningfully define the image properties
+ // and tags. See http://docs.openstack.org/developer/glance/metadefs-concepts.html.
+ Metadata map[string]string `json:"metadata"`
+
+ // Properties is a set of key-value pairs, if any, that are associated with the image.
+ Properties map[string]string `json:"properties"`
+
+ // CreatedAt is the date when the image has been created.
+ CreatedAt time.Time `json:"-"`
+
+ // UpdatedAt is the date when the last change has been made to the image or it's properties.
+ UpdatedAt time.Time `json:"-"`
+
+ // File is the trailing path after the glance endpoint that represent the location
+ // of the image or the path to retrieve it.
+ File string `json:"file"`
+
+ // Schema is the path to the JSON-schema that represent the image or image entity.
+ Schema string `json:"schema"`
+}
+
+func (s *Image) UnmarshalJSON(b []byte) error {
+ type tmp Image
+ var p *struct {
+ tmp
+ SizeBytes interface{} `json:"size"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ }
+ err := json.Unmarshal(b, &p)
+ if err != nil {
+ return err
+ }
+ *s = Image(p.tmp)
+
+ switch t := p.SizeBytes.(type) {
+ case nil:
+ return nil
+ case float32:
+ s.SizeBytes = int64(t)
+ case float64:
+ s.SizeBytes = int64(t)
+ default:
+ return fmt.Errorf("Unknown type for SizeBytes: %v (value: %v)", reflect.TypeOf(t), t)
+ }
+
+ s.CreatedAt, err = time.Parse(time.RFC3339, p.CreatedAt)
+ if err != nil {
+ return err
+ }
+ s.UpdatedAt, err = time.Parse(time.RFC3339, p.UpdatedAt)
+ return err
+}
+
+type commonResult struct {
+ gophercloud.Result
+}
+
+// Extract interprets any commonResult as an Image.
+func (r commonResult) Extract() (*Image, error) {
+ var s *Image
+ err := r.ExtractInto(&s)
+ return s, err
+}
+
+// CreateResult represents the result of a Create operation
+type CreateResult struct {
+ commonResult
+}
+
+// UpdateResult represents the result of an Update operation
+type UpdateResult struct {
+ commonResult
+}
+
+// GetResult represents the result of a Get operation
+type GetResult struct {
+ commonResult
+}
+
+//DeleteResult model
+type DeleteResult struct {
+ gophercloud.ErrResult
+}
+
+// ImagePage represents page
+type ImagePage struct {
+ pagination.LinkedPageBase
+}
+
+// IsEmpty returns true if a page contains no Images results.
+func (r ImagePage) IsEmpty() (bool, error) {
+ images, err := ExtractImages(r)
+ return len(images) == 0, err
+}
+
+// NextPageURL uses the response's embedded link reference to navigate to the next page of results.
+func (r ImagePage) NextPageURL() (string, error) {
+ var s struct {
+ Next string `json:"next"`
+ }
+ err := r.ExtractInto(&s)
+ if err != nil {
+ return "", err
+ }
+ return nextPageURL(r.URL.String(), s.Next), nil
+}
+
+// ExtractImages interprets the results of a single page from a List() call, producing a slice of Image entities.
+func ExtractImages(r pagination.Page) ([]Image, error) {
+ var s struct {
+ Images []Image `json:"images"`
+ }
+ err := (r.(ImagePage)).ExtractInto(&s)
+ return s.Images, err
+}
diff --git a/openstack/imageservice/v2/images/testing/fixtures.go b/openstack/imageservice/v2/images/testing/fixtures.go
new file mode 100644
index 0000000..1754407
--- /dev/null
+++ b/openstack/imageservice/v2/images/testing/fixtures.go
@@ -0,0 +1,328 @@
+package testing
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+type imageEntry struct {
+ ID string
+ JSON string
+}
+
+// HandleImageListSuccessfully test setup
+func HandleImageListSuccessfully(t *testing.T) {
+
+ images := make([]imageEntry, 3)
+
+ images[0] = imageEntry{"cirros-0.3.4-x86_64-uec",
+ `{
+ "status": "active",
+ "name": "cirros-0.3.4-x86_64-uec",
+ "tags": [],
+ "kernel_id": "e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+ "container_format": "ami",
+ "created_at": "2015-07-15T11:43:35Z",
+ "ramdisk_id": "8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+ "disk_format": "ami",
+ "updated_at": "2015-07-15T11:43:35Z",
+ "visibility": "public",
+ "self": "/v2/images/07aa21a9-fa1a-430e-9a33-185be5982431",
+ "min_disk": 0,
+ "protected": false,
+ "id": "07aa21a9-fa1a-430e-9a33-185be5982431",
+ "size": 25165824,
+ "file": "/v2/images/07aa21a9-fa1a-430e-9a33-185be5982431/file",
+ "checksum": "eb9139e4942121f22bbc2afc0400b2a4",
+ "owner": "cba624273b8344e59dd1fd18685183b0",
+ "virtual_size": null,
+ "min_ram": 0,
+ "schema": "/v2/schemas/image"
+ }`}
+ images[1] = imageEntry{"cirros-0.3.4-x86_64-uec-ramdisk",
+ `{
+ "status": "active",
+ "name": "cirros-0.3.4-x86_64-uec-ramdisk",
+ "tags": [],
+ "container_format": "ari",
+ "created_at": "2015-07-15T11:43:32Z",
+ "size": 3740163,
+ "disk_format": "ari",
+ "updated_at": "2015-07-15T11:43:32Z",
+ "visibility": "public",
+ "self": "/v2/images/8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+ "min_disk": 0,
+ "protected": false,
+ "id": "8c64f48a-45a3-4eaa-adff-a8106b6c005b",
+ "file": "/v2/images/8c64f48a-45a3-4eaa-adff-a8106b6c005b/file",
+ "checksum": "be575a2b939972276ef675752936977f",
+ "owner": "cba624273b8344e59dd1fd18685183b0",
+ "virtual_size": null,
+ "min_ram": 0,
+ "schema": "/v2/schemas/image"
+ }`}
+ images[2] = imageEntry{"cirros-0.3.4-x86_64-uec-kernel",
+ `{
+ "status": "active",
+ "name": "cirros-0.3.4-x86_64-uec-kernel",
+ "tags": [],
+ "container_format": "aki",
+ "created_at": "2015-07-15T11:43:29Z",
+ "size": 4979632,
+ "disk_format": "aki",
+ "updated_at": "2015-07-15T11:43:30Z",
+ "visibility": "public",
+ "self": "/v2/images/e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+ "min_disk": 0,
+ "protected": false,
+ "id": "e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4",
+ "file": "/v2/images/e1b6edd4-bd9b-40ac-b010-8a6c16de4ba4/file",
+ "checksum": "8a40c862b5735975d82605c1dd395796",
+ "owner": "cba624273b8344e59dd1fd18685183b0",
+ "virtual_size": null,
+ "min_ram": 0,
+ "schema": "/v2/schemas/image"
+ }`}
+
+ th.Mux.HandleFunc("/images", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.Header().Add("Content-Type", "application/json")
+
+ w.WriteHeader(http.StatusOK)
+
+ limit := 10
+ var err error
+ if r.FormValue("limit") != "" {
+ limit, err = strconv.Atoi(r.FormValue("limit"))
+ if err != nil {
+ t.Errorf("Error value for 'limit' parameter %v (error: %v)", r.FormValue("limit"), err)
+ }
+
+ }
+
+ marker := ""
+ newMarker := ""
+
+ if r.Form["marker"] != nil {
+ marker = r.Form["marker"][0]
+ }
+
+ t.Logf("limit = %v marker = %v", limit, marker)
+
+ selected := 0
+ addNext := false
+ var imageJSON []string
+
+ fmt.Fprintf(w, `{"images": [`)
+
+ for _, i := range images {
+ if marker == "" || addNext {
+ t.Logf("Adding image %v to page", i.ID)
+ imageJSON = append(imageJSON, i.JSON)
+ newMarker = i.ID
+ selected++
+ } else {
+ if strings.Contains(i.JSON, marker) {
+ addNext = true
+ }
+ }
+
+ if selected == limit {
+ break
+ }
+ }
+ t.Logf("Writing out %v image(s)", len(imageJSON))
+ fmt.Fprintf(w, strings.Join(imageJSON, ","))
+
+ fmt.Fprintf(w, `],
+ "next": "/images?marker=%s&limit=%v",
+ "schema": "/schemas/images",
+ "first": "/images?limit=%v"}`, newMarker, limit, limit)
+
+ })
+}
+
+// HandleImageCreationSuccessfully test setup
+func HandleImageCreationSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+ th.TestJSONRequest(t, r, `{
+ "id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "name": "Ubuntu 12.10",
+ "tags": [
+ "ubuntu",
+ "quantal"
+ ]
+ }`)
+
+ w.WriteHeader(http.StatusCreated)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "status": "queued",
+ "name": "Ubuntu 12.10",
+ "protected": false,
+ "tags": ["ubuntu","quantal"],
+ "container_format": "bare",
+ "created_at": "2014-11-11T20:47:55Z",
+ "disk_format": "qcow2",
+ "updated_at": "2014-11-11T20:47:55Z",
+ "visibility": "private",
+ "self": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "min_disk": 0,
+ "protected": false,
+ "id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "file": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd/file",
+ "owner": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "min_ram": 0,
+ "schema": "/v2/schemas/image",
+ "size": 0,
+ "checksum": "",
+ "virtual_size": 0
+ }`)
+ })
+}
+
+// HandleImageCreationSuccessfullyNulls test setup
+// JSON null values could be also returned according to behaviour https://bugs.launchpad.net/glance/+bug/1481512
+func HandleImageCreationSuccessfullyNulls(t *testing.T) {
+ th.Mux.HandleFunc("/images", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "POST")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+ th.TestJSONRequest(t, r, `{
+ "id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "name": "Ubuntu 12.10",
+ "tags": [
+ "ubuntu",
+ "quantal"
+ ]
+ }`)
+
+ w.WriteHeader(http.StatusCreated)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "status": "queued",
+ "name": "Ubuntu 12.10",
+ "protected": false,
+ "tags": ["ubuntu","quantal"],
+ "container_format": "bare",
+ "created_at": "2014-11-11T20:47:55Z",
+ "disk_format": "qcow2",
+ "updated_at": "2014-11-11T20:47:55Z",
+ "visibility": "private",
+ "self": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "min_disk": 0,
+ "protected": false,
+ "id": "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ "file": "/v2/images/e7db3b45-8db7-47ad-8109-3fb55c2c24fd/file",
+ "owner": "b4eedccc6fb74fa8a7ad6b08382b852b",
+ "min_ram": 0,
+ "schema": "/v2/schemas/image",
+ "size": null,
+ "checksum": null,
+ "virtual_size": null
+ }`)
+ })
+}
+
+// HandleImageGetSuccessfully test setup
+func HandleImageGetSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "GET")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.WriteHeader(http.StatusOK)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "status": "active",
+ "name": "cirros-0.3.2-x86_64-disk",
+ "tags": [],
+ "container_format": "bare",
+ "created_at": "2014-05-05T17:15:10Z",
+ "disk_format": "qcow2",
+ "updated_at": "2014-05-05T17:15:11Z",
+ "visibility": "public",
+ "self": "/v2/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+ "min_disk": 0,
+ "protected": false,
+ "id": "1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+ "file": "/v2/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27/file",
+ "checksum": "64d7c1cd2b6f60c92c14662941cb7913",
+ "owner": "5ef70662f8b34079a6eddb8da9d75fe8",
+ "size": 13167616,
+ "min_ram": 0,
+ "schema": "/v2/schemas/image",
+ "virtual_size": "None"
+ }`)
+ })
+}
+
+// HandleImageDeleteSuccessfully test setup
+func HandleImageDeleteSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/1bea47ed-f6a9-463b-b423-14b9cca9ad27", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "DELETE")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ w.WriteHeader(http.StatusNoContent)
+ })
+}
+
+// HandleImageUpdateSuccessfully setup
+func HandleImageUpdateSuccessfully(t *testing.T) {
+ th.Mux.HandleFunc("/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea", func(w http.ResponseWriter, r *http.Request) {
+ th.TestMethod(t, r, "PATCH")
+ th.TestHeader(t, r, "X-Auth-Token", fakeclient.TokenID)
+
+ th.TestJSONRequest(t, r, `[
+ {
+ "op": "replace",
+ "path": "/name",
+ "value": "Fedora 17"
+ },
+ {
+ "op": "replace",
+ "path": "/tags",
+ "value": [
+ "fedora",
+ "beefy"
+ ]
+ }
+ ]`)
+
+ th.AssertEquals(t, "application/openstack-images-v2.1-json-patch", r.Header.Get("Content-Type"))
+
+ w.WriteHeader(http.StatusOK)
+ w.Header().Add("Content-Type", "application/json")
+ fmt.Fprintf(w, `{
+ "id": "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "name": "Fedora 17",
+ "status": "active",
+ "visibility": "public",
+ "size": 2254249,
+ "checksum": "2cec138d7dae2aa59038ef8c9aec2390",
+ "tags": [
+ "fedora",
+ "beefy"
+ ],
+ "created_at": "2012-08-10T19:23:50Z",
+ "updated_at": "2012-08-12T11:11:33Z",
+ "self": "/v2/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ "file": "/v2/images/da3b75d9-3f4a-40e7-8a2c-bfab23927dea/file",
+ "schema": "/v2/schemas/image",
+ "owner": "",
+ "min_ram": 0,
+ "min_disk": 0,
+ "disk_format": "",
+ "virtual_size": 0,
+ "container_format": ""
+ }`)
+ })
+}
diff --git a/openstack/imageservice/v2/images/testing/requests_test.go b/openstack/imageservice/v2/images/testing/requests_test.go
new file mode 100644
index 0000000..788eec4
--- /dev/null
+++ b/openstack/imageservice/v2/images/testing/requests_test.go
@@ -0,0 +1,259 @@
+package testing
+
+import (
+ "testing"
+
+ "github.com/gophercloud/gophercloud/openstack/imageservice/v2/images"
+ "github.com/gophercloud/gophercloud/pagination"
+ th "github.com/gophercloud/gophercloud/testhelper"
+ fakeclient "github.com/gophercloud/gophercloud/testhelper/client"
+)
+
+func TestListImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageListSuccessfully(t)
+
+ t.Logf("Test setup %+v\n", th.Server)
+
+ t.Logf("Id\tName\tOwner\tChecksum\tSizeBytes")
+
+ pager := images.List(fakeclient.ServiceClient(), images.ListOpts{Limit: 1})
+ t.Logf("Pager state %v", pager)
+ count, pages := 0, 0
+ err := pager.EachPage(func(page pagination.Page) (bool, error) {
+ pages++
+ t.Logf("Page %v", page)
+ images, err := images.ExtractImages(page)
+ if err != nil {
+ return false, err
+ }
+
+ for _, i := range images {
+ t.Logf("%s\t%s\t%s\t%s\t%v\t\n", i.ID, i.Name, i.Owner, i.Checksum, i.SizeBytes)
+ count++
+ }
+
+ return true, nil
+ })
+ th.AssertNoErr(t, err)
+
+ t.Logf("--------\n%d images listed on %d pages.\n", count, pages)
+ th.AssertEquals(t, 3, pages)
+ th.AssertEquals(t, 3, count)
+}
+
+func TestCreateImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageCreationSuccessfully(t)
+
+ id := "e7db3b45-8db7-47ad-8109-3fb55c2c24fd"
+ name := "Ubuntu 12.10"
+
+ actualImage, err := images.Create(fakeclient.ServiceClient(), images.CreateOpts{
+ ID: id,
+ Name: name,
+ Tags: []string{"ubuntu", "quantal"},
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ containerFormat := "bare"
+ diskFormat := "qcow2"
+ owner := "b4eedccc6fb74fa8a7ad6b08382b852b"
+ minDiskGigabytes := 0
+ minRAMMegabytes := 0
+ file := actualImage.File
+ createdDate := actualImage.CreatedAt
+ lastUpdate := actualImage.UpdatedAt
+ schema := "/v2/schemas/image"
+
+ expectedImage := images.Image{
+ ID: "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ Name: "Ubuntu 12.10",
+ Tags: []string{"ubuntu", "quantal"},
+
+ Status: images.ImageStatusQueued,
+
+ ContainerFormat: containerFormat,
+ DiskFormat: diskFormat,
+
+ MinDiskGigabytes: minDiskGigabytes,
+ MinRAMMegabytes: minRAMMegabytes,
+
+ Owner: owner,
+
+ Visibility: images.ImageVisibilityPrivate,
+ File: file,
+ CreatedAt: createdDate,
+ UpdatedAt: lastUpdate,
+ Schema: schema,
+ }
+
+ th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestCreateImageNulls(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageCreationSuccessfullyNulls(t)
+
+ id := "e7db3b45-8db7-47ad-8109-3fb55c2c24fd"
+ name := "Ubuntu 12.10"
+
+ actualImage, err := images.Create(fakeclient.ServiceClient(), images.CreateOpts{
+ ID: id,
+ Name: name,
+ Tags: []string{"ubuntu", "quantal"},
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ containerFormat := "bare"
+ diskFormat := "qcow2"
+ owner := "b4eedccc6fb74fa8a7ad6b08382b852b"
+ minDiskGigabytes := 0
+ minRAMMegabytes := 0
+ file := actualImage.File
+ createdDate := actualImage.CreatedAt
+ lastUpdate := actualImage.UpdatedAt
+ schema := "/v2/schemas/image"
+
+ expectedImage := images.Image{
+ ID: "e7db3b45-8db7-47ad-8109-3fb55c2c24fd",
+ Name: "Ubuntu 12.10",
+ Tags: []string{"ubuntu", "quantal"},
+
+ Status: images.ImageStatusQueued,
+
+ ContainerFormat: containerFormat,
+ DiskFormat: diskFormat,
+
+ MinDiskGigabytes: minDiskGigabytes,
+ MinRAMMegabytes: minRAMMegabytes,
+
+ Owner: owner,
+
+ Visibility: images.ImageVisibilityPrivate,
+ File: file,
+ CreatedAt: createdDate,
+ UpdatedAt: lastUpdate,
+ Schema: schema,
+ }
+
+ th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestGetImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageGetSuccessfully(t)
+
+ actualImage, err := images.Get(fakeclient.ServiceClient(), "1bea47ed-f6a9-463b-b423-14b9cca9ad27").Extract()
+
+ th.AssertNoErr(t, err)
+
+ checksum := "64d7c1cd2b6f60c92c14662941cb7913"
+ sizeBytes := int64(13167616)
+ containerFormat := "bare"
+ diskFormat := "qcow2"
+ minDiskGigabytes := 0
+ minRAMMegabytes := 0
+ owner := "5ef70662f8b34079a6eddb8da9d75fe8"
+ file := actualImage.File
+ createdDate := actualImage.CreatedAt
+ lastUpdate := actualImage.UpdatedAt
+ schema := "/v2/schemas/image"
+
+ expectedImage := images.Image{
+ ID: "1bea47ed-f6a9-463b-b423-14b9cca9ad27",
+ Name: "cirros-0.3.2-x86_64-disk",
+ Tags: []string{},
+
+ Status: images.ImageStatusActive,
+
+ ContainerFormat: containerFormat,
+ DiskFormat: diskFormat,
+
+ MinDiskGigabytes: minDiskGigabytes,
+ MinRAMMegabytes: minRAMMegabytes,
+
+ Owner: owner,
+
+ Protected: false,
+ Visibility: images.ImageVisibilityPublic,
+
+ Checksum: checksum,
+ SizeBytes: sizeBytes,
+ File: file,
+ CreatedAt: createdDate,
+ UpdatedAt: lastUpdate,
+ Schema: schema,
+ }
+
+ th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
+
+func TestDeleteImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageDeleteSuccessfully(t)
+
+ result := images.Delete(fakeclient.ServiceClient(), "1bea47ed-f6a9-463b-b423-14b9cca9ad27")
+ th.AssertNoErr(t, result.Err)
+}
+
+func TestUpdateImage(t *testing.T) {
+ th.SetupHTTP()
+ defer th.TeardownHTTP()
+
+ HandleImageUpdateSuccessfully(t)
+
+ actualImage, err := images.Update(fakeclient.ServiceClient(), "da3b75d9-3f4a-40e7-8a2c-bfab23927dea", images.UpdateOpts{
+ images.ReplaceImageName{NewName: "Fedora 17"},
+ images.ReplaceImageTags{NewTags: []string{"fedora", "beefy"}},
+ }).Extract()
+
+ th.AssertNoErr(t, err)
+
+ sizebytes := int64(2254249)
+ checksum := "2cec138d7dae2aa59038ef8c9aec2390"
+ file := actualImage.File
+ createdDate := actualImage.CreatedAt
+ lastUpdate := actualImage.UpdatedAt
+ schema := "/v2/schemas/image"
+
+ expectedImage := images.Image{
+ ID: "da3b75d9-3f4a-40e7-8a2c-bfab23927dea",
+ Name: "Fedora 17",
+ Status: images.ImageStatusActive,
+ Visibility: images.ImageVisibilityPublic,
+
+ SizeBytes: sizebytes,
+ Checksum: checksum,
+
+ Tags: []string{
+ "fedora",
+ "beefy",
+ },
+
+ Owner: "",
+ MinRAMMegabytes: 0,
+ MinDiskGigabytes: 0,
+
+ DiskFormat: "",
+ ContainerFormat: "",
+ File: file,
+ CreatedAt: createdDate,
+ UpdatedAt: lastUpdate,
+ Schema: schema,
+ }
+
+ th.AssertDeepEquals(t, &expectedImage, actualImage)
+}
diff --git a/openstack/imageservice/v2/images/types.go b/openstack/imageservice/v2/images/types.go
new file mode 100644
index 0000000..086e7e5
--- /dev/null
+++ b/openstack/imageservice/v2/images/types.go
@@ -0,0 +1,75 @@
+package images
+
+// ImageStatus image statuses
+// http://docs.openstack.org/developer/glance/statuses.html
+type ImageStatus string
+
+const (
+ // ImageStatusQueued is a status for an image which identifier has
+ // been reserved for an image in the image registry.
+ ImageStatusQueued ImageStatus = "queued"
+
+ // ImageStatusSaving denotes that an image’s raw data is currently being uploaded to Glance
+ ImageStatusSaving ImageStatus = "saving"
+
+ // ImageStatusActive denotes an image that is fully available in Glance.
+ ImageStatusActive ImageStatus = "active"
+
+ // ImageStatusKilled denotes that an error occurred during the uploading
+ // of an image’s data, and that the image is not readable.
+ ImageStatusKilled ImageStatus = "killed"
+
+ // ImageStatusDeleted is used for an image that is no longer available to use.
+ // The image information is retained in the image registry.
+ ImageStatusDeleted ImageStatus = "deleted"
+
+ // ImageStatusPendingDelete is similar to Delete, but the image is not yet deleted.
+ ImageStatusPendingDelete ImageStatus = "pending_delete"
+
+ // ImageStatusDeactivated denotes that access to image data is not allowed to any non-admin user.
+ ImageStatusDeactivated ImageStatus = "deactivated"
+)
+
+// ImageVisibility denotes an image that is fully available in Glance.
+// This occurs when the image data is uploaded, or the image size
+// is explicitly set to zero on creation.
+// According to design
+// https://wiki.openstack.org/wiki/Glance-v2-community-image-visibility-design
+type ImageVisibility string
+
+const (
+ // ImageVisibilityPublic all users
+ ImageVisibilityPublic ImageVisibility = "public"
+
+ // ImageVisibilityPrivate users with tenantId == tenantId(owner)
+ ImageVisibilityPrivate ImageVisibility = "private"
+
+ // ImageVisibilityShared images are visible to:
+ // - users with tenantId == tenantId(owner)
+ // - users with tenantId in the member-list of the image
+ // - users with tenantId in the member-list with member_status == 'accepted'
+ ImageVisibilityShared ImageVisibility = "shared"
+
+ // ImageVisibilityCommunity images:
+ // - all users can see and boot it
+ // - users with tenantId in the member-list of the image with member_status == 'accepted'
+ // have this image in their default image-list
+ ImageVisibilityCommunity ImageVisibility = "community"
+)
+
+// MemberStatus is a status for adding a new member (tenant) to an image member list.
+type ImageMemberStatus string
+
+const (
+ // ImageMemberStatusAccepted is the status for an accepted image member.
+ ImageMemberStatusAccepted ImageMemberStatus = "accepted"
+
+ // ImageMemberStatusPending shows that the member addition is pending
+ ImageMemberStatusPending ImageMemberStatus = "pending"
+
+ // ImageMemberStatusAccepted is the status for a rejected image member
+ ImageMemberStatusRejected ImageMemberStatus = "rejected"
+
+ // ImageMemberStatusAll
+ ImageMemberStatusAll ImageMemberStatus = "all"
+)
diff --git a/openstack/imageservice/v2/images/urls.go b/openstack/imageservice/v2/images/urls.go
new file mode 100644
index 0000000..58cb8f7
--- /dev/null
+++ b/openstack/imageservice/v2/images/urls.go
@@ -0,0 +1,44 @@
+package images
+
+import (
+ "strings"
+
+ "github.com/gophercloud/gophercloud"
+)
+
+// `listURL` is a pure function. `listURL(c)` is a URL for which a GET
+// request will respond with a list of images in the service `c`.
+func listURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("images")
+}
+
+func createURL(c *gophercloud.ServiceClient) string {
+ return c.ServiceURL("images")
+}
+
+// `imageURL(c,i)` is the URL for the image identified by ID `i` in
+// the service `c`.
+func imageURL(c *gophercloud.ServiceClient, imageID string) string {
+ return c.ServiceURL("images", imageID)
+}
+
+// `getURL(c,i)` is a URL for which a GET request will respond with
+// information about the image identified by ID `i` in the service
+// `c`.
+func getURL(c *gophercloud.ServiceClient, imageID string) string {
+ return imageURL(c, imageID)
+}
+
+func updateURL(c *gophercloud.ServiceClient, imageID string) string {
+ return imageURL(c, imageID)
+}
+
+func deleteURL(c *gophercloud.ServiceClient, imageID string) string {
+ return imageURL(c, imageID)
+}
+
+// builds next page full url based on current url
+func nextPageURL(currentURL string, next string) string {
+ base := currentURL[:strings.Index(currentURL, "/images")]
+ return base + next
+}