-
-
Notifications
You must be signed in to change notification settings - Fork 356
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
Changes from all commits
924321e
392f231
ee5a879
f1cf960
cdaafce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
// 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." | ||
} |
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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" | ||
} | ||
} | ||
] | ||
}` |
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!!! | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!