Repository: gruntwork-io/cloud-nuke
Pull request #662 (2024-03-28 16:20:40)

Add IGW exclusion to not delete in phxdevops
<!-- Prepend '[WIP]' to the title if this PR is still a work-in-progress. Remove it when it is ready for review! -->

## Description

* Add name exclusion for IGW in phxdevops

<!-- Description of the changes introduced by this PR. -->

## 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

<!-- One-line description of the PR that can be included in the final release notes. -->
Added name exclusion for IGW in phxdevops


Patch:
diff --git a/.circleci/nuke_config.yml b/.circleci/nuke_config.yml
index 090f3e8..9f83e92 100644
--- a/.circleci/nuke_config.yml
+++ b/.circleci/nuke_config.yml
@@ -272,3 +272,11 @@ MSKCluster:
       - "^msk-cluster-[a-zA-Z0-9]{6}$"
       - "^msk-cluster-[a-zA-Z0-9]{6}-.*"
       - "^msk-tiered-storage-test.*"
+  
+InternetGateway:
+  exclude:
+    names_regex:
+      # Exclude the IGW for default VPCs in phxdevops. This IGW is attached to the default VPC
+      # and is used by tests that require internet access from within the VPC. Without it, any
+      # VPC-based test that requires internet will fail.
+      - "gw-new-default-igw"
diff --git a/README.md b/README.md
index e82338f..3fa20cc 100644
--- a/README.md
+++ b/README.md
@@ -545,10 +545,11 @@ of the file that are supported are listed here.
 | ec2-dedicated-hosts         | EC2DedicatedHosts            | ✅ (EC2 Name Tag)                      | ✅ (Allocation Time)                 | ❌    |    ❌   |
 | ec2-dhcp-option             | EC2DhcpOption                | ❌                                     | ❌                                   | ❌    |    ❌   |
 | ec2-keypairs                | EC2KeyPairs                  | ✅ (Key Pair Name)                     | ✅ (Creation Time)                   | ✅    |    ❌   |
-| ec2-ipam                	  | EC2IPAM			               | ✅ (IPAM name)                    	   | ✅ (Creation Time)                     | ✅    |    ❌   |
+| ec2-ipam                	  | EC2IPAM			                 | ✅ (IPAM name)                    	   | ✅ (Creation Time)                   | ✅    |    ❌   |
 | ec2-ipam-pool               | EC2IPAMPool			             | ✅ (IPAM Pool name)                    | ✅ (Creation Time)                   | ✅    |    ❌   |
 | ec2-ipam-resource-discovery | EC2IPAMResourceDiscovery		 | ✅ (IPAM Discovery Name)               | ✅ (Creation Time)                   | ✅    |    ❌   |
 | ec2-ipam-scope              | EC2IPAMScope		 						 | ✅ (IPAM Scope Name)               		 | ✅ (Creation Time)                   | ✅    |    ❌   |
+| ec2-subnet              		| EC2Subnet		 						     | ✅ (Subnet 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 283b22b..a0325fa 100644
--- a/aws/resource_registry.go
+++ b/aws/resource_registry.go
@@ -124,6 +124,7 @@ func getRegisteredRegionalResources() []AwsResource {
 		&resources.EC2IPAMPool{},
 		&resources.EC2IPAMByoasn{},
 		&resources.EC2IPAMCustomAllocation{},
+		&resources.EC2Subnet{},
 		&resources.Route53HostedZone{},
 		&resources.Route53CidrCollection{},
 		&resources.Route53TrafficPolicy{},
diff --git a/aws/resources/ec2_subnet.go b/aws/resources/ec2_subnet.go
new file mode 100644
index 0000000..6cab275
--- /dev/null
+++ b/aws/resources/ec2_subnet.go
@@ -0,0 +1,156 @@
+package resources
+
+import (
+	"context"
+	"time"
+
+	"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/cloud-nuke/util"
+	"github.com/gruntwork-io/go-commons/errors"
+)
+
+func (ec2subnet *EC2Subnet) setFirstSeenTag(sb ec2.Subnet, value time.Time) error {
+	_, err := ec2subnet.Client.CreateTags(&ec2.CreateTagsInput{
+		Resources: []*string{sb.SubnetId},
+		Tags: []*ec2.Tag{
+			{
+				Key:   aws.String(util.FirstSeenTagKey),
+				Value: aws.String(util.FormatTimestamp(value)),
+			},
+		},
+	})
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
+
+func (ec2subnet *EC2Subnet) getFirstSeenTag(sb ec2.Subnet) (*time.Time, error) {
+	tags := sb.Tags
+	for _, tag := range tags {
+		if util.IsFirstSeenTag(tag.Key) {
+			firstSeenTime, err := util.ParseTimestamp(tag.Value)
+			if err != nil {
+				return nil, errors.WithStackTrace(err)
+			}
+
+			return firstSeenTime, nil
+		}
+	}
+
+	return nil, nil
+}
+
+func shouldIncludeEc2Subnet(subnet *ec2.Subnet, firstSeenTime *time.Time, configObj config.Config) bool {
+	var subnetName string
+	tagMap := util.ConvertEC2TagsToMap(subnet.Tags)
+	if name, ok := tagMap["Name"]; ok {
+		subnetName = name
+	}
+
+	return configObj.EC2Subnet.ShouldInclude(config.ResourceValue{
+		Name: &subnetName,
+		Time: firstSeenTime,
+		Tags: tagMap,
+	})
+}
+
+// Returns a formatted string of EC2 subnets
+func (ec2subnet *EC2Subnet) getAll(_ context.Context, configObj config.Config) ([]*string, error) {
+	result := []*string{}
+	err := ec2subnet.Client.DescribeSubnetsPages(&ec2.DescribeSubnetsInput{}, func(pages *ec2.DescribeSubnetsOutput, lastPage bool) bool {
+		for _, subnet := range pages.Subnets {
+
+			// check first seen tag
+			firstSeenTime, err := ec2subnet.getFirstSeenTag(*subnet)
+			if err != nil {
+				logging.Errorf(
+					"Unable to retrieve tags for Subnet: %s, with error: %s", *subnet.SubnetId, err)
+				continue
+			}
+
+			// if the first seen tag is not there, then create one
+			if firstSeenTime == nil {
+				now := time.Now().UTC()
+				firstSeenTime = &now
+				if err := ec2subnet.setFirstSeenTag(*subnet, time.Now().UTC()); err != nil {
+					logging.Errorf(
+						"Unable to apply first seen tag Subnet: %s, with error: %s", *subnet.SubnetId, err)
+					continue
+				}
+			}
+
+			if shouldIncludeEc2Subnet(subnet, firstSeenTime, configObj) {
+				result = append(result, subnet.SubnetId)
+			}
+		}
+		return !lastPage
+	})
+
+	if err != nil {
+		return nil, errors.WithStackTrace(err)
+	}
+
+	// check the resources are nukable
+	ec2subnet.VerifyNukablePermissions(result, func(id *string) error {
+		params := &ec2.DeleteSubnetInput{
+			SubnetId: id,
+			DryRun:   aws.Bool(true), // dry run set as true , checks permission without actually making the request
+		}
+		_, err := ec2subnet.Client.DeleteSubnet(params)
+		return err
+	})
+
+	return result, nil
+}
+
+// Deletes all Subnets
+func (ec2subnet *EC2Subnet) nukeAll(ids []*string) error {
+	if len(ids) == 0 {
+		logging.Debugf("No Subnets to nuke in region %s", ec2subnet.Region)
+		return nil
+	}
+
+	logging.Debugf("Deleting all Subnets in region %s", ec2subnet.Region)
+	var deletedAddresses []*string
+
+	for _, id := range ids {
+		// check the id has the permission to nuke, if not. continue the execution
+		if nukable, err := ec2subnet.IsNukable(*id); !nukable {
+			// not adding the report on final result hence not adding a record entry here
+			// NOTE: We can skip the error checking and return it here, since it is already being checked while
+			// displaying the identifiers. Here, `err` refers to the error indicating whether the identifier is eligible for nuke or not,
+			// and it is not a programming error.
+			logging.Debugf("[Skipping] %s nuke because %v", *id, err)
+			continue
+		}
+
+		_, err := ec2subnet.Client.DeleteSubnet(&ec2.DeleteSubnetInput{
+			SubnetId: id,
+		})
+
+		// Record status of this resource
+		e := report.Entry{
+			Identifier:   aws.StringValue(id),
+			ResourceType: "Subnet",
+			Error:        err,
+		}
+		report.Record(e)
+
+		if err != nil {
+			logging.Debugf("[Failed] %s", err)
+		} else {
+			deletedAddresses = append(deletedAddresses, id)
+			logging.Debugf("Deleted Subnet: %s", *id)
+		}
+	}
+
+	logging.Debugf("[OK] %d EC2 Subnet(s) deleted in %s", len(deletedAddresses), ec2subnet.Region)
+
+	return nil
+}
diff --git a/aws/resources/ec2_subnet_types.go b/aws/resources/ec2_subnet_types.go
new file mode 100644
index 0000000..fff9c4e
--- /dev/null
+++ b/aws/resources/ec2_subnet_types.go
@@ -0,0 +1,59 @@
+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/ec2"
+	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/go-commons/errors"
+)
+
+// Ec2Subnet- represents all Subnets
+type EC2Subnet struct {
+	BaseAwsResource
+	Client  ec2iface.EC2API
+	Region  string
+	Subnets []string
+}
+
+func (es *EC2Subnet) Init(session *session.Session) {
+	es.BaseAwsResource.Init(session)
+	es.Client = ec2.New(session)
+}
+
+// ResourceName - the simple name of the aws resource
+func (es *EC2Subnet) ResourceName() string {
+	return "ec2-subnet"
+}
+
+func (es *EC2Subnet) MaxBatchSize() int {
+	// Tentative batch size to ensure AWS doesn't throttle
+	return 49
+}
+
+// ResourceIdentifiers - The ids of the subnets
+func (es *EC2Subnet) ResourceIdentifiers() []string {
+	return es.Subnets
+}
+
+func (es *EC2Subnet) GetAndSetIdentifiers(c context.Context, configObj config.Config) ([]string, error) {
+	identifiers, err := es.getAll(c, configObj)
+	if err != nil {
+		return nil, err
+	}
+
+	es.Subnets = awsgo.StringValueSlice(identifiers)
+	return es.Subnets, nil
+}
+
+// Nuke - nuke 'em all!!!
+func (es *EC2Subnet) Nuke(identifiers []string) error {
+	if err := es.nukeAll(awsgo.StringSlice(identifiers)); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
diff --git a/config/config.go b/config/config.go
index fee881e..880a5ac 100644
--- a/config/config.go
+++ b/config/config.go
@@ -44,6 +44,7 @@ type Config struct {
 	EC2IPAMPool                     ResourceType               `yaml:"EC2IPAMPool"`
 	EC2IPAMResourceDiscovery        ResourceType               `yaml:"EC2IPAMResourceDiscovery"`
 	EC2IPAMScope                    ResourceType               `yaml:"EC2IPAMScope"`
+	EC2Subnet                       ResourceType               `yaml:"EC2Subnet"`
 	ECRRepository                   ResourceType               `yaml:"ECRRepository"`
 	ECSCluster                      ResourceType               `yaml:"ECSCluster"`
 	ECSService                      ResourceType               `yaml:"ECSService"`


Test patch:
diff --git a/aws/resources/ec2_subnet_test.go b/aws/resources/ec2_subnet_test.go
new file mode 100644
index 0000000..81101ed
--- /dev/null
+++ b/aws/resources/ec2_subnet_test.go
@@ -0,0 +1,127 @@
+package resources
+
+import (
+	"context"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/aws/aws-sdk-go/service/ec2/ec2iface"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/cloud-nuke/telemetry"
+	"github.com/gruntwork-io/cloud-nuke/util"
+	"github.com/stretchr/testify/require"
+)
+
+type mockedEC2Subnets struct {
+	ec2iface.EC2API
+	DescribeSubnetsOutput ec2.DescribeSubnetsOutput
+	DeleteSubnetOutput    ec2.DeleteSubnetOutput
+}
+
+func (m mockedEC2Subnets) DescribeSubnetsPages(_ *ec2.DescribeSubnetsInput, callback func(pages *ec2.DescribeSubnetsOutput, lastPage bool) bool) error {
+	callback(&m.DescribeSubnetsOutput, true)
+	return nil
+}
+func (m mockedEC2Subnets) DeleteSubnet(_ *ec2.DeleteSubnetInput) (*ec2.DeleteSubnetOutput, error) {
+	return &m.DeleteSubnetOutput, nil
+}
+
+func TestEc2Subnets_GetAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+	t.Parallel()
+
+	var (
+		now       = time.Now()
+		subnet1   = "subnet-0631b58700ba3db41"
+		testName1 = "cloud-nuke-subnet-001"
+		subnet2   = "subnet-0631b58700ba3db42"
+		testName2 = "cloud-nuke-subnet-002"
+	)
+
+	ec2subnet := EC2Subnet{
+		Client: mockedEC2Subnets{
+			DescribeSubnetsOutput: ec2.DescribeSubnetsOutput{
+				Subnets: []*ec2.Subnet{
+					{
+						SubnetId: aws.String(subnet1),
+						Tags: []*ec2.Tag{
+							{
+								Key:   aws.String("Name"),
+								Value: aws.String(testName1),
+							}, {
+								Key:   aws.String(util.FirstSeenTagKey),
+								Value: aws.String(util.FormatTimestamp(now)),
+							},
+						},
+					},
+					{
+						SubnetId: aws.String(subnet2),
+						Tags: []*ec2.Tag{
+							{
+								Key:   aws.String("Name"),
+								Value: aws.String(testName2),
+							}, {
+								Key:   aws.String(util.FirstSeenTagKey),
+								Value: aws.String(util.FormatTimestamp(now.Add(1))),
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	ec2subnet.BaseAwsResource.Init(nil)
+
+	tests := map[string]struct {
+		configObj config.ResourceType
+		expected  []string
+	}{
+		"emptyFilter": {
+			configObj: config.ResourceType{},
+			expected:  []string{subnet1, subnet2},
+		},
+		"nameExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					NamesRegExp: []config.Expression{{
+						RE: *regexp.MustCompile(testName1),
+					}}},
+			},
+			expected: []string{subnet2},
+		},
+		"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 := ec2subnet.getAll(context.Background(), config.Config{
+				EC2Subnet: tc.configObj,
+			})
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, aws.StringValueSlice(names))
+		})
+	}
+
+}
+
+func TestEc2Subnet_NukeAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+	t.Parallel()
+
+	tgw := EC2Subnet{
+		Client: mockedEC2Subnets{
+			DeleteSubnetOutput: ec2.DeleteSubnetOutput{},
+		},
+	}
+
+	err := tgw.nukeAll([]*string{aws.String("test-gateway")})
+	require.NoError(t, err)
+}
diff --git a/config/config_test.go b/config/config_test.go
index d03ccd4..5501554 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -59,6 +59,7 @@ func emptyConfig() *Config {
 		ResourceType{FilterRule{}, FilterRule{}, ""},
 		ResourceType{FilterRule{}, FilterRule{}, ""},
 		ResourceType{FilterRule{}, FilterRule{}, ""},
+		ResourceType{FilterRule{}, FilterRule{}, ""},
 		KMSCustomerKeyResourceType{false, ResourceType{FilterRule{}, FilterRule{}, ""}},
 		ResourceType{FilterRule{}, FilterRule{}, ""},
 		ResourceType{FilterRule{}, FilterRule{}, ""},