Repository: gruntwork-io/cloud-nuke
Pull request #644 (2024-02-26 03:58:25)

elastic bean stalk application implementation
<!-- 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/351.

<!-- 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 d1bc456..522ab23 100644
--- a/README.md
+++ b/README.md
@@ -60,6 +60,7 @@ Cloud-nuke suppports 🔎 inspecting and 🔥💀 deleting the following AWS res
 | Elasticache             | Clusters                                                 |
 | Elasticache             | Parameter Groups                                         |
 | Elasticache             | Subnet Groups                                            |
+| Elastic Beanstalk       | Applications                                             |
 | ECS                     | Services                                                 |
 | ECS                     | Clusters                                                 |
 | EKS                     | Clusters                                                 |
@@ -509,6 +510,7 @@ of the file that are supported are listed here.
 | rds-subnet-group            | DBSubnetGroups               | ✅ (DB Subnet Group Name)              | ❌                                   | ❌    |
 | dynamodb                    | DynamoDB                     | ✅ (Table Name)                        | ✅ (Creation Time)                   | ❌    |
 | ebs                         | EBSVolume                    | ✅ (Volume Name)                       | ✅ (Creation Time)                   | ✅    |
+| elastic-beanstalk           | ElasticBeanstalk             | ✅ (Application Name)                  | ✅ (Creation Time)                   | ❌    |
 | ec2                         | EC2                          | ✅ (Instance Name)                     | ✅ (Launch Time)                     | ✅    |
 | ec2-dedicated-hosts         | EC2DedicatedHosts            | ✅ (EC2 Name Tag)                      | ✅ (Allocation Time)                 | ❌    |
 | ec2-dhcp-option             | EC2DhcpOption                | ❌                                     | ❌                                   | ❌    |
diff --git a/aws/resource_registry.go b/aws/resource_registry.go
index 89ae28c..d909c69 100644
--- a/aws/resource_registry.go
+++ b/aws/resource_registry.go
@@ -64,6 +64,7 @@ func getRegisteredRegionalResources() []AwsResource {
 		&resources.ConfigServiceRule{},
 		&resources.DynamoDB{},
 		&resources.EBSVolumes{},
+		&resources.EBApplications{},
 		&resources.EC2Instances{},
 		&resources.EC2DedicatedHosts{},
 		&resources.EC2KeyPairs{},
diff --git a/aws/resources/ecs_cluster.go b/aws/resources/ecs_cluster.go
index 1f5c0cf..66c41b0 100644
--- a/aws/resources/ecs_cluster.go
+++ b/aws/resources/ecs_cluster.go
@@ -2,9 +2,10 @@ package resources
 
 import (
 	"context"
-	"github.com/gruntwork-io/cloud-nuke/util"
 	"time"
 
+	"github.com/gruntwork-io/cloud-nuke/util"
+
 	"github.com/gruntwork-io/cloud-nuke/telemetry"
 	commonTelemetry "github.com/gruntwork-io/go-commons/telemetry"
 
@@ -124,6 +125,34 @@ func (clusters *ECSClusters) getAll(c context.Context, configObj config.Config)
 	return filteredEcsClusters, nil
 }
 
+func (clusters *ECSClusters) stopClusterRunningTasks(clusterArn *string) error {
+	logging.Debugf("[TASK] stopping tasks running on cluster %v", *clusterArn)
+	// before deleting the cluster, remove the active tasks on that cluster
+	runningTasks, err := clusters.Client.ListTasks(&ecs.ListTasksInput{
+		Cluster:       clusterArn,
+		DesiredStatus: aws.String("RUNNING"),
+	})
+
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	// stop the listed tasks
+	for _, task := range runningTasks.TaskArns {
+		_, err := clusters.Client.StopTask(&ecs.StopTaskInput{
+			Cluster: clusterArn,
+			Task:    task,
+			Reason:  aws.String("Terminating task due to cluster deletion"),
+		})
+		if err != nil {
+			logging.Debugf("[TASK] Unable to stop the task %s on cluster %s. Reason: %v", *task, *clusterArn, err)
+			return errors.WithStackTrace(err)
+		}
+		logging.Debugf("[TASK] Success, stopped task %v", *task)
+	}
+	return nil
+}
+
 func (clusters *ECSClusters) nukeAll(ecsClusterArns []*string) error {
 	numNuking := len(ecsClusterArns)
 
@@ -136,10 +165,18 @@ func (clusters *ECSClusters) nukeAll(ecsClusterArns []*string) error {
 
 	var nukedEcsClusters []*string
 	for _, clusterArn := range ecsClusterArns {
+
+		// before nuking the clusters, do check active tasks on the cluster and stop all of them
+		err := clusters.stopClusterRunningTasks(clusterArn)
+		if err != nil {
+			logging.Debugf("Error, unable to stop the running stasks on the cluster %s %s", aws.StringValue(clusterArn), err)
+			return errors.WithStackTrace(err)
+		}
+
 		params := &ecs.DeleteClusterInput{
 			Cluster: clusterArn,
 		}
-		_, err := clusters.Client.DeleteCluster(params)
+		_, err = clusters.Client.DeleteCluster(params)
 
 		// Record status of this resource
 		e := report.Entry{
@@ -150,7 +187,7 @@ func (clusters *ECSClusters) nukeAll(ecsClusterArns []*string) error {
 		report.Record(e)
 
 		if err != nil {
-			logging.Debugf("Error, failed to delete cluster with ARN %s", aws.StringValue(clusterArn))
+			logging.Debugf("Error, failed to delete cluster with ARN %s %s", aws.StringValue(clusterArn), err)
 			telemetry.TrackEvent(commonTelemetry.EventContext{
 				EventName: "Error Nuking ECS Cluster",
 			}, map[string]interface{}{
diff --git a/aws/resources/elastic_beanstalk.go b/aws/resources/elastic_beanstalk.go
new file mode 100644
index 0000000..f54af16
--- /dev/null
+++ b/aws/resources/elastic_beanstalk.go
@@ -0,0 +1,71 @@
+package resources
+
+import (
+	"context"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/elasticbeanstalk"
+	"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/go-commons/errors"
+)
+
+func shouldIncludeEBApplication(app *elasticbeanstalk.ApplicationDescription, configObj config.Config) bool {
+	return configObj.ElasticBeanstalk.ShouldInclude(config.ResourceValue{
+		Name: app.ApplicationName,
+		Time: app.DateCreated,
+	})
+}
+
+// Returns a formatted string of EB application ids
+func (eb *EBApplications) getAll(c context.Context, configObj config.Config) ([]*string, error) {
+	output, err := eb.Client.DescribeApplications(&elasticbeanstalk.DescribeApplicationsInput{})
+	if err != nil {
+		return nil, errors.WithStackTrace(err)
+	}
+	var appIds []*string
+	for _, app := range output.Applications {
+		if shouldIncludeEBApplication(app, configObj) {
+			appIds = append(appIds, app.ApplicationName)
+		}
+	}
+	return appIds, nil
+}
+
+// Deletes all EB Applications
+func (eb *EBApplications) nukeAll(appIds []*string) error {
+	if len(appIds) == 0 {
+		logging.Debugf("No Elastic Beanstalk to nuke in region %s", eb.Region)
+		return nil
+	}
+
+	logging.Debugf("Deleting all Elastic Beanstalk applications in region %s", eb.Region)
+	var deletedApps []*string
+
+	for _, id := range appIds {
+		_, err := eb.Client.DeleteApplication(&elasticbeanstalk.DeleteApplicationInput{
+			ApplicationName:     id,
+			TerminateEnvByForce: aws.Bool(true),
+		})
+		// Record status of this resource
+		e := report.Entry{
+			Identifier:   aws.StringValue(id),
+			ResourceType: "Elastic Beanstalk Application",
+			Error:        err,
+		}
+		report.Record(e)
+
+		if err != nil {
+			logging.Debugf("[Failed] %s", err)
+			continue
+		}
+
+		// get the deleted ids
+		deletedApps = append(deletedApps, id)
+		logging.Debugf("Deleted Elastic Beanstalk application: %s", *id)
+
+	}
+	logging.Debugf("[OK] %d Elastic Beanstalk application(s) deleted in %s", len(deletedApps), eb.Region)
+	return nil
+}
diff --git a/aws/resources/elastic_beanstalk_types.go b/aws/resources/elastic_beanstalk_types.go
new file mode 100644
index 0000000..5a1b922
--- /dev/null
+++ b/aws/resources/elastic_beanstalk_types.go
@@ -0,0 +1,58 @@
+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/elasticbeanstalk"
+	"github.com/aws/aws-sdk-go/service/elasticbeanstalk/elasticbeanstalkiface"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/gruntwork-io/go-commons/errors"
+)
+
+// EBApplications - represents all elastic beanstalk applications
+type EBApplications struct {
+	BaseAwsResource
+	Client elasticbeanstalkiface.ElasticBeanstalkAPI
+	Region string
+	appIds []string
+}
+
+func (eb *EBApplications) Init(session *session.Session) {
+	eb.Client = elasticbeanstalk.New(session)
+}
+
+// ResourceName - the simple name of the aws resource
+func (eb *EBApplications) ResourceName() string {
+	return "elastic-beanstalk"
+}
+
+// ResourceIdentifiers - The application ids of the elastic beanstalk
+func (eb *EBApplications) ResourceIdentifiers() []string {
+	return eb.appIds
+}
+
+func (eb *EBApplications) MaxBatchSize() int {
+	// Tentative batch size to ensure AWS doesn't throttle
+	return 49
+}
+
+func (eb *EBApplications) GetAndSetIdentifiers(c context.Context, configObj config.Config) ([]string, error) {
+	identifiers, err := eb.getAll(c, configObj)
+	if err != nil {
+		return nil, err
+	}
+
+	eb.appIds = awsgo.StringValueSlice(identifiers)
+	return eb.appIds, nil
+}
+
+// Nuke - nuke 'em all!!!
+func (eb *EBApplications) Nuke(identifiers []string) error {
+	if err := eb.nukeAll(awsgo.StringSlice(identifiers)); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	return nil
+}
diff --git a/config/config.go b/config/config.go
index 99def08..9ec5e65 100644
--- a/config/config.go
+++ b/config/config.go
@@ -35,6 +35,7 @@ type Config struct {
 	DBSubnetGroups                  ResourceType               `yaml:"DBSubnetGroups"`
 	DynamoDB                        ResourceType               `yaml:"DynamoDB"`
 	EBSVolume                       ResourceType               `yaml:"EBSVolume"`
+	ElasticBeanstalk                ResourceType               `yaml:"ElasticBeanstalk"`
 	EC2                             ResourceType               `yaml:"EC2"`
 	EC2DedicatedHosts               ResourceType               `yaml:"EC2DedicatedHosts"`
 	EC2DHCPOption                   ResourceType               `yaml:"EC2DhcpOption"`


Test patch:
diff --git a/aws/resources/ecs_cluster_test.go b/aws/resources/ecs_cluster_test.go
index 9be2532..da2cc99 100644
--- a/aws/resources/ecs_cluster_test.go
+++ b/aws/resources/ecs_cluster_test.go
@@ -2,6 +2,10 @@ package resources
 
 import (
 	"context"
+	"regexp"
+	"testing"
+	"time"
+
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/service/ecs"
 	"github.com/aws/aws-sdk-go/service/ecs/ecsiface"
@@ -9,9 +13,6 @@ import (
 	"github.com/gruntwork-io/cloud-nuke/telemetry"
 	"github.com/gruntwork-io/cloud-nuke/util"
 	"github.com/stretchr/testify/require"
-	"regexp"
-	"testing"
-	"time"
 )
 
 type mockedEC2Cluster struct {
@@ -21,6 +22,8 @@ type mockedEC2Cluster struct {
 	TagResourceOutput         ecs.TagResourceOutput
 	ListTagsForResourceOutput ecs.ListTagsForResourceOutput
 	DeleteClusterOutput       ecs.DeleteClusterOutput
+	ListTasksOutput           ecs.ListTasksOutput
+	StopTaskOutput            ecs.StopTaskOutput
 }
 
 func (m mockedEC2Cluster) ListClusters(*ecs.ListClustersInput) (*ecs.ListClustersOutput, error) {
@@ -43,6 +46,13 @@ func (m mockedEC2Cluster) DeleteCluster(*ecs.DeleteClusterInput) (*ecs.DeleteClu
 	return &m.DeleteClusterOutput, nil
 }
 
+func (m mockedEC2Cluster) ListTasks(*ecs.ListTasksInput) (*ecs.ListTasksOutput, error) {
+	return &m.ListTasksOutput, nil
+}
+func (m mockedEC2Cluster) StopTask(*ecs.StopTaskInput) (*ecs.StopTaskOutput, error) {
+	return &m.StopTaskOutput, nil
+}
+
 func TestEC2Cluster_GetAll(t *testing.T) {
 	telemetry.InitTelemetry("cloud-nuke", "")
 	t.Parallel()
@@ -84,6 +94,12 @@ func TestEC2Cluster_GetAll(t *testing.T) {
 					},
 				},
 			},
+			ListTasksOutput: ecs.ListTasksOutput{
+				TaskArns: []*string{
+					aws.String("task-arn-001"),
+					aws.String("task-arn-002"),
+				},
+			},
 		},
 	}
 
@@ -137,3 +153,23 @@ func TestEC2Cluster_NukeAll(t *testing.T) {
 	err := ec.nukeAll([]*string{aws.String("arn:aws:ecs:us-east-1:123456789012:cluster/cluster1")})
 	require.NoError(t, err)
 }
+
+func TestEC2ClusterWithTasks_NukeAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+	t.Parallel()
+
+	ec := ECSClusters{
+		Client: mockedEC2Cluster{
+			DeleteClusterOutput: ecs.DeleteClusterOutput{},
+			ListTasksOutput: ecs.ListTasksOutput{
+				TaskArns: []*string{
+					aws.String("task-arn-001"),
+					aws.String("task-arn-002"),
+				},
+			},
+		},
+	}
+
+	err := ec.nukeAll([]*string{aws.String("arn:aws:ecs:us-east-1:123456789012:cluster/cluster1")})
+	require.NoError(t, err)
+}
diff --git a/aws/resources/elastic_beanstalk_test.go b/aws/resources/elastic_beanstalk_test.go
new file mode 100644
index 0000000..24d7f56
--- /dev/null
+++ b/aws/resources/elastic_beanstalk_test.go
@@ -0,0 +1,99 @@
+package resources
+
+import (
+	"context"
+	"regexp"
+	"testing"
+	"time"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/elasticbeanstalk"
+	"github.com/aws/aws-sdk-go/service/elasticbeanstalk/elasticbeanstalkiface"
+	"github.com/gruntwork-io/cloud-nuke/config"
+	"github.com/stretchr/testify/require"
+)
+
+type mockedEBApplication struct {
+	elasticbeanstalkiface.ElasticBeanstalkAPI
+	DescribeApplicationsOutput elasticbeanstalk.DescribeApplicationsOutput
+}
+
+func (mock *mockedEBApplication) DescribeApplications(req *elasticbeanstalk.DescribeApplicationsInput) (*elasticbeanstalk.DescribeApplicationsOutput, error) {
+	return &mock.DescribeApplicationsOutput, nil
+}
+
+func (mock *mockedEBApplication) DeleteApplication(*elasticbeanstalk.DeleteApplicationInput) (*elasticbeanstalk.DeleteApplicationOutput, error) {
+	return nil, nil
+}
+
+func TestEBApplication_GetAll(t *testing.T) {
+	t.Parallel()
+
+	app1 := "demo-app-golang-backend"
+	app2 := "demo-app-golang-frontend"
+
+	now := time.Now()
+	eb := EBApplications{
+		Client: &mockedEBApplication{
+			DescribeApplicationsOutput: elasticbeanstalk.DescribeApplicationsOutput{
+				Applications: []*elasticbeanstalk.ApplicationDescription{
+					{
+						ApplicationArn:  aws.String("app-arn-01"),
+						ApplicationName: &app1,
+						DateCreated:     aws.Time(now),
+					},
+					{
+						ApplicationArn:  aws.String("app-arn-02"),
+						ApplicationName: &app2,
+						DateCreated:     aws.Time(now.Add(1)),
+					},
+				},
+			},
+		},
+	}
+
+	tests := map[string]struct {
+		configObj config.ResourceType
+		expected  []string
+	}{
+		"emptyFilter": {
+			configObj: config.ResourceType{},
+			expected: []string{
+				app1, app2,
+			},
+		},
+		"nameExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					NamesRegExp: []config.Expression{{
+						RE: *regexp.MustCompile(app1),
+					}}},
+			},
+			expected: []string{app2},
+		},
+		"timeAfterExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					TimeAfter: aws.Time(now),
+				}},
+			expected: []string{app1},
+		},
+		"timeBeforeExclusionFilter": {
+			configObj: config.ResourceType{
+				ExcludeRule: config.FilterRule{
+					TimeBefore: aws.Time(now.Add(1)),
+				}},
+			expected: []string{app2},
+		},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			names, err := eb.getAll(context.Background(), config.Config{
+				ElasticBeanstalk: tc.configObj,
+			})
+			require.NoError(t, err)
+			require.Equal(t, tc.expected, aws.StringValueSlice(names))
+		})
+	}
+}
diff --git a/config/config_test.go b/config/config_test.go
index 3aeb81f..a33c7c1 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -58,6 +58,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{}},