Skip to content

Commit

Permalink
feat(cli): allow svc status to show auto scaling alarms (#1384)
Browse files Browse the repository at this point in the history
<!-- Provide summary of changes -->
Since we now enable auto scaling in #1355. This feature enable `svc status` to show any alarms created by auto scaling. After this PR `svc status` should be able to show alarms created by auto scaling or alarms with copilot tags.
<!-- Issue number, if available. E.g. "Fixes #31", "Addresses #42, 77" -->

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
  • Loading branch information
iamhopaul123 authored Sep 16, 2020
1 parent 148d7f1 commit d1c8f2e
Show file tree
Hide file tree
Showing 10 changed files with 507 additions and 100 deletions.
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ gen-mocks: tools
${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/secretsmanager/mocks/mock_secretsmanager.go -source=./internal/pkg/aws/secretsmanager/secretsmanager.go
${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/codepipeline/mocks/mock_codepipeline.go -source=./internal/pkg/aws/codepipeline/codepipeline.go
${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/cloudwatch/mocks/mock_cloudwatch.go -source=./internal/pkg/aws/cloudwatch/cloudwatch.go
${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/aas/mocks/mock_aas.go -source=./internal/pkg/aws/aas/aas.go
${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/resourcegroups/mocks/mock_resourcegroups.go -source=./internal/pkg/aws/resourcegroups/resourcegroups.go
${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/cloudwatchlogs/mocks/mock_cloudwatchlogs.go -source=./internal/pkg/aws/cloudwatchlogs/cloudwatchlogs.go
${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/s3/mocks/mock_s3.go -source=./internal/pkg/aws/s3/s3.go
Expand Down
64 changes: 64 additions & 0 deletions internal/pkg/aws/aas/aas.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// Package aas provides a client to make API requests to Application Auto Scaling.
package aas

import (
"fmt"

"github.com/aws/aws-sdk-go/aws"
aas "github.com/aws/aws-sdk-go/service/applicationautoscaling"

"github.com/aws/aws-sdk-go/aws/session"
)

const (
// ECS service resource ID format: service/${clusterName}/${serviceName}.
fmtECSResourceID = "service/%s/%s"
ecsServiceNamespace = "ecs"
)

type api interface {
DescribeScalingPolicies(input *aas.DescribeScalingPoliciesInput) (*aas.DescribeScalingPoliciesOutput, error)
}

// ApplicationAutoscaling wraps an Amazon Application Auto Scaling client.
type ApplicationAutoscaling struct {
client api
}

// New returns a ApplicationAutoscaling struct configured against the input session.
func New(s *session.Session) *ApplicationAutoscaling {
return &ApplicationAutoscaling{
client: aas.New(s),
}
}

// ECSServiceAlarmNames returns names of the CloudWatch alarms associated with the
// scaling policies attached to the ECS service.
func (a *ApplicationAutoscaling) ECSServiceAlarmNames(cluster, service string) ([]string, error) {
resourceID := fmt.Sprintf(fmtECSResourceID, cluster, service)
var alarms []string
var err error
resp := &aas.DescribeScalingPoliciesOutput{}
for {
resp, err = a.client.DescribeScalingPolicies(&aas.DescribeScalingPoliciesInput{
ResourceId: aws.String(resourceID),
ServiceNamespace: aws.String(ecsServiceNamespace),
NextToken: resp.NextToken,
})
if err != nil {
return nil, fmt.Errorf("describe scaling policies for ECS service %s/%s: %w", cluster, service, err)
}
for _, policy := range resp.ScalingPolicies {
for _, alarm := range policy.Alarms {
alarms = append(alarms, aws.StringValue(alarm.AlarmName))
}
}
if resp.NextToken == nil {
break
}
}
return alarms, nil
}
141 changes: 141 additions & 0 deletions internal/pkg/aws/aas/aas_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package aas

import (
"errors"
"fmt"
"testing"

"github.com/aws/aws-sdk-go/aws"
aas "github.com/aws/aws-sdk-go/service/applicationautoscaling"
"github.com/aws/copilot-cli/internal/pkg/aws/aas/mocks"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
)

type aasMocks struct {
client *mocks.Mockapi
}

func TestCloudWatch_ECSServiceAutoscalingAlarms(t *testing.T) {
const (
mockCluster = "mockCluster"
mockService = "mockService"
mockResourceID = "service/mockCluster/mockService"
mockNextToken = "mockNextToken"
)
mockError := errors.New("some error")

testCases := map[string]struct {
setupMocks func(m aasMocks)

wantErr error
wantAlarmNames []string
}{
"errors if failed to retrieve auto scaling alarm names": {
setupMocks: func(m aasMocks) {
m.client.EXPECT().DescribeScalingPolicies(gomock.Any()).Return(nil, mockError)
},

wantErr: fmt.Errorf("describe scaling policies for ECS service mockCluster/mockService: some error"),
},
"success": {
setupMocks: func(m aasMocks) {
m.client.EXPECT().DescribeScalingPolicies(&aas.DescribeScalingPoliciesInput{
ResourceId: aws.String(mockResourceID),
ServiceNamespace: aws.String(ecsServiceNamespace),
}).Return(&aas.DescribeScalingPoliciesOutput{
ScalingPolicies: []*aas.ScalingPolicy{
{
Alarms: []*aas.Alarm{
{
AlarmName: aws.String("mockAlarm1"),
},
{
AlarmName: aws.String("mockAlarm2"),
},
},
},
{
Alarms: []*aas.Alarm{
{
AlarmName: aws.String("mockAlarm3"),
},
},
},
},
}, nil)
},

wantAlarmNames: []string{"mockAlarm1", "mockAlarm2", "mockAlarm3"},
},
"success with pagination": {
setupMocks: func(m aasMocks) {
gomock.InOrder(
m.client.EXPECT().DescribeScalingPolicies(&aas.DescribeScalingPoliciesInput{
ResourceId: aws.String(mockResourceID),
ServiceNamespace: aws.String(ecsServiceNamespace),
}).Return(&aas.DescribeScalingPoliciesOutput{
ScalingPolicies: []*aas.ScalingPolicy{
{
Alarms: []*aas.Alarm{
{
AlarmName: aws.String("mockAlarm1"),
},
},
},
},
NextToken: aws.String(mockNextToken),
}, nil),
m.client.EXPECT().DescribeScalingPolicies(&aas.DescribeScalingPoliciesInput{
ResourceId: aws.String(mockResourceID),
ServiceNamespace: aws.String(ecsServiceNamespace),
NextToken: aws.String(mockNextToken),
}).Return(&aas.DescribeScalingPoliciesOutput{
ScalingPolicies: []*aas.ScalingPolicy{
{
Alarms: []*aas.Alarm{
{
AlarmName: aws.String("mockAlarm2"),
},
},
},
},
}, nil),
)
},

wantAlarmNames: []string{"mockAlarm1", "mockAlarm2"},
},
}

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

mockClient := mocks.NewMockapi(ctrl)
mocks := aasMocks{
client: mockClient,
}

tc.setupMocks(mocks)

aasSvc := ApplicationAutoscaling{
client: mockClient,
}

gotAlarmNames, gotErr := aasSvc.ECSServiceAlarmNames(mockCluster, mockService)

if gotErr != nil {
require.EqualError(t, tc.wantErr, gotErr.Error())
} else {
require.Equal(t, tc.wantAlarmNames, gotAlarmNames)
}
})

}
}
49 changes: 49 additions & 0 deletions internal/pkg/aws/aas/mocks/mock_applicationautoscaling.go

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

58 changes: 30 additions & 28 deletions internal/pkg/aws/cloudwatch/cloudwatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type resourceGetter interface {

// CloudWatch wraps an Amazon CloudWatch client.
type CloudWatch struct {
cwClient api
client api
rgClient resourceGetter
}

Expand All @@ -50,37 +50,39 @@ type AlarmStatus struct {
// New returns a CloudWatch struct configured against the input session.
func New(s *session.Session) *CloudWatch {
return &CloudWatch{
cwClient: cloudwatch.New(s),
client: cloudwatch.New(s),
rgClient: rg.New(s),
}
}

// GetAlarmsWithTags returns all the CloudWatch alarms that have the resource tags.
func (cw *CloudWatch) GetAlarmsWithTags(tags map[string]string) ([]AlarmStatus, error) {
var alarmNames []*string

// AlarmsWithTags returns all the CloudWatch alarms that have the resource tags.
func (cw *CloudWatch) AlarmsWithTags(tags map[string]string) ([]AlarmStatus, error) {
var alarmNames []string
resources, err := cw.rgClient.GetResourcesByTags(cloudwatchResourceType, tags)
if err != nil {
return nil, err
}

for _, resource := range resources {
name, err := cw.getAlarmName(resource.ARN)
name, err := getAlarmName(resource.ARN)
if err != nil {
return nil, err
}
alarmNames = append(alarmNames, name)
}
return cw.AlarmStatus(alarmNames)
}

// Return an empty array since DescribeAlarms will return all alarms if "AlarmNames" is an empty array.
if len(alarmNames) == 0 {
return []AlarmStatus{}, nil
// AlarmStatus returns the status of each given alarm name.
func (cw *CloudWatch) AlarmStatus(alarms []string) ([]AlarmStatus, error) {
if len(alarms) == 0 {
return nil, nil
}
var alarmStatus []AlarmStatus
var err error
alarmResp := &cloudwatch.DescribeAlarmsOutput{}
for {
alarmResp, err = cw.cwClient.DescribeAlarms(&cloudwatch.DescribeAlarmsInput{
AlarmNames: alarmNames,
alarmResp, err = cw.client.DescribeAlarms(&cloudwatch.DescribeAlarmsInput{
AlarmNames: aws.StringSlice(alarms),
NextToken: alarmResp.NextToken,
})
if err != nil {
Expand All @@ -95,21 +97,6 @@ func (cw *CloudWatch) GetAlarmsWithTags(tags map[string]string) ([]AlarmStatus,
return alarmStatus, nil
}

// getAlarmName gets the alarm name given a specific alarm ARN.
// For example: arn:aws:cloudwatch:us-west-2:1234567890:alarm:SDc-ReadCapacityUnitsLimit-BasicAlarm
// returns SDc-ReadCapacityUnitsLimit-BasicAlarm
func (cw *CloudWatch) getAlarmName(alarmArn string) (*string, error) {
resp, err := arn.Parse(alarmArn)
if err != nil {
return nil, fmt.Errorf("parse alarm ARN %s: %w", alarmArn, err)
}
alarmNameList := strings.Split(resp.Resource, ":")
if len(alarmNameList) != 2 {
return nil, fmt.Errorf("cannot parse alarm ARN resource %s", resp.Resource)
}
return aws.String(alarmNameList[1]), nil
}

func (cw *CloudWatch) compositeAlarmsStatus(alarms []*cloudwatch.CompositeAlarm) []AlarmStatus {
var alarmStatusList []AlarmStatus
for _, alarm := range alarms {
Expand Down Expand Up @@ -139,3 +126,18 @@ func (cw *CloudWatch) metricAlarmsStatus(alarms []*cloudwatch.MetricAlarm) []Ala
}
return alarmStatusList
}

// getAlarmName gets the alarm name given a specific alarm ARN.
// For example: arn:aws:cloudwatch:us-west-2:1234567890:alarm:SDc-ReadCapacityUnitsLimit-BasicAlarm
// returns SDc-ReadCapacityUnitsLimit-BasicAlarm
func getAlarmName(alarmARN string) (string, error) {
resp, err := arn.Parse(alarmARN)
if err != nil {
return "", fmt.Errorf("parse alarm ARN %s: %w", alarmARN, err)
}
alarmNameList := strings.Split(resp.Resource, ":")
if len(alarmNameList) != 2 {
return "", fmt.Errorf("unknown ARN resource format %s", resp.Resource)
}
return alarmNameList[1], nil
}
Loading

0 comments on commit d1c8f2e

Please sign in to comment.