Repository: gruntwork-io/cloud-nuke
Pull request #583 (2023-09-10 03:31:14)

Refactor Rendering logic in ui file + consistent UI for confirmation …
…prompt

<!-- Prepend '[WIP]' to the title if this PR is still a work-in-progress. Remove it when it is ready for review! -->

## Description

![image](https://github.com/gruntwork-io/cloud-nuke/assets/96548424/1506596e-430e-41db-939d-485fe38c9a16)


<!-- 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/aws/resources/cloudwatch_alarm.go b/aws/resources/cloudwatch_alarm.go
index b9735f8..b9eed6d 100644
--- a/aws/resources/cloudwatch_alarm.go
+++ b/aws/resources/cloudwatch_alarm.go
@@ -7,6 +7,7 @@ import (
 	"github.com/gruntwork-io/cloud-nuke/logging"
 	"github.com/gruntwork-io/cloud-nuke/report"
 	"github.com/gruntwork-io/cloud-nuke/telemetry"
+	"github.com/gruntwork-io/cloud-nuke/util"
 	"github.com/gruntwork-io/go-commons/errors"
 	commonTelemetry "github.com/gruntwork-io/go-commons/telemetry"
 )
@@ -73,7 +74,10 @@ func (cw *CloudWatchAlarms) nukeAll(identifiers []*string) error {
 		})
 	}
 
+	var compositeAlarmNames []*string
 	for _, compositeAlarm := range alarms.CompositeAlarms {
+		compositeAlarmNames = append(compositeAlarmNames, compositeAlarm.AlarmName)
+
 		_, err := cw.Client.PutCompositeAlarm(&cloudwatch.PutCompositeAlarmInput{
 			AlarmName: compositeAlarm.AlarmName,
 			AlarmRule: aws.String("FALSE"),
@@ -86,14 +90,28 @@ func (cw *CloudWatchAlarms) nukeAll(identifiers []*string) error {
 				"region": cw.Region,
 			})
 		}
+
+		// Note: for composite alarms, we need to delete one by one according to the documentation
+		// - https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_DeleteAlarms.html.
+		_, err = cw.Client.DeleteAlarms(&cloudwatch.DeleteAlarmsInput{
+			AlarmNames: []*string{compositeAlarm.AlarmName},
+		})
+
+		// Record status of this resource
+		report.Record(report.Entry{
+			Identifier:   aws.StringValue(compositeAlarm.AlarmName),
+			ResourceType: "CloudWatch Alarm",
+			Error:        err,
+		})
 	}
 
-	input := cloudwatch.DeleteAlarmsInput{AlarmNames: identifiers}
+	nonCompositeAlarms := util.Difference(identifiers, compositeAlarmNames)
+	input := cloudwatch.DeleteAlarmsInput{AlarmNames: nonCompositeAlarms}
 	_, err = cw.Client.DeleteAlarms(&input)
 
 	// Record status of this resource
 	e := report.BatchEntry{
-		Identifiers:  aws.StringValueSlice(identifiers),
+		Identifiers:  aws.StringValueSlice(nonCompositeAlarms),
 		ResourceType: "CloudWatch Alarm",
 		Error:        err,
 	}
diff --git a/commands/cli.go b/commands/cli.go
index 206cd26..0ed06c9 100644
--- a/commands/cli.go
+++ b/commands/cli.go
@@ -6,7 +6,6 @@ import (
 	"github.com/gruntwork-io/cloud-nuke/aws/resources"
 	"github.com/gruntwork-io/cloud-nuke/progressbar"
 	"os"
-	"strings"
 	"time"
 
 	"github.com/gruntwork-io/cloud-nuke/telemetry"
@@ -271,7 +270,7 @@ func awsNuke(c *cli.Context) error {
 			EventName: "Awaiting nuke confirmation",
 		}, map[string]interface{}{})
 		prompt := "\nAre you sure you want to nuke all listed resources? Enter 'nuke' to confirm (or exit with ^C) "
-		proceed, err := confirmationPrompt(prompt, 2)
+		proceed, err := ui.RenderNukeConfirmationPrompt(prompt, 2)
 		if err != nil {
 			telemetry.TrackEvent(commonTelemetry.EventContext{
 				EventName: "Error confirming nuke",
@@ -417,7 +416,7 @@ func nukeDefaultVpcs(c *cli.Context, regions []string) error {
 
 	var proceed bool
 	if !c.Bool("force") {
-		proceed, err = confirmationPrompt("Are you sure you want to nuke the default VPCs listed above? Enter 'nuke' to confirm (or exit with ^C)", 2)
+		proceed, err = ui.RenderNukeConfirmationPrompt("Are you sure you want to nuke the default VPCs listed above? Enter 'nuke' to confirm (or exit with ^C)", 2)
 		if err != nil {
 			return err
 		}
@@ -479,7 +478,7 @@ func nukeDefaultSecurityGroups(c *cli.Context, regions []string) error {
 	var proceed bool
 	if !c.Bool("force") {
 		prompt := "\nAre you sure you want to nuke the rules in these default security groups ? Enter 'nuke' to confirm (or exit with ^C)"
-		proceed, err = confirmationPrompt(prompt, 2)
+		proceed, err = ui.RenderNukeConfirmationPrompt(prompt, 2)
 		if err != nil {
 			return err
 		}
@@ -495,31 +494,6 @@ func nukeDefaultSecurityGroups(c *cli.Context, regions []string) error {
 	return nil
 }
 
-func confirmationPrompt(prompt string, maxPrompts int) (bool, error) {
-	prompts := 0
-
-	ui.UrgentMessage("THE NEXT STEPS ARE DESTRUCTIVE AND COMPLETELY IRREVERSIBLE, PROCEED WITH CAUTION!!!")
-
-	for prompts < maxPrompts {
-		confirmPrompt := pterm.DefaultInteractiveTextInput.WithMultiLine(false)
-		pterm.Println()
-		input, err := confirmPrompt.Show(prompt)
-		if err != nil {
-			logging.Logger.Errorf("[Failed to render prompt] %s", err)
-			return false, errors.WithStackTrace(err)
-		}
-		response := strings.ToLower(strings.TrimSpace(input))
-		if response == "nuke" {
-			return true, nil
-		}
-		fmt.Printf("Invalid value '%s' was entered.\n", input)
-		prompts++
-		pterm.Println()
-	}
-
-	return false, nil
-}
-
 func awsInspect(c *cli.Context) error {
 	telemetry.TrackEvent(commonTelemetry.EventContext{
 		EventName: "Start aws-inspect",
@@ -563,7 +537,7 @@ func handleGetResources(c *cli.Context, configObj config.Config, includeUnaliase
 	}
 
 	pterm.DefaultSection.WithTopPadding(1).WithBottomPadding(0).Println("AWS Resource Query Parameters")
-	err = RenderQueryAsBulletList(query)
+	err = ui.RenderQueryAsBulletList(query)
 	if err != nil {
 		return nil, nil, errors.WithStackTrace(err)
 	}
@@ -578,7 +552,7 @@ func handleGetResources(c *cli.Context, configObj config.Config, includeUnaliase
 	}
 
 	pterm.DefaultSection.WithTopPadding(1).WithBottomPadding(0).Println("Found AWS Resources")
-	err = RenderResourcesAsTable(accountResources)
+	err = ui.RenderResourcesAsTable(accountResources)
 
 	return query, accountResources, err
 }
@@ -586,71 +560,5 @@ func handleGetResources(c *cli.Context, configObj config.Config, includeUnaliase
 func handleListResourceTypes() error {
 	// Handle the case where the user only wants to list resource types
 	pterm.DefaultSection.WithTopPadding(1).WithBottomPadding(0).Println("AWS Resource Types")
-	return RenderResourceTypesAsBulletList(aws.ListResourceTypes())
-}
-
-// TODO: Refactor the rendering code to other files. This file currently contains quite a lot of logic
-//  and it's hard to separate this logic out to other files due to import cycle issue.
-
-func RenderResourcesAsTable(account *aws.AwsAccountResources) error {
-	var tableData [][]string
-	tableData = append(tableData, []string{"Resource Type", "Region", "Identifier"})
-
-	for region, resourcesInRegion := range account.Resources {
-		for _, foundResources := range resourcesInRegion.Resources {
-			for _, identifier := range (*foundResources).ResourceIdentifiers() {
-				tableData = append(tableData, []string{(*foundResources).ResourceName(), region, identifier})
-			}
-		}
-	}
-
-	return pterm.DefaultTable.WithBoxed(true).
-		WithData(tableData).
-		WithHasHeader(true).
-		WithHeaderRowSeparator("-").
-		Render()
-}
-
-func RenderResourceTypesAsBulletList(resourceTypes []string) error {
-	var items []pterm.BulletListItem
-	for _, resourceType := range resourceTypes {
-		items = append(items, pterm.BulletListItem{Level: 0, Text: resourceType})
-	}
-
-	return pterm.DefaultBulletList.WithItems(items).Render()
-}
-
-func RenderQueryAsBulletList(query *aws.Query) error {
-	var tableData [][]string
-	tableData = append(tableData, []string{"Query Parameter", "Value"})
-
-	// Listing regions if there are <= 5 regions, otherwise the table format breaks.
-	if len(query.Regions) > 5 {
-		tableData = append(tableData,
-			[]string{"Target Regions", fmt.Sprintf("%d regions (too many to list all)", len(query.Regions))})
-	} else {
-		tableData = append(tableData, []string{"Target Regions", strings.Join(query.Regions, ", ")})
-	}
-
-	// Listing resource types if there are <= 5 resources, otherwise the table format breaks.
-	if len(query.ResourceTypes) > 5 {
-		tableData = append(tableData,
-			[]string{"Target Resource Types", fmt.Sprintf("%d resource types (too many to list all)", len(query.ResourceTypes))})
-	} else {
-		tableData = append(tableData, []string{"Target Resource Types", strings.Join(query.ResourceTypes, ", ")})
-	}
-
-	if query.ExcludeAfter != nil {
-		tableData = append(tableData, []string{"Exclude After Filter", query.ExcludeAfter.Format("2006-01-02 15:04:05")})
-	}
-	if query.IncludeAfter != nil {
-		tableData = append(tableData, []string{"Include After Filter", query.IncludeAfter.Format("2006-01-02 15:04:05")})
-	}
-	tableData = append(tableData, []string{"List Unaliased KMS Keys", fmt.Sprintf("%t", query.ListUnaliasedKMSKeys)})
-
-	return pterm.DefaultTable.WithBoxed(true).
-		WithData(tableData).
-		WithHasHeader(true).
-		WithHeaderRowSeparator("-").
-		Render()
+	return ui.RenderResourceTypesAsBulletList(aws.ListResourceTypes())
 }
diff --git a/ui/ui.go b/ui/ui.go
index fdf3307..847612b 100644
--- a/ui/ui.go
+++ b/ui/ui.go
@@ -2,6 +2,8 @@ package ui
 
 import (
 	"fmt"
+	"github.com/gruntwork-io/cloud-nuke/aws"
+	"github.com/gruntwork-io/go-commons/errors"
 	"io"
 	"os"
 	"strings"
@@ -141,3 +143,95 @@ func truncate(s string, maxLen int) string {
 func removeNewlines(s string) string {
 	return strings.ReplaceAll(s, "\n", "")
 }
+
+func RenderResourcesAsTable(account *aws.AwsAccountResources) error {
+	var tableData [][]string
+	tableData = append(tableData, []string{"Resource Type", "Region", "Identifier"})
+
+	for region, resourcesInRegion := range account.Resources {
+		for _, foundResources := range resourcesInRegion.Resources {
+			for _, identifier := range (*foundResources).ResourceIdentifiers() {
+				tableData = append(tableData, []string{(*foundResources).ResourceName(), region, identifier})
+			}
+		}
+	}
+
+	return pterm.DefaultTable.WithBoxed(true).
+		WithData(tableData).
+		WithHasHeader(true).
+		WithHeaderRowSeparator("-").
+		Render()
+}
+
+func RenderResourceTypesAsBulletList(resourceTypes []string) error {
+	var items []pterm.BulletListItem
+	for _, resourceType := range resourceTypes {
+		items = append(items, pterm.BulletListItem{Level: 0, Text: resourceType})
+	}
+
+	return pterm.DefaultBulletList.WithItems(items).Render()
+}
+
+func RenderQueryAsBulletList(query *aws.Query) error {
+	var tableData [][]string
+	tableData = append(tableData, []string{"Query Parameter", "Value"})
+
+	// Listing regions if there are <= 5 regions, otherwise the table format breaks.
+	if len(query.Regions) > 5 {
+		tableData = append(tableData,
+			[]string{"Target Regions", fmt.Sprintf("%d regions (too many to list all)", len(query.Regions))})
+	} else {
+		tableData = append(tableData, []string{"Target Regions", strings.Join(query.Regions, ", ")})
+	}
+
+	// Listing resource types if there are <= 5 resources, otherwise the table format breaks.
+	if len(query.ResourceTypes) > 5 {
+		tableData = append(tableData,
+			[]string{"Target Resource Types", fmt.Sprintf("%d resource types (too many to list all)", len(query.ResourceTypes))})
+	} else {
+		tableData = append(tableData, []string{"Target Resource Types", strings.Join(query.ResourceTypes, ", ")})
+	}
+
+	if query.ExcludeAfter != nil {
+		tableData = append(tableData, []string{"Exclude After Filter", query.ExcludeAfter.Format("2006-01-02 15:04:05")})
+	}
+	if query.IncludeAfter != nil {
+		tableData = append(tableData, []string{"Include After Filter", query.IncludeAfter.Format("2006-01-02 15:04:05")})
+	}
+	tableData = append(tableData, []string{"List Unaliased KMS Keys", fmt.Sprintf("%t", query.ListUnaliasedKMSKeys)})
+
+	return pterm.DefaultTable.WithBoxed(true).
+		WithData(tableData).
+		WithHasHeader(true).
+		WithHeaderRowSeparator("-").
+		Render()
+}
+
+func RenderNukeConfirmationPrompt(prompt string, numRetryCount int) (bool, error) {
+	prompts := 0
+
+	pterm.Println()
+	pterm.Warning.Println("THE NEXT STEPS ARE DESTRUCTIVE AND COMPLETELY IRREVERSIBLE, PROCEED WITH CAUTION!!!")
+
+	for prompts < numRetryCount {
+		confirmPrompt := pterm.DefaultInteractiveTextInput.WithMultiLine(false)
+		input, err := confirmPrompt.Show(prompt)
+		if err != nil {
+			logging.Logger.Errorf("[Failed to render prompt] %s", err)
+			return false, errors.WithStackTrace(err)
+		}
+
+		response := strings.ToLower(strings.TrimSpace(input))
+		if response == "nuke" {
+			pterm.Println()
+			return true, nil
+		}
+
+		pterm.Println()
+		pterm.Error.Println(fmt.Sprintf("Invalid value was entered: %s. Try again.", input))
+		prompts++
+	}
+
+	pterm.Println()
+	return false, nil
+}
diff --git a/util/string_utils.go b/util/string_utils.go
index 4e1b2e5..d4072b9 100644
--- a/util/string_utils.go
+++ b/util/string_utils.go
@@ -19,3 +19,20 @@ func Split(identifiers []string, limit int) [][]string {
 
 	return chunks
 }
+
+// Difference returns the elements in `a` that aren't in `b`.
+func Difference(a, b []*string) []*string {
+	mb := make(map[string]bool, len(b))
+	for _, x := range b {
+		mb[*x] = true
+	}
+
+	var diff []*string
+	for _, x := range a {
+		if _, found := mb[*x]; !found {
+			diff = append(diff, x)
+		}
+	}
+
+	return diff
+}


Test patch:
diff --git a/aws/resources/cloudwatch_alarm_test.go b/aws/resources/cloudwatch_alarm_test.go
index d5e5c1c..d95d687 100644
--- a/aws/resources/cloudwatch_alarm_test.go
+++ b/aws/resources/cloudwatch_alarm_test.go
@@ -112,3 +112,26 @@ func TestCloudWatchAlarms_NukeAll(t *testing.T) {
 	err := cw.nukeAll([]*string{aws.String(testName1), aws.String(testName2)})
 	require.NoError(t, err)
 }
+
+func TestCloudWatchCompositeAlarms_NukeAll(t *testing.T) {
+	telemetry.InitTelemetry("cloud-nuke", "")
+	t.Parallel()
+
+	testCompositeAlaram1 := "test-name1"
+	testCompositeAlaram2 := "test-name2"
+	testName3 := "test-name3"
+	now := time.Now()
+	cw := CloudWatchAlarms{
+		Client: mockedCloudWatchAlarms{
+			DescribeAlarmsOutput: cloudwatch.DescribeAlarmsOutput{
+				MetricAlarms: []*cloudwatch.MetricAlarm{
+					{AlarmName: aws.String(testCompositeAlaram1), AlarmConfigurationUpdatedTimestamp: &now},
+					{AlarmName: aws.String(testCompositeAlaram2), AlarmConfigurationUpdatedTimestamp: &now},
+				}},
+			PutCompositeAlarmOutput: cloudwatch.PutCompositeAlarmOutput{},
+			DeleteAlarmsOutput:      cloudwatch.DeleteAlarmsOutput{},
+		}}
+
+	err := cw.nukeAll([]*string{aws.String(testCompositeAlaram1), aws.String(testCompositeAlaram2), aws.String(testName3)})
+	require.NoError(t, err)
+}