diff --git a/README.md b/README.md index fee6f8dc..ba50318d 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Cloud-nuke suppports 🔎 inspecting and 🔥💀 deleting the following AWS res | EC2 | Endpoint | | EC2 | Security Group | | EC2 | Network Interface | +| EC2 | Placement Group | | Certificate Manager | ACM Private CA | | Direct Connect | Transit Gateways | | Elasticache | Clusters | @@ -577,6 +578,7 @@ of the file that are supported are listed here. | 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-placement-groups | EC2PlacementGroups | ✅ (Placement Group Name) | ✅ (First Seen Tag Time) | ✅ | ✅ | | ec2-subnet | EC2Subnet | ✅ (Subnet Name) | ✅ (Creation Time) | ✅ | ❌ | | ec2-endpoint | EC2Endpoint | ✅ (Endpoint Name) | ✅ (Creation Time) | ✅ | ✅ | | ecr | ECRRepository | ✅ (Repository Name) | ✅ (Creation Time) | ❌ | ✅ | diff --git a/aws/resource_registry.go b/aws/resource_registry.go index fe1c4f48..de4475ff 100644 --- a/aws/resource_registry.go +++ b/aws/resource_registry.go @@ -75,6 +75,7 @@ func getRegisteredRegionalResources() []AwsResource { &resources.EC2Instances{}, &resources.EC2DedicatedHosts{}, &resources.EC2KeyPairs{}, + &resources.EC2PlacementGroups{}, &resources.TransitGateways{}, &resources.TransitGatewaysRouteTables{}, // Note: nuking transitgateway vpc attachement before nuking the vpc since vpc could be associated with it. diff --git a/aws/resources/ec2_placement_group.go b/aws/resources/ec2_placement_group.go new file mode 100644 index 00000000..5ccc14de --- /dev/null +++ b/aws/resources/ec2_placement_group.go @@ -0,0 +1,95 @@ +package resources + +import ( + "context" + "time" + + awsgo "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/util" + "github.com/gruntwork-io/gruntwork-cli/errors" + "github.com/hashicorp/go-multierror" +) + +// getAll extracts the list of existing ec2 placement groups +func (p *EC2PlacementGroups) getAll(c context.Context, configObj config.Config) ([]*string, error) { + var names []*string + var firstSeenTime *time.Time + + result, err := p.Client.DescribePlacementGroupsWithContext(p.Context, &ec2.DescribePlacementGroupsInput{}) + if err != nil { + return nil, errors.WithStackTrace(err) + } + for _, placementGroup := range result.PlacementGroups { + firstSeenTime, err = util.GetOrCreateFirstSeen(c, p.Client, placementGroup.GroupId, util.ConvertEC2TagsToMap(placementGroup.Tags)) + if err != nil { + logging.Error("Unable to retrieve tags") + return nil, errors.WithStackTrace(err) + } + + if configObj.EC2PlacementGroups.ShouldInclude(config.ResourceValue{ + Name: placementGroup.GroupName, + Time: firstSeenTime, + Tags: util.ConvertEC2TagsToMap(placementGroup.Tags), + }) { + names = append(names, placementGroup.GroupName) + } + } + + // checking the nukable permissions + p.VerifyNukablePermissions(names, func(name *string) error { + _, err := p.Client.DeletePlacementGroupWithContext(p.Context, &ec2.DeletePlacementGroupInput{ + GroupName: name, + DryRun: awsgo.Bool(true), + }) + return err + }) + + return names, nil +} + +// deleteKeyPair is a helper method that deletes the given ec2 key pair. +func (p *EC2PlacementGroups) deletePlacementGroup(placementGroupName *string) error { + params := &ec2.DeletePlacementGroupInput{ + GroupName: placementGroupName, + } + + _, err := p.Client.DeletePlacementGroupWithContext(p.Context, params) + if err != nil { + return errors.WithStackTrace(err) + } + + return nil +} + +// nukeAllEc2KeyPairs attempts to delete given ec2 key pair IDs. +func (p *EC2PlacementGroups) nukeAll(groupNames []*string) error { + if len(groupNames) == 0 { + logging.Infof("No EC2 placement groups to nuke in region %s", p.Region) + return nil + } + + logging.Infof("Terminating all EC2 placement groups in region %s", p.Region) + + deletedPlacementGroups := 0 + var multiErr *multierror.Error + for _, groupName := range groupNames { + if nukable, reason := p.IsNukable(awsgo.StringValue(groupName)); !nukable { + logging.Debugf("[Skipping] %s nuke because %v", awsgo.StringValue(groupName), reason) + continue + } + + if err := p.deletePlacementGroup(groupName); err != nil { + logging.Errorf("[Failed] %s", err) + multiErr = multierror.Append(multiErr, err) + } else { + deletedPlacementGroups++ + logging.Infof("Deleted EC2 Placement Group: %s", *groupName) + } + } + + logging.Infof("[OK] %d EC2 Placement Group(s) terminated", deletedPlacementGroups) + return multiErr.ErrorOrNil() +} diff --git a/aws/resources/ec2_placement_group_test.go b/aws/resources/ec2_placement_group_test.go new file mode 100644 index 00000000..8fdd8345 --- /dev/null +++ b/aws/resources/ec2_placement_group_test.go @@ -0,0 +1,121 @@ +package resources + +import ( + "context" + "regexp" + "testing" + "time" + + awsgo "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "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/util" + "github.com/stretchr/testify/require" +) + +type mockedEC2PlacementGroups struct { + ec2iface.EC2API + DescribePlacementGroupsOutput ec2.DescribePlacementGroupsOutput + DeletePlacementGroupOutput ec2.DeletePlacementGroupOutput +} + +func (m mockedEC2PlacementGroups) DescribePlacementGroupsWithContext(_ awsgo.Context, _ *ec2.DescribePlacementGroupsInput, _ ...request.Option) (*ec2.DescribePlacementGroupsOutput, error) { + return &m.DescribePlacementGroupsOutput, nil +} + +func (m mockedEC2PlacementGroups) DeletePlacementGroupWithContext(_ awsgo.Context, _ *ec2.DeletePlacementGroupInput, _ ...request.Option) (*ec2.DeletePlacementGroupOutput, error) { + return &m.DeletePlacementGroupOutput, nil +} + +func TestEC2PlacementGroups_GetAll(t *testing.T) { + + t.Parallel() + + // Set excludeFirstSeenTag to false for testing + ctx := context.WithValue(context.Background(), util.ExcludeFirstSeenTagKey, false) + + now := time.Now() + testId1 := "test-group-id1" + testName1 := "test-group1" + testId2 := "test-group-id2" + testName2 := "test-group2" + p := EC2PlacementGroups{ + Client: mockedEC2PlacementGroups{ + DescribePlacementGroupsOutput: ec2.DescribePlacementGroupsOutput{ + PlacementGroups: []*ec2.PlacementGroup{ + { + GroupName: awsgo.String(testName1), + GroupId: awsgo.String(testId1), + Tags: []*ec2.Tag{{ + Key: awsgo.String(util.FirstSeenTagKey), + Value: awsgo.String(util.FormatTimestamp(now)), + }}, + }, + { + GroupName: awsgo.String(testName2), + GroupId: awsgo.String(testId2), + Tags: []*ec2.Tag{{ + Key: awsgo.String(util.FirstSeenTagKey), + Value: awsgo.String(util.FormatTimestamp(now.Add(2 * time.Hour))), + }}, + }, + }, + }, + }, + } + + tests := map[string]struct { + ctx context.Context + configObj config.ResourceType + expected []string + }{ + "emptyFilter": { + ctx: ctx, + configObj: config.ResourceType{}, + expected: []string{testName1, testName2}, + }, + "nameExclusionFilter": { + ctx: ctx, + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + NamesRegExp: []config.Expression{{ + RE: *regexp.MustCompile(testName1), + }}}, + }, + expected: []string{testName2}, + }, + "timeAfterExclusionFilter": { + ctx: ctx, + configObj: config.ResourceType{ + ExcludeRule: config.FilterRule{ + TimeAfter: awsgo.Time(now.Add(1 * time.Hour)), + }}, + expected: []string{testName1}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + names, err := p.getAll(tc.ctx, config.Config{ + EC2PlacementGroups: tc.configObj, + }) + require.NoError(t, err) + require.Equal(t, tc.expected, awsgo.StringValueSlice(names)) + }) + } +} + +func TestEC2PlacementGroups_NukeAll(t *testing.T) { + + t.Parallel() + + h := EC2PlacementGroups{ + Client: mockedEC2PlacementGroups{ + DeletePlacementGroupOutput: ec2.DeletePlacementGroupOutput{}, + }, + } + + err := h.nukeAll([]*string{awsgo.String("test-group1"), awsgo.String("test-group2")}) + require.NoError(t, err) +} diff --git a/aws/resources/ec2_placement_group_types.go b/aws/resources/ec2_placement_group_types.go new file mode 100644 index 00000000..928a36a5 --- /dev/null +++ b/aws/resources/ec2_placement_group_types.go @@ -0,0 +1,60 @@ +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" +) + +type EC2PlacementGroups struct { + BaseAwsResource + Client ec2iface.EC2API + Region string + PlacementGroupNames []string +} + +func (k *EC2PlacementGroups) Init(session *session.Session) { + k.Client = ec2.New(session) +} + +// ResourceName - the simple name of the aws resource +func (k *EC2PlacementGroups) ResourceName() string { + return "ec2-placement-groups" +} + +// ResourceIdentifiers - IDs of the ec2 key pairs +func (k *EC2PlacementGroups) ResourceIdentifiers() []string { + return k.PlacementGroupNames +} + +func (k *EC2PlacementGroups) MaxBatchSize() int { + // Tentative batch size to ensure AWS doesn't throttle + return 200 +} + +func (k *EC2PlacementGroups) GetAndSetResourceConfig(configObj config.Config) config.ResourceType { + return configObj.EC2PlacementGroups +} + +func (k *EC2PlacementGroups) GetAndSetIdentifiers(c context.Context, configObj config.Config) ([]string, error) { + identifiers, err := k.getAll(c, configObj) + if err != nil { + return nil, err + } + + k.PlacementGroupNames = awsgo.StringValueSlice(identifiers) + return k.PlacementGroupNames, nil +} + +func (k *EC2PlacementGroups) Nuke(identifiers []string) error { + if err := k.nukeAll(awsgo.StringSlice(identifiers)); err != nil { + return errors.WithStackTrace(err) + } + + return nil +} diff --git a/config/config.go b/config/config.go index 709c12cb..f745e175 100644 --- a/config/config.go +++ b/config/config.go @@ -56,6 +56,7 @@ type Config struct { EC2IPAMResourceDiscovery ResourceType `yaml:"EC2IPAMResourceDiscovery"` EC2IPAMScope ResourceType `yaml:"EC2IPAMScope"` EC2Endpoint ResourceType `yaml:"EC2Endpoint"` + EC2PlacementGroups ResourceType `yaml:"EC2PlacementGroups"` EC2Subnet EC2ResourceType `yaml:"EC2Subnet"` EgressOnlyInternetGateway ResourceType `yaml:"EgressOnlyInternetGateway"` ECRRepository ResourceType `yaml:"ECRRepository"` diff --git a/config/config_test.go b/config/config_test.go index ca156988..cc31eb2d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -48,6 +48,7 @@ func emptyConfig() *Config { EC2IPAMResourceDiscovery: ResourceType{FilterRule{}, FilterRule{}, "",false}, EC2IPAMScope: ResourceType{FilterRule{}, FilterRule{}, "",false}, EC2Endpoint: ResourceType{FilterRule{}, FilterRule{}, "",false}, + EC2PlacementGroups: ResourceType{FilterRule{}, FilterRule{}, "",false}, EC2Subnet: EC2ResourceType{false, ResourceType{FilterRule{}, FilterRule{}, "",false}}, EgressOnlyInternetGateway: ResourceType{FilterRule{}, FilterRule{}, "",false}, ECRRepository: ResourceType{FilterRule{}, FilterRule{}, "",false},