From 6cec6f67526e5c3c5e22465ef6d718c5136e1518 Mon Sep 17 00:00:00 2001 From: LBGarber Date: Mon, 3 May 2021 14:34:53 -0400 Subject: [PATCH] Add Machine Images Endpoints --- images.go | 119 ++++++++- .../integration/fixtures/TestUploadImage.yaml | 227 ++++++++++++++++++ test/integration/images_test.go | 49 +++- waitfor.go | 27 +++ 4 files changed, 409 insertions(+), 13 deletions(-) create mode 100644 test/integration/fixtures/TestUploadImage.yaml diff --git a/images.go b/images.go index 154683ef8..2f1f2c9ba 100644 --- a/images.go +++ b/images.go @@ -4,24 +4,37 @@ import ( "context" "encoding/json" "fmt" + "io" "time" + "github.com/go-resty/resty/v2" "github.com/linode/linodego/internal/parseabletime" ) +// ImageStatus represents the status of an Image. +type ImageStatus string + +// ImageStatus options start with ImageStatus and include all Image statuses +const ( + ImageStatusCreating ImageStatus = "creating" + ImageStatusPendingUpload ImageStatus = "pending_upload" + ImageStatusAvailable ImageStatus = "available" +) + // Image represents a deployable Image object for use with Linode Instances type Image struct { - ID string `json:"id"` - CreatedBy string `json:"created_by"` - Label string `json:"label"` - Description string `json:"description"` - Type string `json:"type"` - Vendor string `json:"vendor"` - Size int `json:"size"` - IsPublic bool `json:"is_public"` - Deprecated bool `json:"deprecated"` - Created *time.Time `json:"-"` - Expiry *time.Time `json:"-"` + ID string `json:"id"` + CreatedBy string `json:"created_by"` + Label string `json:"label"` + Description string `json:"description"` + Type string `json:"type"` + Vendor string `json:"vendor"` + Status ImageStatus `json:"status"` + Size int `json:"size"` + IsPublic bool `json:"is_public"` + Deprecated bool `json:"deprecated"` + Created *time.Time `json:"-"` + Expiry *time.Time `json:"-"` } // ImageCreateOptions fields are those accepted by CreateImage @@ -37,6 +50,27 @@ type ImageUpdateOptions struct { Description *string `json:"description,omitempty"` } +// ImageCreateUploadResponse fields are those returned by CreateImageUpload +type ImageCreateUploadResponse struct { + Image *Image `json:"image"` + UploadTo string `json:"upload_to"` +} + +// ImageCreateUploadOptions fields are those accepted by CreateImageUpload +type ImageCreateUploadOptions struct { + Region string `json:"region"` + Label string `json:"label"` + Description string `json:"description,omitempty"` +} + +// ImageUploadOptions fields are those accepted by UploadImage +type ImageUploadOptions struct { + Region string `json:"region"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Image io.Reader +} + // UnmarshalJSON implements the json.Unmarshaler interface func (i *Image) UnmarshalJSON(b []byte) error { type Mask Image @@ -175,3 +209,66 @@ func (c *Client) DeleteImage(ctx context.Context, id string) error { _, err = coupleAPIErrors(c.R(ctx).Delete(e)) return err } + +// CreateImageUpload creates an Image and an upload URL +func (c *Client) CreateImageUpload(ctx context.Context, createOpts ImageCreateUploadOptions) (image *Image, uploadURL string, err error) { + var body string + + e, err := c.Images.Endpoint() + if err != nil { + return nil, "", err + } + + e = fmt.Sprintf("%s/upload", e) + + req := c.R(ctx).SetResult(&ImageCreateUploadResponse{}) + + if bodyData, err := json.Marshal(createOpts); err == nil { + body = string(bodyData) + } else { + return nil, "", NewError(err) + } + + r, err := coupleAPIErrors(req. + SetBody(body). + Post(e)) + if err != nil { + return nil, "", err + } + + result, ok := r.Result().(*ImageCreateUploadResponse) + if !ok { + return nil, "", fmt.Errorf("failed to parse result") + } + + return result.Image, result.UploadTo, nil +} + +// UploadImageToURL uploads the given image to the given upload URL +func (c *Client) UploadImageToURL(ctx context.Context, uploadURL string, image io.Reader) error { + // We currently need to create a new resty instance in order to bypass the global transport. + // This is due to the S3 rejecting requests with Authorization headers, which are injected + // by the client and test suite. + req := resty.New().SetDebug(c.resty.Debug).R(). + SetHeader("Content-Type", "application/octet-stream"). + SetBody(image) + + _, err := coupleAPIErrors(req. + Put(uploadURL)) + + return err +} + +// UploadImage creates and uploads an image +func (c *Client) UploadImage(ctx context.Context, options ImageUploadOptions) (*Image, error) { + image, uploadURL, err := c.CreateImageUpload(ctx, ImageCreateUploadOptions{ + Label: options.Label, + Region: options.Region, + Description: options.Description, + }) + if err != nil { + return nil, err + } + + return image, c.UploadImageToURL(ctx, uploadURL, options.Image) +} diff --git a/test/integration/fixtures/TestUploadImage.yaml b/test/integration/fixtures/TestUploadImage.yaml new file mode 100644 index 000000000..02c0f0506 --- /dev/null +++ b/test/integration/fixtures/TestUploadImage.yaml @@ -0,0 +1,227 @@ +--- +version: 1 +interactions: +- request: + body: '{"region":"us-southeast","label":"linodego-test-image","description":"An image that does stuff."}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego https://github.com/linode/linodego + url: https://api.linode.com/v4beta/images/upload + method: POST + response: + body: '{"upload_to": "https://us-east-1.linodeobjects.com:443/linode-production-machine-images-uploads/12056370?Signature=iiRvVN6J0LbQjK4AiIpK7l3Pvpw%3D&Expires=1620318538&AWSAccessKeyID=SANITIZED", "image": {"id": "private/12056370", "label": "linodego-test-image", "description": "An image that does stuff.", "created": "2018-01-02T03:04:05", "updated": "2018-01-02T03:04:05", "size": 0, "created_by": "LGarber", "type": "manual", "is_public": false, "deprecated": false, "vendor": null, "expiry": null, "eol": null, "status": "pending_upload"}}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Cache-Control: + - private, max-age=60, s-maxage=60 + Content-Length: + - "551" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Server: + - nginx + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - images:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego https://github.com/linode/linodego + url: https://api.linode.com/v4beta/images/private/12056370 + method: GET + response: + body: '{"id": "private/12056370", "label": "linodego-test-image", "description": "An image that does stuff.", "created": "2018-01-02T03:04:05", "updated": "2018-01-02T03:04:05", "size": 0, "created_by": "LGarber", "type": "manual", "is_public": false, "deprecated": false, "vendor": null, "expiry": null, "eol": null, "status": "pending_upload"}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Cache-Control: + - private, max-age=0, s-maxage=0, no-cache, no-store + - private, max-age=60, s-maxage=60 + Content-Length: + - "338" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Server: + - nginx + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - images:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego https://github.com/linode/linodego + url: https://api.linode.com/v4beta/images/private/12056370 + method: GET + response: + body: '{"id": "private/12056370", "label": "linodego-test-image", "description": "An image that does stuff.", "created": "2018-01-02T03:04:05", "updated": "2018-01-02T03:04:05", "size": 1, "created_by": "LGarber", "type": "manual", "is_public": false, "deprecated": false, "vendor": null, "expiry": null, "eol": null, "status": "available"}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Cache-Control: + - private, max-age=0, s-maxage=0, no-cache, no-store + - private, max-age=60, s-maxage=60 + Content-Length: + - "333" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Server: + - nginx + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - images:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego https://github.com/linode/linodego + url: https://api.linode.com/v4beta/images/private/12056370 + method: DELETE + response: + body: '{}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Cache-Control: + - private, max-age=60, s-maxage=60 + Content-Length: + - "2" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Server: + - nginx + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - images:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "800" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" diff --git a/test/integration/images_test.go b/test/integration/images_test.go index bc9f81259..91d02d1ae 100644 --- a/test/integration/images_test.go +++ b/test/integration/images_test.go @@ -1,12 +1,19 @@ package integration import ( + "bytes" "context" - "testing" - + "github.com/dnaeon/go-vcr/recorder" . "github.com/linode/linodego" + "testing" ) +// testImageBytes is a minimal Gzipped image. +// This is necessary because the API will reject invalid images. +var testImageBytes = []byte{0x1f, 0x8b, 0x08, 0x08, 0xbd, 0x5c, 0x91, 0x60, +0x00, 0x03, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x69, 0x6d, 0x67, 0x00, 0x03, 0x00, +0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} + func TestGetImage_missing(t *testing.T) { client, teardown := createTestClient(t, "fixtures/TestGetImage_missing") defer teardown() @@ -50,3 +57,41 @@ func TestListImages(t *testing.T) { t.Errorf("Expected a list of images, but got none %v", i) } } + +func TestUploadImage(t *testing.T) { + client, teardown := createTestClient(t, "fixtures/TestUploadImage") + defer teardown() + + image, uploadURL, err := client.CreateImageUpload(context.Background(), ImageCreateUploadOptions{ + Region: "us-southeast", + Label: "linodego-test-image", + Description: "An image that does stuff.", + }) + if err != nil { + t.Errorf("Failed to create image upload: %v", err) + } + defer func() { + if err := client.DeleteImage(context.Background(), image.ID); err != nil { + t.Errorf("Failed to delete image %s: %v", image.ID, err) + } + }() + + if uploadURL == "" { + t.Errorf("Expected upload URL, got none") + } + + if _, err := client.WaitForImageStatus(context.Background(), image.ID, ImageStatusPendingUpload, 60); err != nil { + t.Errorf("Failed to wait for image pending upload status: %v", err) + } + + // Because this request currently bypasses the recorder, we should only run it when the recorder is recording + if testingMode != recorder.ModeReplaying { + if err := client.UploadImageToURL(context.Background(), uploadURL, bytes.NewReader(testImageBytes)); err != nil { + t.Errorf("failed to upload image: %v", err) + } + } + + if _, err := client.WaitForImageStatus(context.Background(), image.ID, ImageStatusAvailable, 240); err != nil { + t.Errorf("Failed to wait for image available upload status: %v", err) + } +} diff --git a/waitfor.go b/waitfor.go index b1c1aa634..0fda4b10b 100644 --- a/waitfor.go +++ b/waitfor.go @@ -409,3 +409,30 @@ func (client Client) WaitForEventFinished(ctx context.Context, id interface{}, e } } } + +// WaitForImageStatus waits for the Image to reach the desired state +// before returning. It will timeout with an error after timeoutSeconds. +func (client Client) WaitForImageStatus(ctx context.Context, imageID string, status ImageStatus, timeoutSeconds int) (*Image, error) { + ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second) + defer cancel() + + ticker := time.NewTicker(client.millisecondsPerPoll * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + image, err := client.GetImage(ctx, imageID) + if err != nil { + return image, err + } + complete := image.Status == status + + if complete { + return image, nil + } + case <-ctx.Done(): + return nil, fmt.Errorf("failed to wait for Image %s status %s: %s", imageID, status, ctx.Err()) + } + } +}