From 5f96bd52686a91e2024ef7e9196ffab75d4905f3 Mon Sep 17 00:00:00 2001 From: James Kwon Date: Mon, 25 Mar 2024 19:47:21 -0400 Subject: [PATCH] implemented ec2-subnet nuke --- README.md | 3 +- aws/resource_registry.go | 1 + aws/resources/ec2_subnet.go | 156 ++++++++++++++++++++++++++++++ aws/resources/ec2_subnet_test.go | 127 ++++++++++++++++++++++++ aws/resources/ec2_subnet_types.go | 59 +++++++++++ config/config.go | 1 + config/config_test.go | 1 + 7 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 aws/resources/ec2_subnet.go create mode 100644 aws/resources/ec2_subnet_test.go create mode 100644 aws/resources/ec2_subnet_types.go diff --git a/README.md b/README.md index e82338f2d..3fa20cc0b 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 283b22b09..a0325facd 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 000000000..81c31021d --- /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 actualy 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_test.go b/aws/resources/ec2_subnet_test.go new file mode 100644 index 000000000..81101ed50 --- /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/aws/resources/ec2_subnet_types.go b/aws/resources/ec2_subnet_types.go new file mode 100644 index 000000000..fff9c4eba --- /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 fee881ebd..880a5ac30 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"` diff --git a/config/config_test.go b/config/config_test.go index d03ccd491..55015548e 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{}, ""},