Skip to content

Commit

Permalink
chore(cli): add svc status output for Static Site service type (#4985)
Browse files Browse the repository at this point in the history
Very basic output:
```
% copilot svc status          
Service: static-site
Bucket Summary

  Bucket Name     bugbash-static-test-static-site-bucket-1qdzh62y4l3gj
  Total Objects   22
  Total Size      3.7 MB
```

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the Apache 2.0 License.
  • Loading branch information
huanjani authored Jun 20, 2023
1 parent ad52ae5 commit d6c5279
Show file tree
Hide file tree
Showing 12 changed files with 515 additions and 85 deletions.
10 changes: 5 additions & 5 deletions internal/pkg/aws/cloudwatch/cloudwatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ type AlarmStatus struct {
// AlarmDescription contains CloudWatch alarm config.
// Also available: MetricName, ComparisonOperator, DatapointsToAlarm, EvaluationPeriods, Threshold, Unit.
type AlarmDescription struct {
Name string `json:"name"`
Description string `json:"description"`
Environment string `json:"environment"`
Name string `json:"name"`
Description string `json:"description"`
Environment string `json:"environment"`
}

// New returns a CloudWatch struct configured against the input session.
Expand Down Expand Up @@ -181,8 +181,8 @@ func (cw *CloudWatch) metricAlarmsDescriptions(alarms []*cloudwatch.MetricAlarm)
continue
}
alarmDescriptionsList = append(alarmDescriptionsList, &AlarmDescription{
Name: aws.StringValue(alarm.AlarmName),
Description: aws.StringValue(alarm.AlarmDescription),
Name: aws.StringValue(alarm.AlarmName),
Description: aws.StringValue(alarm.AlarmDescription),
})
}
return alarmDescriptionsList
Expand Down
92 changes: 58 additions & 34 deletions internal/pkg/aws/s3/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ package s3
import (
"errors"
"fmt"
"github.com/xlab/treeprint"
"io"
"mime"
"path/filepath"
Expand All @@ -20,6 +19,8 @@ import (
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/dustin/go-humanize"
"github.com/xlab/treeprint"
)

const (
Expand Down Expand Up @@ -194,68 +195,91 @@ func FormatARN(partition, location string) string {
return fmt.Sprintf("arn:%s:s3:::%s", partition, location)
}

func (s *S3) bucketExists(bucket string) (bool, error) {
input := &s3.HeadBucketInput{
Bucket: aws.String(bucket),
// BucketTree creates an ASCII tree representing the folder structure of a bucket's objects.
func (s *S3) BucketTree(bucket string) (string, error) {
outputs, err := s.listObjects(bucket, "/")
if err != nil || outputs == nil {
return "", err
}
_, err := s.s3Client.HeadBucket(input)
if err != nil {
if aerr, ok := err.(awserr.Error); ok && aerr.Code() == errCodeNotFound {
return false, nil
}
return false, err
var contents []*s3.Object
var prefixes []*s3.CommonPrefix
for _, output := range outputs {
contents = append(contents, output.Contents...)
prefixes = append(prefixes, output.CommonPrefixes...)
}

return true, nil
tree := treeprint.New()
// Add top-level files.
for _, object := range contents {
tree.AddNode(aws.StringValue(object.Key))
}
// Recursively add folders and their children.
if err := s.addNodes(tree, prefixes, bucket); err != nil {
return "", err
}
return tree.String(), nil
}

// GetBucketTree retrieves the objects in an S3 bucket and creates an ASCII tree representing their folder structure.
func (s *S3) GetBucketTree(bucket string) (string, error) {
exists, err := s.bucketExists(bucket)
if err != nil {
return "", err
// BucketSizeAndCount returns the total size and number of objects in an S3 bucket.
func (s *S3) BucketSizeAndCount(bucket string) (string, int, error) {
outputs, err := s.listObjects(bucket, "")
if err != nil || outputs == nil {
return "", 0, err
}
if !exists {
return "", nil
var size int64
var count int
for _, output := range outputs {
for _, object := range output.Contents {
size += aws.Int64Value(object.Size)
count++
}
}
return humanize.Bytes(uint64(size)), count, nil
}

var contents []*s3.Object
var prefixes []*s3.CommonPrefix
func (s *S3) listObjects(bucket, delimiter string) ([]s3.ListObjectsV2Output, error) {
exists, err := s.bucketExists(bucket)
if err != nil || !exists {
return nil, err
}
var outputs []s3.ListObjectsV2Output
listResp := &s3.ListObjectsV2Output{}
for {
listParams := &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Delimiter: aws.String(slashDelimiter),
Delimiter: aws.String(delimiter),
ContinuationToken: listResp.NextContinuationToken,
}
listResp, err = s.s3Client.ListObjectsV2(listParams)
if err != nil {
return "", fmt.Errorf("list objects for bucket %s: %w", bucket, err)
return nil, fmt.Errorf("list objects for bucket %s: %w", bucket, err)
}
contents = append(contents, listResp.Contents...)
prefixes = append(prefixes, listResp.CommonPrefixes...)
outputs = append(outputs, *listResp)
if listResp.NextContinuationToken == nil {
break
}
}
return outputs, nil
}

tree := treeprint.New()
// Add top-level files.
for _, object := range contents {
tree.AddNode(aws.StringValue(object.Key))
func (s *S3) bucketExists(bucket string) (bool, error) {
input := &s3.HeadBucketInput{
Bucket: aws.String(bucket),
}
// Recursively add folders and their children.
if err := s.addNodes(tree, prefixes, bucket); err != nil {
return "", err
_, err := s.s3Client.HeadBucket(input)
if err != nil {
var aerr awserr.Error
if errors.As(err, &aerr) && aerr.Code() == errCodeNotFound {
return false, nil
}
return false, err
}
return tree.String(), nil
return true, nil
}

func (s *S3) addNodes(tree treeprint.Tree, prefixes []*s3.CommonPrefix, bucket string) error {
if len(prefixes) == 0 {
return nil
}

listResp := &s3.ListObjectsV2Output{}
var err error
for _, prefix := range prefixes {
Expand Down
167 changes: 165 additions & 2 deletions internal/pkg/aws/s3/s3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,7 +489,7 @@ func TestS3_FormatARN(t *testing.T) {
}
}

func TestS3_GetBucketTree(t *testing.T) {
func TestS3_BucketTree(t *testing.T) {
type s3Mocks struct {
s3API *mocks.Mocks3API
s3ManagerAPI *mocks.Mocks3ManagerAPI
Expand Down Expand Up @@ -689,7 +689,7 @@ func TestS3_GetBucketTree(t *testing.T) {
}
tc.setupMocks(s3mocks)

gotTree, gotErr := service.GetBucketTree(aws.StringValue(mockBucket))
gotTree, gotErr := service.BucketTree(aws.StringValue(mockBucket))
if tc.wantErr != nil {
require.EqualError(t, gotErr, tc.wantErr.Error())
return
Expand All @@ -700,3 +700,166 @@ func TestS3_GetBucketTree(t *testing.T) {

}
}

func TestS3_BucketSizeAndCount(t *testing.T) {
type s3Mocks struct {
s3API *mocks.Mocks3API
s3ManagerAPI *mocks.Mocks3ManagerAPI
}
mockBucket := aws.String("bucketName")
nonexistentError := awserr.New(errCodeNotFound, "msg", errors.New("some error"))
mockContinuationToken := "next"

resp := s3.ListObjectsV2Output{
Contents: []*s3.Object{
{
Key: aws.String("README.md"),
Size: aws.Int64(111111),
},
{
Key: aws.String("error.html"),
Size: aws.Int64(222222),
},
{
Key: aws.String("index.html"),
Size: aws.Int64(333333),
},
},
KeyCount: aws.Int64(14),
MaxKeys: aws.Int64(1000),
Name: mockBucket,
}

testCases := map[string]struct {
setupMocks func(mocks s3Mocks)

wantSize string
wantCount int
wantErr error
}{
"should return correct size and count": {
setupMocks: func(m s3Mocks) {
m.s3API.EXPECT().HeadBucket(&s3.HeadBucketInput{Bucket: mockBucket}).Return(&s3.HeadBucketOutput{}, nil)
m.s3API.EXPECT().ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: mockBucket,
Delimiter: aws.String(""),
ContinuationToken: nil,
Prefix: nil,
}).Return(&resp, nil)
},
wantSize: "667 kB",
wantCount: 3,
},
"should handle multiple pages of objects": {
setupMocks: func(m s3Mocks) {
m.s3API.EXPECT().HeadBucket(&s3.HeadBucketInput{Bucket: mockBucket}).Return(&s3.HeadBucketOutput{}, nil)
m.s3API.EXPECT().ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: mockBucket,
Delimiter: aws.String(""),
ContinuationToken: nil,
Prefix: nil,
}).Return(
&s3.ListObjectsV2Output{
Contents: []*s3.Object{
{
Key: aws.String("README.md"),
Size: aws.Int64(123),
},
},
KeyCount: aws.Int64(14),
MaxKeys: aws.Int64(1000),
Name: mockBucket,
NextContinuationToken: &mockContinuationToken,
}, nil)
m.s3API.EXPECT().ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: mockBucket,
Delimiter: aws.String(""),
ContinuationToken: &mockContinuationToken,
Prefix: nil,
}).Return(
&s3.ListObjectsV2Output{
Contents: []*s3.Object{
{
Key: aws.String("READMETOO.md"),
Size: aws.Int64(321),
},
},
KeyCount: aws.Int64(14),
MaxKeys: aws.Int64(1000),
Name: mockBucket,
NextContinuationToken: nil,
}, nil)
},
wantSize: "444 B",
wantCount: 2,
},
"empty bucket": {
setupMocks: func(m s3Mocks) {
m.s3API.EXPECT().HeadBucket(&s3.HeadBucketInput{Bucket: mockBucket}).Return(&s3.HeadBucketOutput{}, nil)
m.s3API.EXPECT().ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: mockBucket,
Delimiter: aws.String(""),
ContinuationToken: nil,
Prefix: nil,
}).Return(&s3.ListObjectsV2Output{
Contents: nil,
Name: mockBucket,
}, nil)
},
wantSize: "0 B",
wantCount: 0,
},
"return nil if bucket doesn't exist": {
setupMocks: func(m s3Mocks) {
m.s3API.EXPECT().HeadBucket(&s3.HeadBucketInput{Bucket: mockBucket}).Return(&s3.HeadBucketOutput{}, nonexistentError)
},
},
"return err if cannot determine if bucket exists": {
setupMocks: func(m s3Mocks) {
m.s3API.EXPECT().HeadBucket(&s3.HeadBucketInput{Bucket: mockBucket}).Return(&s3.HeadBucketOutput{}, errors.New("some error"))
},
wantErr: errors.New("some error"),
},
"should wrap error if fail to list objects": {
setupMocks: func(m s3Mocks) {
m.s3API.EXPECT().HeadBucket(&s3.HeadBucketInput{Bucket: mockBucket}).Return(&s3.HeadBucketOutput{}, nil)
m.s3API.EXPECT().ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: mockBucket,
Delimiter: aws.String(""),
ContinuationToken: nil,
}).Return(nil, errors.New("some error"))
},
wantErr: errors.New("list objects for bucket bucketName: some error"),
},
}

for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
// GIVEN
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockS3Client := mocks.NewMocks3API(ctrl)
mockS3Manager := mocks.NewMocks3ManagerAPI(ctrl)

s3mocks := s3Mocks{
s3API: mockS3Client,
s3ManagerAPI: mockS3Manager,
}
service := S3{
s3Client: mockS3Client,
}
tc.setupMocks(s3mocks)

gotSize, gotCount, gotErr := service.BucketSizeAndCount(aws.StringValue(mockBucket))
if tc.wantErr != nil {
require.EqualError(t, gotErr, tc.wantErr.Error())
return
}
require.NoError(t, gotErr)
require.Equal(t, tc.wantSize, gotSize)
require.Equal(t, tc.wantCount, gotCount)
})

}
}
20 changes: 16 additions & 4 deletions internal/pkg/cli/svc_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,26 +66,38 @@ func newSvcStatusOpts(vars svcStatusVars) (*svcStatusOpts, error) {
if err != nil {
return fmt.Errorf("retrieve %s from application %s: %w", o.appName, o.svcName, err)
}
if wkld.Type == manifestinfo.RequestDrivenWebServiceType {
switch wkld.Type {
case manifestinfo.RequestDrivenWebServiceType:
d, err := describe.NewAppRunnerStatusDescriber(&describe.NewServiceStatusConfig{
App: o.appName,
Env: o.envName,
Svc: o.svcName,
ConfigStore: configStore,
})
if err != nil {
return fmt.Errorf("creating status describer for apprunner service %s in application %s: %w", o.svcName, o.appName, err)
return fmt.Errorf("create status describer for App Runner service %s in application %s: %w", o.svcName, o.appName, err)
}
o.statusDescriber = d
} else {
case manifestinfo.StaticSiteType:
d, err := describe.NewStaticSiteStatusDescriber(&describe.NewServiceStatusConfig{
App: o.appName,
Env: o.envName,
Svc: o.svcName,
ConfigStore: configStore,
})
if err != nil {
return fmt.Errorf("create status describer for Static Site service %s in application %s: %w", o.svcName, o.appName, err)
}
o.statusDescriber = d
default:
d, err := describe.NewECSStatusDescriber(&describe.NewServiceStatusConfig{
App: o.appName,
Env: o.envName,
Svc: o.svcName,
ConfigStore: configStore,
})
if err != nil {
return fmt.Errorf("creating status describer for service %s in application %s: %w", o.svcName, o.appName, err)
return fmt.Errorf("create status describer for service %s in application %s: %w", o.svcName, o.appName, err)
}
o.statusDescriber = d
}
Expand Down
Loading

0 comments on commit d6c5279

Please sign in to comment.