Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing ListSnapshots #286

Merged
merged 3 commits into from
May 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 86 additions & 2 deletions pkg/cloud/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ var (

// ErrAlreadyExists is returned when a resource is already existent.
ErrAlreadyExists = errors.New("Resource already exists")

// ErrMultiSnapshots is returned when multiple snapshots are found
// with the same ID
ErrMultiSnapshots = errors.New("Multiple snapshots with the same name found")

// ErrInvalidMaxResults is returned when a MaxResults pagination parameter is between 1 and 4
ErrInvalidMaxResults = errors.New("MaxResults parameter must be 0 or greater than or equal to 5")
)

// Disk represents a EBS volume
Expand Down Expand Up @@ -124,11 +131,23 @@ type Snapshot struct {
ReadyToUse bool
}

// ListSnapshotsResponse is the container for our snapshots along with a pagination token to pass back to the caller
type ListSnapshotsResponse struct {
Snapshots []*Snapshot
NextToken string
}

// SnapshotOptions represents parameters to create an EBS volume
type SnapshotOptions struct {
Tags map[string]string
}

// ec2ListSnapshotsResponse is a helper struct returned from the AWS API calling function to the main ListSnapshots function
type ec2ListSnapshotsResponse struct {
Snapshots []*ec2.Snapshot
NextToken *string
}

// EC2 abstracts aws.EC2 to facilitate its mocking.
// See https://docs.aws.amazon.com/sdk-for-go/api/service/ec2/ for details
type EC2 interface {
Expand Down Expand Up @@ -156,6 +175,7 @@ type Cloud interface {
CreateSnapshot(ctx context.Context, volumeID string, snapshotOptions *SnapshotOptions) (snapshot *Snapshot, err error)
DeleteSnapshot(ctx context.Context, snapshotID string) (success bool, err error)
GetSnapshotByName(ctx context.Context, name string) (snapshot *Snapshot, err error)
ListSnapshots(ctx context.Context, volumeID string, maxResults int64, nextToken string) (listSnapshotsResponse *ListSnapshotsResponse, err error)
}

type cloud struct {
Expand Down Expand Up @@ -542,6 +562,49 @@ func (c *cloud) GetSnapshotByName(ctx context.Context, name string) (snapshot *S
return c.ec2SnapshotResponseToStruct(ec2snapshot), nil
}

// ListSnapshots retrieves AWS EBS snapshots for an optionally specified volume ID. If maxResults is set, it will return up to maxResults snapshots. If there are more snapshots than maxResults,
// a next token value will be returned to the client as well. They can use this token with subsequent calls to retrieve the next page of results. If maxResults is not set (0),
// there will be no restriction up to 1000 results (https://docs.aws.amazon.com/sdk-for-go/api/service/ec2/#DescribeSnapshotsInput).
func (c *cloud) ListSnapshots(ctx context.Context, volumeID string, maxResults int64, nextToken string) (listSnapshotsResponse *ListSnapshotsResponse, err error) {
zacharya marked this conversation as resolved.
Show resolved Hide resolved
if maxResults > 0 && maxResults < 5 {
return nil, ErrInvalidMaxResults
}

describeSnapshotsInput := &ec2.DescribeSnapshotsInput{
MaxResults: aws.Int64(maxResults),
}

if len(nextToken) != 0 {
describeSnapshotsInput.NextToken = aws.String(nextToken)
}
if len(volumeID) != 0 {
describeSnapshotsInput.Filters = []*ec2.Filter{
{
Name: aws.String("volume-id"),
Values: []*string{aws.String(volumeID)},
},
}
}

ec2SnapshotsResponse, err := c.listSnapshots(ctx, describeSnapshotsInput)
if err != nil {
return nil, err
}
var snapshots []*Snapshot
for _, ec2Snapshot := range ec2SnapshotsResponse.Snapshots {
snapshots = append(snapshots, c.ec2SnapshotResponseToStruct(ec2Snapshot))
}

if len(snapshots) == 0 {
return nil, ErrNotFound
}

return &ListSnapshotsResponse{
Snapshots: snapshots,
NextToken: aws.StringValue(ec2SnapshotsResponse.NextToken),
}, nil
}

// Helper method converting EC2 snapshot type to the internal struct
func (c *cloud) ec2SnapshotResponseToStruct(ec2Snapshot *ec2.Snapshot) *Snapshot {
if ec2Snapshot == nil {
Expand Down Expand Up @@ -625,7 +688,6 @@ func (c *cloud) getInstance(ctx context.Context, nodeID string) (*ec2.Instance,
func (c *cloud) getSnapshot(ctx context.Context, request *ec2.DescribeSnapshotsInput) (*ec2.Snapshot, error) {
var snapshots []*ec2.Snapshot
var nextToken *string

for {
response, err := c.ec2.DescribeSnapshotsWithContext(ctx, request)
if err != nil {
Expand All @@ -640,14 +702,36 @@ func (c *cloud) getSnapshot(ctx context.Context, request *ec2.DescribeSnapshotsI
}

if l := len(snapshots); l > 1 {
return nil, errors.New("Multiple snapshots with the same name found")
return nil, ErrMultiSnapshots
} else if l < 1 {
return nil, ErrNotFound
}

return snapshots[0], nil
}

// listSnapshots returns all snapshots based from a request
func (c *cloud) listSnapshots(ctx context.Context, request *ec2.DescribeSnapshotsInput) (*ec2ListSnapshotsResponse, error) {
var snapshots []*ec2.Snapshot
var nextToken *string

response, err := c.ec2.DescribeSnapshotsWithContext(ctx, request)
if err != nil {
return nil, err
}

snapshots = append(snapshots, response.Snapshots...)

if response.NextToken != nil {
nextToken = response.NextToken
}

return &ec2ListSnapshotsResponse{
Snapshots: snapshots,
NextToken: nextToken,
}, nil
}

// waitForVolume waits for volume to be in the "available" state.
// On a random AWS account (shared among several developers) it took 4s on average.
func (c *cloud) waitForVolume(ctx context.Context, volumeID string) error {
Expand Down
212 changes: 212 additions & 0 deletions pkg/cloud/cloud_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package cloud

import (
"context"
"errors"
"fmt"
"strings"
"testing"
Expand Down Expand Up @@ -647,6 +648,217 @@ func TestGetSnapshotByName(t *testing.T) {
}
}

func TestListSnapshots(t *testing.T) {
testCases := []struct {
name string
testFunc func(t *testing.T)
}{
{
name: "success: normal",
testFunc: func(t *testing.T) {
expSnapshots := []*Snapshot{
{
SourceVolumeID: "snap-test-volume1",
SnapshotID: "snap-test-name1",
},
{
SourceVolumeID: "snap-test-volume2",
SnapshotID: "snap-test-name2",
},
}
ec2Snapshots := []*ec2.Snapshot{
{
SnapshotId: aws.String(expSnapshots[0].SnapshotID),
VolumeId: aws.String("snap-test-volume1"),
State: aws.String("completed"),
},
{
SnapshotId: aws.String(expSnapshots[1].SnapshotID),
VolumeId: aws.String("snap-test-volume2"),
State: aws.String("completed"),
},
}

mockCtl := gomock.NewController(t)
defer mockCtl.Finish()
mockEC2 := mocks.NewMockEC2(mockCtl)
c := newCloud(mockEC2)

ctx := context.Background()

mockEC2.EXPECT().DescribeSnapshotsWithContext(gomock.Eq(ctx), gomock.Any()).Return(&ec2.DescribeSnapshotsOutput{Snapshots: ec2Snapshots}, nil)

_, err := c.ListSnapshots(ctx, "", 0, "")
if err != nil {
t.Fatalf("ListSnapshots() failed: expected no error, got: %v", err)
}
},
},
{
name: "success: with volume ID",
testFunc: func(t *testing.T) {
sourceVolumeID := "snap-test-volume"
expSnapshots := []*Snapshot{
{
SourceVolumeID: sourceVolumeID,
SnapshotID: "snap-test-name1",
},
{
SourceVolumeID: sourceVolumeID,
SnapshotID: "snap-test-name2",
},
}
ec2Snapshots := []*ec2.Snapshot{
{
SnapshotId: aws.String(expSnapshots[0].SnapshotID),
VolumeId: aws.String(sourceVolumeID),
State: aws.String("completed"),
},
{
SnapshotId: aws.String(expSnapshots[1].SnapshotID),
VolumeId: aws.String(sourceVolumeID),
State: aws.String("completed"),
},
}

mockCtl := gomock.NewController(t)
defer mockCtl.Finish()
mockEC2 := mocks.NewMockEC2(mockCtl)
c := newCloud(mockEC2)

ctx := context.Background()

mockEC2.EXPECT().DescribeSnapshotsWithContext(gomock.Eq(ctx), gomock.Any()).Return(&ec2.DescribeSnapshotsOutput{Snapshots: ec2Snapshots}, nil)

resp, err := c.ListSnapshots(ctx, sourceVolumeID, 0, "")
if err != nil {
t.Fatalf("ListSnapshots() failed: expected no error, got: %v", err)
}

if len(resp.Snapshots) != len(expSnapshots) {
t.Fatalf("Expected %d snapshots, got %d", len(expSnapshots), len(resp.Snapshots))
}

for _, snap := range resp.Snapshots {
if snap.SourceVolumeID != sourceVolumeID {
t.Fatalf("Unexpected source volume. Expected %s, got %s", sourceVolumeID, snap.SourceVolumeID)
}
}
},
},
{
name: "success: max results, next token",
testFunc: func(t *testing.T) {
maxResults := 5
nextTokenValue := "nextTokenValue"
var expSnapshots []*Snapshot
for i := 0; i < maxResults*2; i++ {
expSnapshots = append(expSnapshots, &Snapshot{
SourceVolumeID: "snap-test-volume1",
SnapshotID: fmt.Sprintf("snap-test-name%d", i),
})
}

var ec2Snapshots []*ec2.Snapshot
for i := 0; i < maxResults*2; i++ {
ec2Snapshots = append(ec2Snapshots, &ec2.Snapshot{
SnapshotId: aws.String(expSnapshots[i].SnapshotID),
VolumeId: aws.String(fmt.Sprintf("snap-test-volume%d", i)),
State: aws.String("completed"),
})
}

mockCtl := gomock.NewController(t)
defer mockCtl.Finish()
mockEC2 := mocks.NewMockEC2(mockCtl)
c := newCloud(mockEC2)

ctx := context.Background()

firstCall := mockEC2.EXPECT().DescribeSnapshotsWithContext(gomock.Eq(ctx), gomock.Any()).Return(&ec2.DescribeSnapshotsOutput{
Snapshots: ec2Snapshots[:maxResults],
NextToken: aws.String(nextTokenValue),
}, nil)
secondCall := mockEC2.EXPECT().DescribeSnapshotsWithContext(gomock.Eq(ctx), gomock.Any()).Return(&ec2.DescribeSnapshotsOutput{
Snapshots: ec2Snapshots[maxResults:],
}, nil)
gomock.InOrder(
firstCall,
secondCall,
)

firstSnapshotsResponse, err := c.ListSnapshots(ctx, "", 5, "")
if err != nil {
t.Fatalf("ListSnapshots() failed: expected no error, got: %v", err)
}

if len(firstSnapshotsResponse.Snapshots) != maxResults {
t.Fatalf("Expected %d snapshots, got %d", maxResults, len(firstSnapshotsResponse.Snapshots))
}

if firstSnapshotsResponse.NextToken != nextTokenValue {
t.Fatalf("Expected next token value '%s' got '%s'", nextTokenValue, firstSnapshotsResponse.NextToken)
}

secondSnapshotsResponse, err := c.ListSnapshots(ctx, "", 0, firstSnapshotsResponse.NextToken)
if err != nil {
t.Fatalf("CreateSnapshot() failed: expected no error, got: %v", err)
}

if len(secondSnapshotsResponse.Snapshots) != maxResults {
t.Fatalf("Expected %d snapshots, got %d", maxResults, len(secondSnapshotsResponse.Snapshots))
}

if secondSnapshotsResponse.NextToken != "" {
t.Fatalf("Expected next token value to be empty got %s", secondSnapshotsResponse.NextToken)
}
},
},
{
name: "fail: AWS DescribeSnapshotsWithContext error",
testFunc: func(t *testing.T) {
mockCtl := gomock.NewController(t)
defer mockCtl.Finish()
mockEC2 := mocks.NewMockEC2(mockCtl)
c := newCloud(mockEC2)

ctx := context.Background()

mockEC2.EXPECT().DescribeSnapshotsWithContext(gomock.Eq(ctx), gomock.Any()).Return(nil, errors.New("test error"))

if _, err := c.ListSnapshots(ctx, "", 0, ""); err == nil {
t.Fatalf("ListSnapshots() failed: expected an error, got none")
}
},
},
{
name: "fail: no snapshots ErrNotFound",
testFunc: func(t *testing.T) {
mockCtl := gomock.NewController(t)
defer mockCtl.Finish()
mockEC2 := mocks.NewMockEC2(mockCtl)
c := newCloud(mockEC2)

ctx := context.Background()

mockEC2.EXPECT().DescribeSnapshotsWithContext(gomock.Eq(ctx), gomock.Any()).Return(&ec2.DescribeSnapshotsOutput{}, nil)

if _, err := c.ListSnapshots(ctx, "", 0, ""); err != nil {
if err != ErrNotFound {
t.Fatalf("Expected error %v, got %v", ErrNotFound, err)
}
} else {
t.Fatalf("Expected error, got none")
}
},
},
zacharya marked this conversation as resolved.
Show resolved Hide resolved
}

for _, tc := range testCases {
t.Run(tc.name, tc.testFunc)
}
}

func newCloud(mockEC2 EC2) Cloud {
return &cloud{
metadata: &Metadata{
Expand Down
Loading