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

Nuke cloudwatch dashboard #233

Merged
merged 5 commits into from
Nov 12, 2021
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The currently supported functionality includes:
- Deleting all IAM Access Analyzers in an AWS account
- Revoking the default rules in the un-deletable default security group of a VPC
- Deleting all DynamoDB tables in an AWS account
- Deleting all CloudWatch Dashboards in an AWS account

### BEWARE!

Expand Down Expand Up @@ -158,6 +159,9 @@ The following resources support the Config file:
- IAM Access Analyzers
- Resource type: `accessanalyzer`
- Config key: `AccessAnalyzer`
- CloudWatch Dashboards
- Resource type: `cloudwatch-dashboard`
- Config key: `CloudWatchDashboard`


#### Example
Expand Down Expand Up @@ -350,7 +354,7 @@ TEST_ACMPCA_EXPENSIVE_ENABLE=1 go test -v ./...
Every source file in this project should be formatted with `go fmt`.

### Releasing new versions
We try to follow the release process as deifned in our [Coding Methodology](https://www.notion.so/gruntwork/Gruntwork-Coding-Methodology-02fdcd6e4b004e818553684760bf691e#08b68ee0e19143e89523dcf483d2bf48).
We try to follow the release process as deifned in our [Coding Methodology](https://www.notion.so/gruntwork/Gruntwork-Coding-Methodology-02fdcd6e4b004e818553684760bf691e#08b68ee0e19143e89523dcf483d2bf48).

#### Choosing a new release tag
If the new release contains any new resources that `cloud-nuke` will support, mark it as a minor version bump (X in v0.X.Y) to indicate backward incompatibilities.
Expand Down
17 changes: 16 additions & 1 deletion aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,20 @@ func GetAllResources(targetRegions []string, excludeAfter time.Time, resourceTyp
}
// End AccessAnalyzer

// CloudWatchDashboard
cloudwatchDashboards := CloudWatchDashboards{}
if IsNukeable(cloudwatchDashboards.ResourceName(), resourceTypes) {
cwdbNames, err := getAllCloudWatchDashboards(session, excludeAfter, configObj)
if err != nil {
return nil, errors.WithStackTrace(err)
}
if len(cwdbNames) > 0 {
cloudwatchDashboards.DashboardNames = awsgo.StringValueSlice(cwdbNames)
resourcesInRegion.Resources = append(resourcesInRegion.Resources, cloudwatchDashboards)
}
}
// End CloudWatchDashboard

// S3 Buckets
s3Buckets := S3Buckets{}
if IsNukeable(s3Buckets.ResourceName(), resourceTypes) {
Expand Down Expand Up @@ -687,6 +701,7 @@ func ListResourceTypes() []string {
IAMUsers{}.ResourceName(),
SecretsManagerSecrets{}.ResourceName(),
NatGateways{}.ResourceName(),
CloudWatchDashboards{}.ResourceName(),
AccessAnalyzer{}.ResourceName(),
DynamoDB{}.ResourceName(),
}
Expand Down Expand Up @@ -769,4 +784,4 @@ func NukeAllResources(account *AwsAccountResources, regions []string) error {
}

return nil
}
}
89 changes: 89 additions & 0 deletions aws/cloudwatch_dashboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package aws

import (
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/gruntwork-io/go-commons/errors"

"github.com/gruntwork-io/cloud-nuke/config"
"github.com/gruntwork-io/cloud-nuke/logging"
)

func getAllCloudWatchDashboards(session *session.Session, excludeAfter time.Time, configObj config.Config) ([]*string, error) {
svc := cloudwatch.New(session)

allDashboards := []*string{}
input := &cloudwatch.ListDashboardsInput{}
err := svc.ListDashboardsPages(
input,
func(page *cloudwatch.ListDashboardsOutput, lastPage bool) bool {
for _, dashboard := range page.DashboardEntries {
if shouldIncludeCloudWatchDashboard(dashboard, excludeAfter, configObj) {
allDashboards = append(allDashboards, dashboard.DashboardName)
}
}
return !lastPage
},
)
return allDashboards, errors.WithStackTrace(err)
}

func shouldIncludeCloudWatchDashboard(dashboard *cloudwatch.DashboardEntry, excludeAfter time.Time, configObj config.Config) bool {
if dashboard == nil {
return false
}

if dashboard.LastModified != nil && excludeAfter.Before(*dashboard.LastModified) {
return false
}

return config.ShouldInclude(
aws.StringValue(dashboard.DashboardName),
configObj.CloudWatchDashboard.IncludeRule.NamesRegExp,
configObj.CloudWatchDashboard.ExcludeRule.NamesRegExp,
)
}

func nukeAllCloudWatchDashboards(session *session.Session, identifiers []*string) error {
region := aws.StringValue(session.Config.Region)

svc := cloudwatch.New(session)

if len(identifiers) == 0 {
logging.Logger.Infof("No CloudWatch Dashboards to nuke in region %s", region)
return nil
}

// NOTE: we don't need to do pagination here, because the pagination is handled by the caller to this function,
Copy link
Contributor

Choose a reason for hiding this comment

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

just to confirm - is that a pattern we should adopt more or less in all cases for pagination?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes I think we should. I've been doing this on any new resource I introduce, but haven't been going back and retroactively porting it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Hm, that's awesome! I've raised an issue actually some weeks ago about this: #226 so really good to see this!

// based on CloudWatchDashboard.MaxBatchSize, however we add a guard here to warn users when the batching fails and has a
// chance of throttling AWS. Since we concurrently make one call for each identifier, we pick 100 for the limit here
// because many APIs in AWS have a limit of 100 requests per second.
if len(identifiers) > 100 {
logging.Logger.Errorf("Nuking too many CloudWatch Dashboards at once (100): halting to avoid hitting AWS API rate limiting")
return TooManyCloudWatchDashboardsErr{}
}

logging.Logger.Infof("Deleting CloudWatch Dashboards in region %s", region)
input := cloudwatch.DeleteDashboardsInput{DashboardNames: identifiers}
_, err := svc.DeleteDashboards(&input)
if err != nil {
logging.Logger.Errorf("[Failed] %s", err)
return errors.WithStackTrace(err)
}

for _, dashboardName := range identifiers {
logging.Logger.Infof("[OK] CloudWatch Dashboard %s was deleted in %s", aws.StringValue(dashboardName), region)
}
return nil
}

// Custom errors

type TooManyCloudWatchDashboardsErr struct{}

func (err TooManyCloudWatchDashboardsErr) Error() string {
return "Too many CloudWatch Dashboards requested at once."
}
164 changes: 164 additions & 0 deletions aws/cloudwatch_dashboard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package aws

import (
"fmt"
"strings"
"testing"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudwatch"
"github.com/gruntwork-io/cloud-nuke/config"
"github.com/gruntwork-io/cloud-nuke/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestListCloudWatchDashboards(t *testing.T) {
t.Parallel()

region, err := getRandomRegion()
require.NoError(t, err)

session, err := session.NewSession(&aws.Config{Region: aws.String(region)})
require.NoError(t, err)
svc := cloudwatch.New(session)

cwdbName := createCloudWatchDashboard(t, svc, region)
defer deleteCloudWatchDashboard(t, svc, cwdbName, true)

cwdbNames, err := getAllCloudWatchDashboards(session, time.Now(), config.Config{})
require.NoError(t, err)
assert.Contains(t, aws.StringValueSlice(cwdbNames), aws.StringValue(cwdbName))
}

func TestTimeFilterExclusionNewlyCreatedCloudWatchDashboard(t *testing.T) {
t.Parallel()

region, err := getRandomRegion()
require.NoError(t, err)

session, err := session.NewSession(&aws.Config{Region: aws.String(region)})
require.NoError(t, err)
svc := cloudwatch.New(session)

cwdbName := createCloudWatchDashboard(t, svc, region)
defer deleteCloudWatchDashboard(t, svc, cwdbName, true)

// Assert CloudWatch Dashboard is picked up without filters
cwdbNamesNewer, err := getAllCloudWatchDashboards(session, time.Now(), config.Config{})
require.NoError(t, err)
assert.Contains(t, aws.StringValueSlice(cwdbNamesNewer), aws.StringValue(cwdbName))

// Assert user doesn't appear when we look at users older than 1 Hour
olderThan := time.Now().Add(-1 * time.Hour)
cwdbNamesOlder, err := getAllCloudWatchDashboards(session, olderThan, config.Config{})
require.NoError(t, err)
assert.NotContains(t, aws.StringValueSlice(cwdbNamesOlder), aws.StringValue(cwdbName))
}

func TestNukeCloudWatchDashboardOne(t *testing.T) {
t.Parallel()

region, err := getRandomRegion()
require.NoError(t, err)

session, err := session.NewSession(&aws.Config{Region: aws.String(region)})
require.NoError(t, err)
svc := cloudwatch.New(session)

// We ignore errors in the delete call here, because it is intended to be a stop gap in case there is a bug in nuke.
cwdbName := createCloudWatchDashboard(t, svc, region)
defer deleteCloudWatchDashboard(t, svc, cwdbName, false)
identifiers := []*string{cwdbName}

require.NoError(
t,
nukeAllCloudWatchDashboards(session, identifiers),
)

// Make sure the CloudWatch Dashboard is deleted.
assertCloudWatchDashboardsDeleted(t, svc, identifiers)
}

func TestNukeCloudWatchDashboardsMoreThanOne(t *testing.T) {
t.Parallel()

region, err := getRandomRegion()
require.NoError(t, err)

session, err := session.NewSession(&aws.Config{Region: aws.String(region)})
require.NoError(t, err)
svc := cloudwatch.New(session)

cwdbNames := []*string{}
for i := 0; i < 3; i++ {
// We ignore errors in the delete call here, because it is intended to be a stop gap in case there is a bug in nuke.
Copy link
Contributor

Choose a reason for hiding this comment

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

Q: for my knowledge - so this stop gap is meant to try & delete the dashboard once, but if that call fails, then it leaves it at that in case the deletion failed for a good reason?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah no. So the logic here is that the test makes a call to nukeAllCloudWatchDashboards, which will go ahead and delete the dashboards that it created in the test, if it works. However, if there is a bug in the function and it fails, those dashboards will be lying around. To avoid having the test pollute the environment with those dashboards, we attempt an alternative cleaning function here to try to make sure those get deleted.

The reason we can't error check is that in the happy path, the dashboards would be deleted by the nuke function and they won't exist, thus this function will throw an error.

Copy link
Contributor

Choose a reason for hiding this comment

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

ahhh! that makes total sense! thanks, Yori :)

cwdbName := createCloudWatchDashboard(t, svc, region)
defer deleteCloudWatchDashboard(t, svc, cwdbName, false)
cwdbNames = append(cwdbNames, cwdbName)
}

require.NoError(
t,
nukeAllCloudWatchDashboards(session, cwdbNames),
)

// Make sure the CloudWatch Dashboard is deleted.
assertCloudWatchDashboardsDeleted(t, svc, cwdbNames)
}

// Helper functions for driving the CloudWatch Dashboard tests

// createCloudWatchDashboard will create a new CloudWatch Dashboard with a single text widget.
func createCloudWatchDashboard(t *testing.T, svc *cloudwatch.CloudWatch, region string) *string {
uniqueID := util.UniqueID()
name := fmt.Sprintf("cloud-nuke-test-%s", strings.ToLower(uniqueID))

resp, err := svc.PutDashboard(&cloudwatch.PutDashboardInput{
DashboardBody: aws.String(helloWorldCloudWatchDashboardWidget),
DashboardName: aws.String(name),
})
require.NoError(t, err)
if len(resp.DashboardValidationMessages) > 0 {
t.Fatalf("Error creating Dashboard %v", resp.DashboardValidationMessages)
}
// Add an arbitrary sleep to account for eventual consistency
time.Sleep(15 * time.Second)
return &name
}

// deleteCloudWatchDashboard is a function to delete the given CloudWatch Dashboard.
func deleteCloudWatchDashboard(t *testing.T, svc *cloudwatch.CloudWatch, name *string, checkErr bool) {
input := &cloudwatch.DeleteDashboardsInput{DashboardNames: []*string{name}}
_, err := svc.DeleteDashboards(input)
if checkErr {
require.NoError(t, err)
}
}

func assertCloudWatchDashboardsDeleted(t *testing.T, svc *cloudwatch.CloudWatch, identifiers []*string) {
for _, name := range identifiers {
resp, err := svc.ListDashboards(&cloudwatch.ListDashboardsInput{DashboardNamePrefix: name})
require.NoError(t, err)
if len(resp.DashboardEntries) > 0 {
t.Fatalf("Dashboard %s is not deleted", aws.StringValue(name))
}
}
}

const helloWorldCloudWatchDashboardWidget = `{
"widgets":[
{
"type":"text",
"x":0,
"y":7,
"width":3,
"height":3,
"properties":{
"markdown":"Hello world"
}
}
]
}`
35 changes: 35 additions & 0 deletions aws/cloudwatch_dashboard_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package aws

import (
awsgo "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/gruntwork-io/go-commons/errors"
)

// CloudWatchDashboards - represents all CloudWatch Dashboards that should be deleted.
type CloudWatchDashboards struct {
DashboardNames []string
}

// ResourceName - the simple name of the aws resource
func (cwdb CloudWatchDashboards) ResourceName() string {
return "cloudwatch-dashboard"
}

// ResourceIdentifiers - The instance ids of the ec2 instances
func (cwdb CloudWatchDashboards) ResourceIdentifiers() []string {
return cwdb.DashboardNames
}

func (cwdb CloudWatchDashboards) MaxBatchSize() int {
return 100
}

// Nuke - nuke 'em all!!!
Copy link
Contributor

Choose a reason for hiding this comment

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

😁

func (cwdb CloudWatchDashboards) Nuke(session *session.Session, identifiers []string) error {
if err := nukeAllCloudWatchDashboards(session, awsgo.StringSlice(identifiers)); err != nil {
return errors.WithStackTrace(err)
}

return nil
}
5 changes: 4 additions & 1 deletion aws/secrets_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,5 +128,8 @@ func TestNukeSecretMoreThanOne(t *testing.T) {
func createSecretStringWithDefaultKey(t *testing.T, awsRegion string, name string) string {
description := "Random secret created for cloud-nuke testing."
secretVal := random.UniqueId()
return terraws.CreateSecretStringWithDefaultKey(t, awsRegion, description, name, secretVal)
arn := terraws.CreateSecretStringWithDefaultKey(t, awsRegion, description, name, secretVal)
// Add an arbitrary sleep to account for eventual consistency
time.Sleep(15 * time.Second)
return arn
}
9 changes: 5 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type Config struct {
SecretsManagerSecrets ResourceType `yaml:"SecretsManager"`
NatGateway ResourceType `yaml:"NatGateway"`
AccessAnalyzer ResourceType `yaml:"AccessAnalyzer"`
CloudWatchDashboard ResourceType `yaml:"CloudWatchDashboard"`
}

type ResourceType struct {
Expand Down Expand Up @@ -81,17 +82,17 @@ func matches(name string, regexps []Expression) bool {

// ShouldInclude - Checks if a resource's name should be included according to the inclusion and exclusion rules
func ShouldInclude(name string, includeREs []Expression, excludeREs []Expression) bool {
// If no rules are defined, should always include
if len(includeREs) == 0 && len(excludeREs) == 0 {
// If no rules are defined, should always include
return true
// If a rule that exclude matches, should not include
} else if matches(name, excludeREs) {
// If a rule that exclude matches, should not include
return false
// Given the 'name' is not in the 'exclude' list, should include if there is no 'include' list
} else if len(includeREs) == 0 {
// Given the 'name' is not in the 'exclude' list, should include if there is no 'include' list
return true
// Given there is a 'include' list, and 'name' is there, should include
} else {
// Given there is a 'include' list, and 'name' is there, should include
return matches(name, includeREs)
}
}
Loading