Repository: gruntwork-io/cloud-nuke
Pull request #615 (2023-10-24 12:37:32)

Small improvements for exclude tags feature
## Description

Fix some errors in README.

Also add one additional resource type, because I use it.

## TODOs

Read the [Gruntwork contribution guidelines](https://gruntwork.notion.site/Gruntwork-Coding-Methodology-02fdcd6e4b004e818553684760bf691e).

- [X] Update the docs.
- [x] Run the relevant tests successfully, including pre-commit checks.
- [X] Ensure any 3rd party code adheres with our [license policy](https://www.notion.so/gruntwork/Gruntwork-licenses-and-open-source-usage-policy-f7dece1f780341c7b69c1763f22b1378) or delete this line if its not applicable.
- [X] Include release notes. If this PR is backward incompatible, include a migration guide.
- [X] Attention Grunts - if this PR adds support for a new resource, ensure the `nuke_sandbox` and `nuke_phxdevops` jobs in `.circleci/config.yml` have been updated with appropriate exclusions (either directly in the job or via the `.circleci/nuke_config.yml` file) to prevent nuking IAM roles, groups, resources, etc that are important for the test accounts.


## Release Notes (draft)

N/A, only extends new feature.

### Migration Guide

N/A




Patch:
diff --git a/README.md b/README.md
index 8b42fe5..8ddbcb8 100644
--- a/README.md
+++ b/README.md
@@ -428,14 +428,14 @@ s3:
 
 #### Tag Filter
 
-You can also exclude resources by tags. The following config will delete all s3 buckets that have a tag with key `foo`
+You can also exclude resources by tags. The following config will exclude all s3 buckets that have a tag with key `foo` and value `true` (case-insensitive).
 ```yaml
 s3:
   exclude:
     tag: 'foo'
 ```
 
-By default, it will use the exclusion default tag: `cloud-nuke-excluded` to exclude resources. 
+By default, it will use the exclusion default tag: `cloud-nuke-excluded` to exclude resources.
 _Note: it doesn't support including resources by tags._
 
 ### What's supported?
@@ -468,7 +468,7 @@ of the file that are supported are listed here.
 | ec2                         | EC2                          | ✅ (Instance Name)                     | ✅ (Launch Time)                     | ✅    |
 | ec2-dedicated-hosts         | EC2DedicatedHosts            | ✅ (EC2 Name Tag)                      | ✅ (Allocation Time)                 | ❌    |
 | ec2-dhcp-option             | EC2DhcpOption                | ❌                                     | ❌                                   | ❌    |
-| ec2-keypairs                | EC2KeyPairs                  | ✅ (Key Pair Name)                     | ✅ (Creation Time)                   | ❌    |
+| ec2-keypairs                | EC2KeyPairs                  | ✅ (Key Pair Name)                     | ✅ (Creation Time)                   | ✅    |
 | ecr                         | ECRRepository                | ✅ (Repository Name)                   | ✅ (Creation Time)                   | ❌    |
 | ecscluster                  | ECSCluster                   | ✅ (Cluster Name)                      | ❌                                   | ❌    |
 | ecsserv                     | ECSService                   | ✅ (Service Name)                      | ✅ (Creation Time)                   | ❌    |
diff --git a/aws/resource_registry.go b/aws/resource_registry.go
index 43ebb47..d6301a6 100644
--- a/aws/resource_registry.go
+++ b/aws/resource_registry.go
@@ -92,6 +92,7 @@ func getRegisteredRegionalResources() []AwsResources {
 		&resources.DBInstances{},
 		&resources.DBSubnetGroups{},
 		&resources.DBClusters{},
+		&resources.RdsSnapshot{},
 		&resources.RedshiftClusters{},
 		&resources.S3Buckets{},
 		&resources.SageMakerNotebookInstances{},
diff --git a/aws/resources/ec2_dhcp_option.go b/aws/resources/ec2_dhcp_option.go
index c09ea0a..12e22e4 100644
--- a/aws/resources/ec2_dhcp_option.go
+++ b/aws/resources/ec2_dhcp_option.go
@@ -2,9 +2,11 @@ package resources
 
 import (
 	"context"
-	"fmt"
+	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/service/ec2"
 	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/cloud-nuke/logging"
+	"github.com/gruntwork-io/cloud-nuke/report"
 	"github.com/gruntwork-io/go-commons/errors"
 	"github.com/pterm/pterm"
 )
@@ -35,9 +37,18 @@ func (v *EC2DhcpOption) nukeAll(identifiers []*string) error {
 			DhcpOptionsId: identifier,
 		})
 		if err != nil {
-			pterm.Debug.Println(fmt.Sprintf("Failed to delete DHCP option w/ err: %s.", err))
-			return errors.WithStackTrace(err)
+			logging.Debugf("Failed to delete DHCP option w/ err: %s.", err)
+		} else {
+			logging.Infof("Successfully deleted DHCP option %s.", pterm.Green(*identifier))
 		}
+
+		// Record status of this resource
+		e := report.Entry{
+			Identifier:   aws.StringValue(identifier),
+			ResourceType: v.ResourceName(),
+			Error:        err,
+		}
+		report.Record(e)
 	}
 
 	return nil
diff --git a/aws/resources/ec2_key_pair.go b/aws/resources/ec2_key_pair.go
index 4737adb..66fc833 100644
--- a/aws/resources/ec2_key_pair.go
+++ b/aws/resources/ec2_key_pair.go
@@ -6,6 +6,7 @@ import (
 	"github.com/gruntwork-io/cloud-nuke/config"
 	"github.com/gruntwork-io/cloud-nuke/logging"
 	"github.com/gruntwork-io/cloud-nuke/telemetry"
+	"github.com/gruntwork-io/cloud-nuke/util"
 	commonTelemetry "github.com/gruntwork-io/go-commons/telemetry"
 	"github.com/gruntwork-io/gruntwork-cli/errors"
 	"github.com/hashicorp/go-multierror"
@@ -23,6 +24,7 @@ func (k *EC2KeyPairs) getAll(c context.Context, configObj config.Config) ([]*str
 		if configObj.EC2KeyPairs.ShouldInclude(config.ResourceValue{
 			Name: keyPair.KeyName,
 			Time: keyPair.CreateTime,
+			Tags: util.ConvertEC2TagsToMap(keyPair.Tags),
 		}) {
 			ids = append(ids, keyPair.KeyPairId)
 		}
diff --git a/aws/resources/rds_snapshot.go b/aws/resources/rds_snapshot.go
new file mode 100644
index 0000000..1cf64bd
--- /dev/null
+++ b/aws/resources/rds_snapshot.go
@@ -0,0 +1,58 @@
+package resources
+
+import (
+	"context"
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/rds"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/cloud-nuke/logging"
+	"github.com/gruntwork-io/cloud-nuke/report"
+	"github.com/gruntwork-io/cloud-nuke/util"
+)
+
+func (snapshot *RdsSnapshot) getAll(c context.Context, configObj config.Config) ([]*string, error) {
+	var identifiers []*string
+	err := snapshot.Client.DescribeDBSnapshotsPages(&rds.DescribeDBSnapshotsInput{}, func(page *rds.DescribeDBSnapshotsOutput, lastPage bool) bool {
+		for _, s := range page.DBSnapshots {
+			if configObj.RdsSnapshot.ShouldInclude(config.ResourceValue{
+				Name: s.DBSnapshotIdentifier,
+				Time: s.SnapshotCreateTime,
+				Tags: util.ConvertRDSTagsToMap(s.TagList),
+			}) {
+				identifiers = append(identifiers, s.DBSnapshotIdentifier)
+			}
+		}
+
+		return !lastPage
+	})
+	if err != nil {
+		logging.Debugf("[RDS Snapshot] Failed to list snapshots: %s", err)
+		return nil, err
+	}
+
+	return identifiers, nil
+}
+
+func (snapshot *RdsSnapshot) nukeAll(identifiers []*string) error {
+	for _, identifier := range identifiers {
+		logging.Debugf("[RDS Snapshot] Deleting %s in region %s", *identifier, snapshot.Region)
+		_, err := snapshot.Client.DeleteDBSnapshot(&rds.DeleteDBSnapshotInput{
+			DBSnapshotIdentifier: identifier,
+		})
+		if err != nil {
+			logging.Errorf("[RDS Snapshot] Error deleting RDS Snapshot %s: %s", *identifier, err)
+		} else {
+			logging.Debugf("[RDS Snapshot] Deleted RDS Snapshot %s", *identifier)
+		}
+
+		// Record status of this resource
+		e := report.Entry{
+			Identifier:   aws.StringValue(identifier),
+			ResourceType: snapshot.ResourceName(),
+			Error:        err,
+		}
+		report.Record(e)
+	}
+
+	return nil
+}
diff --git a/aws/resources/rds_snapshot_types.go b/aws/resources/rds_snapshot_types.go
new file mode 100644
index 0000000..6ba9134
--- /dev/null
+++ b/aws/resources/rds_snapshot_types.go
@@ -0,0 +1,52 @@
+package resources
+
+import (
+	"context"
+	awsgo "github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/rds"
+	"github.com/aws/aws-sdk-go/service/rds/rdsiface"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/go-commons/errors"
+)
+
+type RdsSnapshot struct {
+	Client      rdsiface.RDSAPI
+	Region      string
+	Identifiers []string
+}
+
+func (snapshot *RdsSnapshot) Init(session *session.Session) {
+	snapshot.Client = rds.New(session)
+}
+
+func (snapshot *RdsSnapshot) ResourceName() string {
+	return "rds-snapshot"
+}
+
+func (snapshot *RdsSnapshot) ResourceIdentifiers() []string {
+	return snapshot.Identifiers
+}
+
+func (snapshot *RdsSnapshot) MaxBatchSize() int {
+	return 49
+}
+
+func (snapshot *RdsSnapshot) GetAndSetIdentifiers(c context.Context, configObj config.Config) ([]string, error) {
+	identifiers, err := snapshot.getAll(c, configObj)
+	if err != nil {
+		return nil, err
+	}
+
+	snapshot.Identifiers = awsgo.StringValueSlice(identifiers)
+	return snapshot.Identifiers, nil
+}
+
+// Nuke - nuke 'em all!!!
+func (snapshot *RdsSnapshot) Nuke(identifiers []string) error {
+	if err := snapshot.nukeAll(awsgo.StringSlice(identifiers)); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
diff --git a/config/config.go b/config/config.go
index b530605..a1d5f18 100644
--- a/config/config.go
+++ b/config/config.go
@@ -67,6 +67,7 @@ type Config struct {
 	OIDCProvider                 ResourceType               `yaml:"OIDCProvider"`
 	OpenSearchDomain             ResourceType               `yaml:"OpenSearchDomain"`
 	Redshift                     ResourceType               `yaml:"Redshift"`
+	RdsSnapshot                  ResourceType               `yaml:"RdsSnapshot"`
 	S3                           ResourceType               `yaml:"s3"`
 	SNS                          ResourceType               `yaml:"SNS"`
 	SQS                          ResourceType               `yaml:"SQS"`


Test patch:
diff --git a/aws/resources/rds_snapshot_test.go b/aws/resources/rds_snapshot_test.go
new file mode 100644
index 0000000..144b18c
--- /dev/null
+++ b/aws/resources/rds_snapshot_test.go
@@ -0,0 +1,105 @@
+package resources
+
+import (
+	"context"
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/rds"
+	"github.com/aws/aws-sdk-go/service/rds/rdsiface"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/cloud-nuke/telemetry"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"regexp"
+	"testing"
+	"time"
+)
+
+type mockedRdsSnapshot struct {
+	rdsiface.RDSAPI
+	DescribeDBSnapshotsPagesOutput rds.DescribeDBSnapshotsOutput
+	DeleteDBSnapshotOutput         rds.DeleteDBSnapshotOutput
+}
+
+func (m mockedRdsSnapshot) DescribeDBSnapshotsPages(input *rds.DescribeDBSnapshotsInput, callback func(*rds.DescribeDBSnapshotsOutput, bool) bool) error {
+	callback(&m.DescribeDBSnapshotsPagesOutput, true)
+	return nil
+}
+
+func (m mockedRdsSnapshot) DeleteDBSnapshot(input *rds.DeleteDBSnapshotInput) (*rds.DeleteDBSnapshotOutput, error) {
+	return &m.DeleteDBSnapshotOutput, nil
+}
+
+func TestRdsSnapshot_GetAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+	t.Parallel()
+
+	testName1 := "test-name1"
+	testName2 := "test-name2"
+	now := time.Now()
+	snapshot := RdsSnapshot{
+		Client: mockedRdsSnapshot{
+			DescribeDBSnapshotsPagesOutput: rds.DescribeDBSnapshotsOutput{
+				DBSnapshots: []*rds.DBSnapshot{
+					{
+						DBSnapshotIdentifier: &testName1,
+						SnapshotCreateTime:   &now,
+					},
+					{
+						DBSnapshotIdentifier: &testName2,
+						SnapshotCreateTime:   aws.Time(now.Add(1)),
+					},
+				},
+			},
+		},
+	}
+
+	tests := map[string]struct {
+		configObj config.ResourceType
+		expected  []string
+	}{
+		"emptyFilter": {
+			configObj: config.ResourceType{},
+			expected:  []string{testName1, testName2},
+		},
+		"nameExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					NamesRegExp: []config.Expression{{
+						RE: *regexp.MustCompile(testName1),
+					}}},
+			},
+			expected: []string{testName2},
+		},
+		"timeAfterExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					TimeAfter: aws.Time(now.Add(-1 * time.Hour)),
+				}},
+			expected: []string{},
+		},
+	}
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			names, err := snapshot.getAll(context.Background(), config.Config{
+				RdsSnapshot: tc.configObj,
+			})
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, aws.StringValueSlice(names))
+		})
+	}
+}
+
+func TestRdsSnapshot_NukeAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+	t.Parallel()
+
+	testName := "test-db-cluster"
+	snapshot := RdsSnapshot{
+		Client: mockedRdsSnapshot{
+			DeleteDBSnapshotOutput: rds.DeleteDBSnapshotOutput{},
+		},
+	}
+
+	err := snapshot.nukeAll([]*string{&testName})
+	assert.NoError(t, err)
+}
diff --git a/config/config_test.go b/config/config_test.go
index 59b7374..bec4e31 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -76,6 +76,7 @@ func emptyConfig() *Config {
 		ResourceType{FilterRule{}, FilterRule{}},
 		ResourceType{FilterRule{}, FilterRule{}},
 		ResourceType{FilterRule{}, FilterRule{}},
+		ResourceType{FilterRule{}, FilterRule{}},
 	}
 }