diff --git a/README.md b/README.md index 3fa20cc0..04644601 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Cloud-nuke suppports 🔎 inspecting and 🔥💀 deleting the following AWS res | EC2 | IPAM BYOASN | | EC2 | IPAM Resource Discovery | | EC2 | Internet Gateway | +| EC2 | Egress only internet gateway | | Certificate Manager | ACM Private CA | | Direct Connect | Transit Gateways | | Elasticache | Clusters | @@ -568,6 +569,7 @@ of the file that are supported are listed here. | iam-service-linked-role | IAMServiceLinkedRoles | ✅ (Service Linked Role Name) | ✅ (Creation Time) | ❌ | ❌ | | iam | IAMUsers | ✅ (User Name) | ✅ (Creation Time) | ✅ | ❌ | | internet-gateway | InternetGateway | ✅ (Gateway Name) | ✅ (Creation Time) | ✅ | ❌ | +| egress-only-internet-gateway| EgressOnlyInternetGateway | ✅ (Gateway name) | ✅ (Creation Time) | ✅ | ❌ | | kmscustomerkeys | KMSCustomerKeys | ✅ (Key Name) | ✅ (Creation Time) | ❌ | ❌ | | kinesis-stream | KinesisStream | ✅ (Stream Name) | ❌ | ❌ | ❌ | | lambda | LambdaFunction | ✅ (Function Name) | ✅ (Last Modified Time) | ❌ | ❌ | diff --git a/aws/resource_registry.go b/aws/resource_registry.go index a0325fac..6800e252 100644 --- a/aws/resource_registry.go +++ b/aws/resource_registry.go @@ -78,6 +78,7 @@ func getRegisteredRegionalResources() []AwsResource { &resources.ECR{}, &resources.ECSClusters{}, &resources.ECSServices{}, + &resources.EgressOnlyInternetGateway{}, &resources.ElasticFileSystem{}, &resources.EIPAddresses{}, &resources.EKSClusters{}, diff --git a/aws/resources/ec2_egress_only_igw.go b/aws/resources/ec2_egress_only_igw.go new file mode 100644 index 00000000..efb8960c --- /dev/null +++ b/aws/resources/ec2_egress_only_igw.go @@ -0,0 +1,150 @@ +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 (egigw *EgressOnlyInternetGateway) setFirstSeenTag(eoig ec2.EgressOnlyInternetGateway, value time.Time) error { + _, err := egigw.Client.CreateTags(&ec2.CreateTagsInput{ + Resources: []*string{eoig.EgressOnlyInternetGatewayId}, + Tags: []*ec2.Tag{ + { + Key: aws.String(util.FirstSeenTagKey), + Value: aws.String(util.FormatTimestamp(value)), + }, + }, + }) + if err != nil { + return errors.WithStackTrace(err) + } + + return nil +} + +func (egigw *EgressOnlyInternetGateway) getFirstSeenTag(eoig ec2.EgressOnlyInternetGateway) (*time.Time, error) { + tags := eoig.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 shouldIncludeEgressOnlyInternetGateway(gateway *ec2.EgressOnlyInternetGateway, firstSeenTime *time.Time, configObj config.Config) bool { + var gatewayName string + // get the tags as map + tagMap := util.ConvertEC2TagsToMap(gateway.Tags) + if name, ok := tagMap["Name"]; ok { + gatewayName = name + } + return configObj.EgressOnlyInternetGateway.ShouldInclude(config.ResourceValue{ + Name: &gatewayName, + Tags: tagMap, + Time: firstSeenTime, + }) +} + +func (egigw *EgressOnlyInternetGateway) getAll(_ context.Context, configObj config.Config) ([]*string, error) { + var result []*string + + output, err := egigw.Client.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{}) + if err != nil { + return nil, errors.WithStackTrace(err) + } + + for _, igw := range output.EgressOnlyInternetGateways { + // check first seen tag + firstSeenTime, err := egigw.getFirstSeenTag(*igw) + if err != nil { + logging.Errorf( + "Unable to retrieve tags for Egress IGW: %s, with error: %s", *igw.EgressOnlyInternetGatewayId, err) + continue + } + + // if the first seen tag is not there, then create one + if firstSeenTime == nil { + now := time.Now().UTC() + firstSeenTime = &now + if err := egigw.setFirstSeenTag(*igw, time.Now().UTC()); err != nil { + logging.Errorf( + "Unable to apply first seen tag Egress IGW: %s, with error: %s", *igw.EgressOnlyInternetGatewayId, err) + continue + } + } + + if shouldIncludeEgressOnlyInternetGateway(igw, firstSeenTime, configObj) { + result = append(result, igw.EgressOnlyInternetGatewayId) + } + } + + // checking the nukable permissions + egigw.VerifyNukablePermissions(result, func(id *string) error { + _, err := egigw.Client.DeleteEgressOnlyInternetGateway(&ec2.DeleteEgressOnlyInternetGatewayInput{ + EgressOnlyInternetGatewayId: id, + DryRun: aws.Bool(true), + }) + return err + }) + + return result, nil +} + +func (egigw *EgressOnlyInternetGateway) nukeAll(ids []*string) error { + if len(ids) == 0 { + logging.Debugf("No Egress only internet gateway ID's to nuke in region %s", egigw.Region) + return nil + } + + logging.Debugf("Deleting all Egress only internet gateway in region %s", egigw.Region) + var deletedList []*string + + for _, id := range ids { + // NOTE : We can skip the error checking and return it here, since it is already being checked while displaying the identifiers with the Nukable field. + // Here, `err` refers to the error indicating whether the identifier is eligible for nuke or not (an error which we got from aws when tried to delete the resource with dryRun), + // and it is not a programming error. (edited) + if nukable, err := egigw.IsNukable(*id); !nukable { + logging.Debugf("[Skipping] %s nuke because %v", *id, err) + continue + } + + _, err := egigw.Client.DeleteEgressOnlyInternetGateway(&ec2.DeleteEgressOnlyInternetGatewayInput{ + EgressOnlyInternetGatewayId: id, + }) + + // Record status of this resource + e := report.Entry{ + Identifier: aws.StringValue(id), + ResourceType: "Egress Only Internet Gateway", + Error: err, + } + report.Record(e) + + if err != nil { + logging.Debugf("[Failed] %s", err) + } else { + deletedList = append(deletedList, id) + logging.Debugf("Deleted egress only internet gateway: %s", *id) + } + } + + logging.Debugf("[OK] %d Egress only internet gateway(s) deleted in %s", len(deletedList), egigw.Region) + + return nil + +} diff --git a/aws/resources/ec2_egress_only_igw_test.go b/aws/resources/ec2_egress_only_igw_test.go new file mode 100644 index 00000000..1364e7aa --- /dev/null +++ b/aws/resources/ec2_egress_only_igw_test.go @@ -0,0 +1,171 @@ +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 mockedEgressOnlyIgw struct { + BaseAwsResource + ec2iface.EC2API + DescribeEgressOnlyInternetGatewaysOutput ec2.DescribeEgressOnlyInternetGatewaysOutput + DeleteEgressOnlyInternetGatewayOutput ec2.DeleteEgressOnlyInternetGatewayOutput +} + +func (m mockedEgressOnlyIgw) DescribeEgressOnlyInternetGateways(_ *ec2.DescribeEgressOnlyInternetGatewaysInput) (*ec2.DescribeEgressOnlyInternetGatewaysOutput, error) { + return &m.DescribeEgressOnlyInternetGatewaysOutput, nil +} + +func (m mockedEgressOnlyIgw) DeleteEgressOnlyInternetGateway(_ *ec2.DeleteEgressOnlyInternetGatewayInput) (*ec2.DeleteEgressOnlyInternetGatewayOutput, error) { + return &m.DeleteEgressOnlyInternetGatewayOutput, nil +} + +func TestEgressOnlyInternetGateway_GetAll(t *testing.T) { + telemetry.InitTelemetry("cloud-nuke", "") + t.Parallel() + + var ( + now = time.Now() + gateway1 = "igw-0b44cfa6103932e1d001" + gateway2 = "igw-0b44cfa6103932e1d002" + + testName1 = "cloud-nuke-igw-001" + testName2 = "cloud-nuke-igw-002" + ) + object := EgressOnlyInternetGateway{ + Client: mockedEgressOnlyIgw{ + DescribeEgressOnlyInternetGatewaysOutput: ec2.DescribeEgressOnlyInternetGatewaysOutput{ + EgressOnlyInternetGateways: []*ec2.EgressOnlyInternetGateway{ + { + EgressOnlyInternetGatewayId: aws.String(gateway1), + Tags: []*ec2.Tag{ + { + Key: aws.String("Name"), + Value: aws.String(testName1), + }, { + Key: aws.String(util.FirstSeenTagKey), + Value: aws.String(util.FormatTimestamp(now)), + }, + }, + }, + { + EgressOnlyInternetGatewayId: aws.String(gateway2), + Tags: []*ec2.Tag{ + { + Key: aws.String("Name"), + Value: aws.String(testName2), + }, { + Key: aws.String(util.FirstSeenTagKey), + Value: aws.String(util.FormatTimestamp(now.Add(1 * time.Hour))), + }, + }, + }, + }, + }, + }, + } + object.BaseAwsResource.Init(nil) + + tests := map[string]struct { + configObj config.ResourceType + expected []string + }{ + "emptyFilter": { + configObj: config.ResourceType{}, + expected: []string{gateway1, gateway2}, + }, + "nameExclusionFilter": { + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + NamesRegExp: []config.Expression{{ + RE: *regexp.MustCompile(testName1), + }}}, + }, + expected: []string{gateway2}, + }, + "timeAfterExclusionFilter": { + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + TimeAfter: aws.Time(now), + }}, + expected: []string{gateway1}, + }, + "timeBeforeExclusionFilter": { + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + TimeBefore: aws.Time(now.Add(1)), + }}, + expected: []string{gateway2}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + names, err := object.getAll(context.Background(), config.Config{ + EgressOnlyInternetGateway: tc.configObj, + }) + require.NoError(t, err) + require.Equal(t, tc.expected, aws.StringValueSlice(names)) + }) + } + +} + +func TestEc2EgressOnlyInternetGateway_NukeAll(t *testing.T) { + telemetry.InitTelemetry("cloud-nuke", "") + t.Parallel() + + var ( + gateway1 = "igw-0b44cfa6103932e1d001" + gateway2 = "igw-0b44cfa6103932e1d002" + ) + + igw := EgressOnlyInternetGateway{ + BaseAwsResource: BaseAwsResource{ + Nukables: map[string]error{ + gateway1: nil, + }, + }, + Client: mockedEgressOnlyIgw{ + DescribeEgressOnlyInternetGatewaysOutput: ec2.DescribeEgressOnlyInternetGatewaysOutput{ + EgressOnlyInternetGateways: []*ec2.EgressOnlyInternetGateway{ + { + EgressOnlyInternetGatewayId: aws.String(gateway1), + Attachments: []*ec2.InternetGatewayAttachment{ + { + State: aws.String("testing-state"), + VpcId: aws.String("test-gateway-vpc"), + }, + }, + }, + { + EgressOnlyInternetGatewayId: aws.String(gateway2), + Attachments: []*ec2.InternetGatewayAttachment{ + { + State: aws.String("testing-state"), + VpcId: aws.String("test-gateway-vpc"), + }, + }, + }, + }, + }, + DeleteEgressOnlyInternetGatewayOutput: ec2.DeleteEgressOnlyInternetGatewayOutput{}, + }, + } + + err := igw.nukeAll([]*string{ + aws.String(gateway1), + aws.String(gateway2), + }) + require.NoError(t, err) +} diff --git a/aws/resources/ec2_egress_only_igw_types.go b/aws/resources/ec2_egress_only_igw_types.go new file mode 100644 index 00000000..727c9f51 --- /dev/null +++ b/aws/resources/ec2_egress_only_igw_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" +) + +// EgressOnlyInternetGateway represents all Egress only internet gateway +type EgressOnlyInternetGateway struct { + BaseAwsResource + Client ec2iface.EC2API + Region string + Pools []string +} + +func (egigw *EgressOnlyInternetGateway) Init(session *session.Session) { + egigw.BaseAwsResource.Init(session) + egigw.Client = ec2.New(session) +} + +// ResourceName - the simple name of the aws resource +func (egigw *EgressOnlyInternetGateway) ResourceName() string { + return "egress-only-internet-gateway" +} + +func (egigw *EgressOnlyInternetGateway) MaxBatchSize() int { + // Tentative batch size to ensure AWS doesn't throttle + return 49 +} + +// ResourceIdentifiers - The ids of the Egress only igw +func (egigw *EgressOnlyInternetGateway) ResourceIdentifiers() []string { + return egigw.Pools +} + +func (egigw *EgressOnlyInternetGateway) GetAndSetIdentifiers(c context.Context, configObj config.Config) ([]string, error) { + identifiers, err := egigw.getAll(c, configObj) + if err != nil { + return nil, err + } + + egigw.Pools = awsgo.StringValueSlice(identifiers) + return egigw.Pools, nil +} + +// Nuke - nuke 'em all!!! +func (egigw *EgressOnlyInternetGateway) Nuke(identifiers []string) error { + if err := egigw.nukeAll(awsgo.StringSlice(identifiers)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/config/config.go b/config/config.go index 880a5ac3..922ca7c4 100644 --- a/config/config.go +++ b/config/config.go @@ -45,6 +45,7 @@ type Config struct { EC2IPAMResourceDiscovery ResourceType `yaml:"EC2IPAMResourceDiscovery"` EC2IPAMScope ResourceType `yaml:"EC2IPAMScope"` EC2Subnet ResourceType `yaml:"EC2Subnet"` + EgressOnlyInternetGateway ResourceType `yaml:"EgressOnlyInternetGateway"` 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 55015548..086ecc31 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -60,6 +60,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{}, ""},