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

chore(cli): add svc status output for Static Site service type #4985

Merged
merged 11 commits into from
Jun 20, 2023
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
36 changes: 35 additions & 1 deletion internal/pkg/aws/s3/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package s3
import (
"errors"
"fmt"
"github.com/dustin/go-humanize"
"github.com/xlab/treeprint"
"io"
"mime"
Expand Down Expand Up @@ -218,7 +219,6 @@ func (s *S3) GetBucketTree(bucket string) (string, error) {
if !exists {
return "", nil
}

var contents []*s3.Object
var prefixes []*s3.CommonPrefix
listResp := &s3.ListObjectsV2Output{}
Expand Down Expand Up @@ -251,6 +251,40 @@ func (s *S3) GetBucketTree(bucket string) (string, error) {
return tree.String(), nil
}

// GetBucketSizeAndCount returns the total size and number of objects in an S3 bucket.
func (s *S3) GetBucketSizeAndCount(bucket string) (string, int, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that this public function is specifically designed for status use case makes me think maybe we could rename this function. Otherwise it'll be too coupled with each other and not generic enough.

Also by looking at GetBucketTree I realized they share many very similar logics with each other (aka listing objects within the bucket). How about we just have

func (*S3) ListObjects(bucket string) (Objects, error)

type Objects []*s3.Object

func (Objects) TotalObjectCount() int

func (Objects) TotalSize() string

func (Objects) Tree() (string, error)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, yeah. I hadn't reused GetBucketTree because of the delimiter difference, but I could pass that in. Will refactor!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept total count and total size together (for now) to avoid making another API call.
I have ListObjects returning the output because Objects are just one element of the response that we need (we also need prefixes for tree-making).

exists, err := s.bucketExists(bucket)
if err != nil {
huanjani marked this conversation as resolved.
Show resolved Hide resolved
return "", 0, err
}
if !exists {
return "", 0, nil
}
var objects []*s3.Object
var size int64
var number int
huanjani marked this conversation as resolved.
Show resolved Hide resolved
listResp := &s3.ListObjectsV2Output{}
for {
listParams := &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
ContinuationToken: listResp.NextContinuationToken,
}
listResp, err = s.s3Client.ListObjectsV2(listParams)
if err != nil {
return "", 0, fmt.Errorf("list objects for bucket %s: %w", bucket, err)
}
objects = append(objects, listResp.Contents...)
if listResp.NextContinuationToken == nil {
break
}
}
for _, object := range objects {
size = size + aws.Int64Value(object.Size)
number = number + 1
}
return humanize.Bytes(uint64(size)), number, nil
}

func (s *S3) addNodes(tree treeprint.Tree, prefixes []*s3.CommonPrefix, bucket string) error {
if len(prefixes) == 0 {
return nil
Expand Down
159 changes: 159 additions & 0 deletions internal/pkg/aws/s3/s3_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -700,3 +700,162 @@ func TestS3_GetBucketTree(t *testing.T) {

}
}

func TestS3_GetBucketSizeAndCount(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,
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,
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,
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,
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,
ContinuationToken: nil,
Prefix: 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.GetBucketSizeAndCount(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)
})

}
}
15 changes: 13 additions & 2 deletions internal/pkg/cli/svc_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,18 @@ func newSvcStatusOpts(vars svcStatusVars) (*svcStatusOpts, error) {
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 if wkld.Type == manifestinfo.StaticSiteType {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe consider using a switch here?

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
} else {
Expand All @@ -85,7 +96,7 @@ func newSvcStatusOpts(vars svcStatusVars) (*svcStatusOpts, error) {
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
39 changes: 39 additions & 0 deletions internal/pkg/describe/mocks/mock_service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions internal/pkg/describe/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ type bucketDescriber interface {
GetBucketTree(bucket string) (string, error)
}

type bucketDataGetter interface {
GetBucketSizeAndCount(bucket string) (string, int, error)
}

type bucketNameGetter interface {
BucketName(app, env, svc string) (string, error)
}
Expand Down
35 changes: 32 additions & 3 deletions internal/pkg/describe/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,19 @@ type ecsServiceStatus struct {
TargetHealthDescriptions []taskTargetHealth `json:"targetHealthDescriptions"`
}

// appRunnerServiceStatus contains the status for an AppRunner service.
// appRunnerServiceStatus contains the status for an App Runner service.
type appRunnerServiceStatus struct {
Service apprunner.Service
LogEvents []*cloudwatchlogs.Event
}

// staticSiteServiceStatus contains the status for a Static Site service.
type staticSiteServiceStatus struct {
BucketName string `json:"bucketName"`
Size string `json:"totalSize"`
Count int `json:"totalObjects"`
}

type taskTargetHealth struct {
HealthStatus elbv2.HealthStatus `json:"healthStatus"`
TaskID string `json:"taskID"` // TaskID is empty if the target cannot be traced to a task.
Expand Down Expand Up @@ -97,7 +104,16 @@ func (a *appRunnerServiceStatus) JSONString() (string, error) {
return fmt.Sprintf("%s\n", b), nil
}

// HumanString returns the stringified ecsServiceStatus struct with human readable format.
// JSONString returns the stringified staticSiteServiceStatus struct with json format.
func (s *staticSiteServiceStatus) JSONString() (string, error) {
b, err := json.Marshal(s)
if err != nil {
return "", fmt.Errorf("marshal services: %w", err)
}
return fmt.Sprintf("%s\n", b), nil
}

// HumanString returns the stringified ecsServiceStatus struct in human-readable format.
func (s *ecsServiceStatus) HumanString() string {
var b bytes.Buffer
writer := tabwriter.NewWriter(&b, statusMinCellWidth, tabWidth, statusCellPaddingWidth, paddingChar, noAdditionalFormatting)
Expand Down Expand Up @@ -130,7 +146,7 @@ func (s *ecsServiceStatus) HumanString() string {
return b.String()
}

// HumanString returns the stringified appRunnerServiceStatus struct with human readable format.
// HumanString returns the stringified appRunnerServiceStatus struct in human-readable format.
func (a *appRunnerServiceStatus) HumanString() string {
var b bytes.Buffer
writer := tabwriter.NewWriter(&b, minCellWidth, tabWidth, statusCellPaddingWidth, paddingChar, noAdditionalFormatting)
Expand Down Expand Up @@ -159,6 +175,19 @@ func (a *appRunnerServiceStatus) HumanString() string {
return b.String()
}

// HumanString returns the stringified staticSiteServiceStatus struct in human-readable format.
func (s *staticSiteServiceStatus) HumanString() string {
var b bytes.Buffer
writer := tabwriter.NewWriter(&b, minCellWidth, tabWidth, statusCellPaddingWidth, paddingChar, noAdditionalFormatting)
fmt.Fprint(writer, color.Bold.Sprint("Bucket Summary\n\n"))
writer.Flush()
fmt.Fprintf(writer, " Bucket Name %s\n", s.BucketName)
fmt.Fprintf(writer, " Total Objects %s\n", strconv.Itoa(s.Count))
fmt.Fprintf(writer, " Total Size %s\n", s.Size)
writer.Flush()
return b.String()
}

func (s *ecsServiceStatus) writeTaskSummary(writer io.Writer) {
// NOTE: all the `bar` need to be fully colored. Observe how all the second parameter for all `summaryBar` function
// is a list of strings that are colored (e.g. `[]string{color.Green.Sprint("■"), color.Grey.Sprint("□")}`)
Expand Down
Loading