Repository: gruntwork-io/cloud-nuke
Pull request #610 (2023-10-19 22:42:28)

Implement a new resource type for EC2 DHCP option
<!-- Prepend '[WIP]' to the title if this PR is still a work-in-progress. Remove it when it is ready for review! -->

## Description

Fixes https://github.com/gruntwork-io/cloud-nuke/issues/118. 

<!-- Description of the changes introduced by this PR. -->

## TODOs

Read the [Gruntwork contribution guidelines](https://gruntwork.notion.site/Gruntwork-Coding-Methodology-02fdcd6e4b004e818553684760bf691e).

- [ ] Update the docs.
- [ ] Run the relevant tests successfully, including pre-commit checks.
- [ ] 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.
- [ ] 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)

<!-- One-line description of the PR that can be included in the final release notes. -->
Added / Removed / Updated [X].

### Migration Guide

<!-- Important: If you made any backward incompatible changes, then you must write a migration guide! -->




Patch:
diff --git a/README.md b/README.md
index 03af035..8b42fe5 100644
--- a/README.md
+++ b/README.md
@@ -467,6 +467,7 @@ of the file that are supported are listed here.
 | ebs                         | EBSVolume                    | ✅ (Volume Name)                       | ✅ (Creation Time)                   | ✅    |
 | ec2                         | EC2                          | ✅ (Instance Name)                     | ✅ (Launch Time)                     | ✅    |
 | ec2-dedicated-hosts         | EC2DedicatedHosts            | ✅ (EC2 Name Tag)                      | ✅ (Allocation Time)                 | ❌    |
+| ec2-dhcp-option             | EC2DhcpOption                | ❌                                     | ❌                                   | ❌    |
 | ec2-keypairs                | EC2KeyPairs                  | ✅ (Key Pair Name)                     | ✅ (Creation Time)                   | ❌    |
 | ecr                         | ECRRepository                | ✅ (Repository Name)                   | ✅ (Creation Time)                   | ❌    |
 | ecscluster                  | ECSCluster                   | ✅ (Cluster Name)                      | ❌                                   | ❌    |
diff --git a/aws/resource_registry.go b/aws/resource_registry.go
index b900e1c..43ebb47 100644
--- a/aws/resource_registry.go
+++ b/aws/resource_registry.go
@@ -67,6 +67,8 @@ func getRegisteredRegionalResources() []AwsResources {
 		&resources.EC2DedicatedHosts{},
 		&resources.EC2KeyPairs{},
 		&resources.EC2VPCs{},
+		// Note: nuking EC2 DHCP options after nuking EC2 VPC because DHCP options could be associated with VPCs.
+		&resources.EC2DhcpOption{},
 		&resources.ECR{},
 		&resources.ECSClusters{},
 		&resources.ECSServices{},
diff --git a/aws/resources/ec2_dhcp_option.go b/aws/resources/ec2_dhcp_option.go
new file mode 100644
index 0000000..c09ea0a
--- /dev/null
+++ b/aws/resources/ec2_dhcp_option.go
@@ -0,0 +1,44 @@
+package resources
+
+import (
+	"context"
+	"fmt"
+	"github.com/aws/aws-sdk-go/service/ec2"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/pterm/pterm"
+)
+
+func (v *EC2DhcpOption) getAll(c context.Context, configObj config.Config) ([]*string, error) {
+	var dhcpOptionIds []*string
+	err := v.Client.DescribeDhcpOptionsPages(&ec2.DescribeDhcpOptionsInput{}, func(page *ec2.DescribeDhcpOptionsOutput, lastPage bool) bool {
+		for _, dhcpOption := range page.DhcpOptions {
+			// No specific filters to apply at this point, we can think about introducing
+			// filtering with name tag in the future. In the initial version, we just getAll
+			// without filtering.
+			dhcpOptionIds = append(dhcpOptionIds, dhcpOption.DhcpOptionsId)
+		}
+
+		return !lastPage
+	})
+
+	if err != nil {
+		return nil, errors.WithStackTrace(err)
+	}
+
+	return dhcpOptionIds, nil
+}
+
+func (v *EC2DhcpOption) nukeAll(identifiers []*string) error {
+	for _, identifier := range identifiers {
+		_, err := v.Client.DeleteDhcpOptions(&ec2.DeleteDhcpOptionsInput{
+			DhcpOptionsId: identifier,
+		})
+		if err != nil {
+			pterm.Debug.Println(fmt.Sprintf("Failed to delete DHCP option w/ err: %s.", err))
+			return errors.WithStackTrace(err)
+		}
+	}
+
+	return nil
+}
diff --git a/aws/resources/ec2_dhcp_option_types.go b/aws/resources/ec2_dhcp_option_types.go
new file mode 100644
index 0000000..551722d
--- /dev/null
+++ b/aws/resources/ec2_dhcp_option_types.go
@@ -0,0 +1,54 @@
+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 EC2DhcpOption struct {
+	Client ec2iface.EC2API
+	Region string
+	VPCIds []string
+}
+
+func (v *EC2DhcpOption) Init(session *session.Session) {
+	v.Client = ec2.New(session)
+}
+
+// ResourceName - the simple name of the aws resource
+func (v *EC2DhcpOption) ResourceName() string {
+	return "ec2_dhcp_option"
+}
+
+// ResourceIdentifiers - The instance ids of the ec2 instances
+func (v *EC2DhcpOption) ResourceIdentifiers() []string {
+	return v.VPCIds
+}
+
+func (v *EC2DhcpOption) MaxBatchSize() int {
+	// Tentative batch size to ensure AWS doesn't throttle
+	return 49
+}
+
+func (v *EC2DhcpOption) GetAndSetIdentifiers(c context.Context, configObj config.Config) ([]string, error) {
+	identifiers, err := v.getAll(c, configObj)
+	if err != nil {
+		return nil, err
+	}
+
+	v.VPCIds = awsgo.StringValueSlice(identifiers)
+	return v.VPCIds, nil
+}
+
+func (v *EC2DhcpOption) Nuke(identifiers []string) error {
+	if err := v.nukeAll(awsgo.StringSlice(identifiers)); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
diff --git a/config/config.go b/config/config.go
index 5b343a3..b530605 100644
--- a/config/config.go
+++ b/config/config.go
@@ -37,6 +37,7 @@ type Config struct {
 	EBSVolume                    ResourceType               `yaml:"EBSVolume"`
 	EC2                          ResourceType               `yaml:"EC2"`
 	EC2DedicatedHosts            ResourceType               `yaml:"EC2DedicatedHosts"`
+	EC2DHCPOption                ResourceType               `yaml:"EC2DhcpOption"`
 	EC2KeyPairs                  ResourceType               `yaml:"EC2KeyPairs"`
 	ECRRepository                ResourceType               `yaml:"ECRRepository"`
 	ECSCluster                   ResourceType               `yaml:"ECSCluster"`


Test patch:
diff --git a/aws/resources/ec2_dhcp_option_test.go b/aws/resources/ec2_dhcp_option_test.go
new file mode 100644
index 0000000..e6c09b0
--- /dev/null
+++ b/aws/resources/ec2_dhcp_option_test.go
@@ -0,0 +1,79 @@
+package resources
+
+import (
+	"context"
+	awsgo "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/stretchr/testify/require"
+	"testing"
+)
+
+type mockedEC2DhcpOption struct {
+	ec2iface.EC2API
+	DescribeDhcpOptionsOutput ec2.DescribeDhcpOptionsOutput
+	DeleteDhcpOptionsOutput   ec2.DeleteDhcpOptionsOutput
+}
+
+func (m mockedEC2DhcpOption) DescribeDhcpOptionsPages(input *ec2.DescribeDhcpOptionsInput, fn func(*ec2.DescribeDhcpOptionsOutput, bool) bool) error {
+	fn(&m.DescribeDhcpOptionsOutput, true)
+	return nil
+}
+
+func (m mockedEC2DhcpOption) DeleteDhcpOptions(input *ec2.DeleteDhcpOptionsInput) (*ec2.DeleteDhcpOptionsOutput, error) {
+	return &m.DeleteDhcpOptionsOutput, nil
+}
+
+func TestEC2DhcpOption_GetAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+	t.Parallel()
+
+	testId1 := "test-id-1"
+	h := EC2DhcpOption{
+		Client: mockedEC2DhcpOption{
+			DescribeDhcpOptionsOutput: ec2.DescribeDhcpOptionsOutput{
+				DhcpOptions: []*ec2.DhcpOptions{
+					{
+						DhcpOptionsId: awsgo.String(testId1),
+					},
+				},
+			},
+		},
+	}
+
+	tests := map[string]struct {
+		configObj config.ResourceType
+		expected  []string
+	}{
+		"emptyFilter": {
+			configObj: config.ResourceType{},
+			expected:  []string{testId1},
+		},
+	}
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			names, err := h.getAll(context.Background(), config.Config{
+				EC2DedicatedHosts: tc.configObj,
+			})
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, awsgo.StringValueSlice(names))
+		})
+	}
+
+}
+
+func TestEC2DhcpOption_NukeAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+	t.Parallel()
+
+	h := EC2DhcpOption{
+		Client: mockedEC2DhcpOption{
+			DeleteDhcpOptionsOutput: ec2.DeleteDhcpOptionsOutput{},
+		},
+	}
+
+	err := h.nukeAll([]*string{awsgo.String("test-id-1"), awsgo.String("test-id-2")})
+	require.NoError(t, err)
+}
diff --git a/config/config_test.go b/config/config_test.go
index b4b159a..59b7374 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -53,6 +53,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{}},