Repository: gruntwork-io/cloud-nuke
Pull request #758 (2024-08-05 10:14:15)

Support resource EC2 Placement Groups
## Description

Support new resource type EC2 Placement groups

## 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] Include release notes. If this PR is backward incompatible, include a migration guide.
- [ ] 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)

Added support for nuking EC2 Placement Groups.



Patch:
diff --git a/README.md b/README.md
index fee6f8d..ba50318 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 fe1c4f4..de4475f 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 0000000..5ccc14d
--- /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_types.go b/aws/resources/ec2_placement_group_types.go
new file mode 100644
index 0000000..928a36a
--- /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 709c12c..f745e17 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"`


Test patch:
diff --git a/aws/resources/ec2_placement_group_test.go b/aws/resources/ec2_placement_group_test.go
new file mode 100644
index 0000000..8fdd834
--- /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/config/config_test.go b/config/config_test.go
index ca15698..cc31eb2 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},