Repository: go-to-k/delstack (go)

Issue:
[Feature] multiple stacks deletion
**Is your feature request related to a problem? Please describe.**
It's a pain to launch the tool every time I want to delete multiple stacks.

**Describe the solution you'd like**
Allow multiple stacks to be selected.

**Additional context**
> Add any other context or screenshots about the feature request here.


Pull request description:
feat(app): multiple stacks deletion
Closes #339 .


Patch:
diff --git a/README.md b/README.md
index 207273d..d9bf1d0 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,9 @@ All resources that do not fail normal deletion can be deleted as is.
   - CloudFormation stack name
     - Must be specified in **not** interactive mode
     - Otherwise you can specify it in the interactive mode
+  - **Multiple specifications are possible.**
+    - `delstack -s test1 -s test2`
+    - **Dependencies between stacks are taken into account, the stacks are deleted in order, starting with the newly created stack.**
 - -p, --profile: optional
   - AWS profile name
 - -r, --region: optional(default: `us-east-1`)
@@ -88,60 +91,59 @@ All resources that do not fail normal deletion can be deleted as is.
 
 ## Interactive Mode
 
-### ResourceTypes
-
-The `-i, --interactive` option allows you to select the ResourceTypes **you wish to force delete even if DELETE_FAILED**. This feature allows you to **protect resources** you really do not want to delete by **"do not select the ResourceTypes"**!
-
-However, if a resource can be deleted **without becoming DELETE_FAILED** by the **normal** CloudFormation stack deletion feature, the resource will be deleted **even if you do not select that resource type**. This tool is not intended to protect specific resources from the normal CloudFormation stack deletion feature, so I implemented this feature with the nuance that **only those resources that really should not be deleted will not be forced to be deleted**.
-
-If the stack contains resources that will be DELETE_FAILED but is not selected, **all DELETE_FAILED resources including the selected or not selected resources and the stack will remain undeleted**.
-
-```bash
-❯ delstack -s YourStack -i
-? Select ResourceTypes you wish to delete even if DELETE_FAILED.
-However, if a resource can be deleted without becoming DELETE_FAILED by the normal CloudFormation stack deletion feature, the resource will be deleted even if you do not select that resource type.
-  [Use arrows to move, space to select, <right> to all, <left> to none, type to filter]
-  [ ]  AWS::S3::Bucket
-  [x]  AWS::IAM::Role
-> [x]  AWS::ECR::Repository
-  [ ]  AWS::Backup::BackupVault
-  [x]  AWS::CloudFormation::Stack
-  [ ]  Custom::
-```
-
 ### StackName Selection
 
-If you do not specify a stack name in command options in the interactive mode, you can search stack names in a **case-insensitive** and select a stack.
+If you do not specify a stack name in command options in the interactive mode (`-i, --interactive`), you can search stack names in a **case-insensitive** and select a stack.
 
 It can be **empty**.
 
 ```bash
 ❯ delstack -i
-Filter a keyword of stack names(case-insensitive): test-goto
+Filter a keyword of stack names(case-insensitive): goto
 ```
 
 Then you select stack names in the UI.
 
 ```bash
-? Select StackName.
+? Select StackNames.
 Nested child stacks, XXX_IN_PROGRESS(e.g. ROLLBACK_IN_PROGRESS) status stacks and EnableTerminationProtection stacks are not displayed.
-  [Use arrows to move, type to filter]
-> test-goto-stack-1
-  test-goto-stack-2
-  test-goto-stack-3
-  TEST-GOTO-stack-4
-  Test-Goto-stack-5
-  TEST-goto-stack-6
+  [Use arrows to move, space to select, <right> to all, <left> to none, type to filter]
+  [x]  dev-goto-04-TestStack
+  [ ]  dev-GOTO-03-TestStack
+> [x]  dev-Goto-02-TestStack
+  [ ]  dev-goto-01-TestStack
 ```
 
 In addition, **child stacks of nested stacks are not displayed**. This is because it is unlikely that there are cases where only child stacks of nested stacks are deleted without deleting the parent stack, and also because it is possible that the parent stack may be buried in the stack list if there are child stacks, or that the child stacks may be accidentally deleted.
 
-However, the `-s` command option allows deletion of child stacks by specifying their names, so please use this option if you want.
+However, **the `-s` command option allows deletion of CHILD stacks by specifying their names**, so please use this option if you want.
 
 And stacks with **the XXX_IN_PROGRESS(e.g. ROLLBACK_IN_PROGRESS) CloudFormation status** are not displayed, because multiple CloudFormation operations should not be duplicated at the same time.
 
 Also, **"Termination Protection"** stacks will not be displayed, because it probably really should not want to delete it.
 
+### ResourceTypes
+
+The `-i, --interactive` option allows you to select the ResourceTypes **you wish to force delete even if DELETE_FAILED**. This feature allows you to **protect resources** you really do not want to delete by **"do not select the ResourceTypes"**!
+
+However, if a resource can be deleted **without becoming DELETE_FAILED** by the **normal** CloudFormation stack deletion feature, the resource will be deleted **even if you do not select that resource type**. This tool is not intended to protect specific resources from the normal CloudFormation stack deletion feature, so I implemented this feature with the nuance that **only those resources that really should not be deleted will not be forced to be deleted**.
+
+If the stack contains resources that will be DELETE_FAILED but is not selected, **all DELETE_FAILED resources including the selected or not selected resources and the stack will remain undeleted**.
+
+```bash
+❯ delstack -i -s dev-goto-01-TestStack
+? dev-goto-01-TestStack
+Select ResourceTypes you wish to delete even if DELETE_FAILED.
+However, if a resource can be deleted without becoming DELETE_FAILED by the normal CloudFormation stack deletion feature, the resource will be deleted even if you do not select that resource type.
+  [Use arrows to move, space to select, <right> to all, <left> to none, type to filter]
+  [x]  AWS::S3::Bucket
+  [ ]  AWS::IAM::Role
+> [x]  AWS::ECR::Repository
+  [ ]  AWS::Backup::BackupVault
+  [ ]  AWS::CloudFormation::Stack
+  [ ]  Custom::
+```
+
 ## GitHub Actions
 
 You can use delstack with parameters **"stack-name" and "region"** in GitHub Actions Workflow.
diff --git a/internal/app/app.go b/internal/app/app.go
index 04ba81d..f4e4d5c 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -15,24 +15,30 @@ import (
 
 type App struct {
 	Cli             *cli.App
-	StackName       string
+	StackNames      *cli.StringSlice
 	Profile         string
 	Region          string
 	InteractiveMode bool
 }
 
+type targetStack struct {
+	stackName           string
+	targetResourceTypes []string
+}
+
 func NewApp(version string) *App {
 	app := App{}
+	app.StackNames = cli.NewStringSlice()
 
 	app.Cli = &cli.App{
 		Name:  "delstack",
 		Usage: "A CLI tool to force delete the entire CloudFormation stack.",
 		Flags: []cli.Flag{
-			&cli.StringFlag{
+			&cli.StringSliceFlag{
 				Name:        "stackName",
 				Aliases:     []string{"s"},
-				Usage:       "CloudFormation stack name",
-				Destination: &app.StackName,
+				Usage:       "CloudFormation stack names(one or more)",
+				Destination: app.StackNames,
 			},
 			&cli.StringFlag{
 				Name:        "profile",
@@ -69,9 +75,9 @@ func (a *App) Run(ctx context.Context) error {
 
 func (a *App) getAction() func(c *cli.Context) error {
 	return func(c *cli.Context) error {
-		if !a.InteractiveMode && a.StackName == "" {
-			errMsg := fmt.Sprintln("The stack name must be specified in command options (-s) or a flow of the interactive mode (-i).")
-			return fmt.Errorf("StackNameNotSpecifiedError: %v", errMsg)
+		if !a.InteractiveMode && len(a.StackNames.Value()) == 0 {
+			errMsg := fmt.Sprintln("At least one stack name must be specified in command options (-s) or a flow of the interactive mode (-i).")
+			return fmt.Errorf("InvalidOptionError: %v", errMsg)
 		}
 
 		config, err := client.LoadAWSConfig(c.Context, a.Region, a.Profile)
@@ -79,70 +85,152 @@ func (a *App) getAction() func(c *cli.Context) error {
 			return err
 		}
 
-		var targetResourceTypes []string
-		var keyword string
-		continuation := true
-		if a.InteractiveMode {
-			targetResourceTypes, keyword, continuation = a.doInteractiveMode()
-		} else {
-			targetResourceTypes = resourcetype.GetResourceTypes()
-		}
+		operatorFactory := operation.NewOperatorFactory(config)
+		cloudformationStackOperator := operatorFactory.CreateCloudFormationStackOperator(resourcetype.GetResourceTypes())
+
+		deduplicatedStackNames := a.deduplicateStackNames()
 
-		if !continuation {
+		sortedStackNames, err := a.getSortedStackNames(c.Context, cloudformationStackOperator, deduplicatedStackNames)
+		if err != nil {
+			return err
+		}
+		// The case for interruption(Ctrl + C)
+		if len(sortedStackNames) == 0 {
 			return nil
 		}
 
-		operatorFactory := operation.NewOperatorFactory(config)
-		cloudformationStackOperator := operatorFactory.CreateCloudFormationStackOperator(targetResourceTypes)
+		targetStacks := a.attachTargetResourceTypes(sortedStackNames, deduplicatedStackNames)
+		// Explanation of deletion order in the case of multiple stacks
+		if len(targetStacks) > 1 {
+			io.Logger.Info().Msg("The stacks are removed in order of the latest creation time, taking into account dependencies.")
+		}
+
+		isRootStack := true
+		for _, stack := range targetStacks {
+			operatorCollection := operation.NewOperatorCollection(config, operatorFactory, stack.targetResourceTypes)
+			operatorManager := operation.NewOperatorManager(operatorCollection)
+			cloudformationStackOperator := operatorFactory.CreateCloudFormationStackOperator(stack.targetResourceTypes)
+
+			io.Logger.Info().Msgf("%v: Start deletion. Please wait a few minutes...", stack.stackName)
 
-		if a.InteractiveMode && a.StackName == "" {
-			stackNames, err := cloudformationStackOperator.ListStacksFilteredByKeyword(c.Context, aws.String(keyword))
-			if err != nil {
+			if err := cloudformationStackOperator.DeleteCloudFormationStack(c.Context, aws.String(stack.stackName), isRootStack, operatorManager); err != nil {
 				return err
 			}
-			if len(stackNames) == 0 {
-				errMsg := fmt.Sprintf("No stacks matching the keyword %s.", keyword)
-				return fmt.Errorf("NotExistsError: %v", errMsg)
-			}
 
-			stackName := a.selectStackName(stackNames)
-			if stackName == "" {
-				return nil
-			}
+			io.Logger.Info().Msgf("%v: Successfully deleted!!", stack.stackName)
+		}
+		return nil
+	}
+}
 
-			a.StackName = stackName
+func (a *App) deduplicateStackNames() []string {
+	deduplicatedStackNames := []string{}
+
+	for _, stackName := range a.StackNames.Value() {
+		var isDuplicated bool
+		for _, deduplicatedStackName := range deduplicatedStackNames {
+			if stackName == deduplicatedStackName {
+				isDuplicated = true
+				break
+			}
 		}
+		if !isDuplicated {
+			deduplicatedStackNames = append(deduplicatedStackNames, stackName)
+		}
+	}
 
-		isRootStack := true
-		operatorCollection := operation.NewOperatorCollection(config, operatorFactory, targetResourceTypes)
-		operatorManager := operation.NewOperatorManager(operatorCollection)
+	return deduplicatedStackNames
+}
 
-		io.Logger.Info().Msgf("Start deletion, %v", a.StackName)
-		io.Logger.Info().Msg("Please wait a few minutes...")
+func (a *App) getSortedStackNames(ctx context.Context, cloudformationStackOperator *operation.CloudFormationStackOperator, specifiedStackNames []string) ([]string, error) {
+	sortedStackNames := []string{}
 
-		if err := cloudformationStackOperator.DeleteCloudFormationStack(c.Context, aws.String(a.StackName), isRootStack, operatorManager); err != nil {
-			return err
+	if len(specifiedStackNames) != 0 {
+		stackNames, err := cloudformationStackOperator.GetSortedStackNames(ctx, specifiedStackNames)
+		if err != nil {
+			return sortedStackNames, err
+		}
+		sortedStackNames = append(sortedStackNames, stackNames...)
+	} else if a.InteractiveMode {
+		keyword := a.inputKeywordForFilter()
+		stacks, err := cloudformationStackOperator.ListStacksFilteredByKeyword(ctx, aws.String(keyword))
+		if err != nil {
+			return sortedStackNames, err
 		}
 
-		io.Logger.Info().Msgf("Successfully deleted, %v", a.StackName)
-		return nil
+		// The `ListStacksFilteredByKeyword` with SDK's `DescribeStacks` returns the stacks in descending order of CreationTime.
+		// Therefore, by deleting stacks in the same order, we can delete from a new stack that is not depended on by any stack.
+		stackNames := a.selectStackNames(stacks)
+		sortedStackNames = append(sortedStackNames, stackNames...)
 	}
+
+	return sortedStackNames, nil
 }
 
-func (a *App) doInteractiveMode() ([]string, string, bool) {
+func (a *App) attachTargetResourceTypes(sortedStackNames []string, specifiedStackNames []string) []targetStack {
+	targetStacks := []targetStack{}
+
+	// If stackNames are specified with an interactive mode option, select ResourceTypes in the order specified (not sorted order).
+	if a.InteractiveMode && len(specifiedStackNames) != 0 {
+		var selectedResourceTypes []targetStack
+		for _, stackName := range specifiedStackNames {
+			targetResourceTypes, continuation := a.selectResourceTypes(stackName)
+			if !continuation {
+				return nil
+			}
+			selectedResourceTypes = append(selectedResourceTypes, targetStack{
+				stackName:           stackName,
+				targetResourceTypes: targetResourceTypes,
+			})
+		}
+		for _, stackName := range sortedStackNames {
+			for _, selectedResourceType := range selectedResourceTypes {
+				if stackName == selectedResourceType.stackName {
+					targetStacks = append(targetStacks, selectedResourceType)
+				}
+			}
+		}
+	}
+	if a.InteractiveMode && len(specifiedStackNames) == 0 {
+		for _, stackName := range sortedStackNames {
+			targetResourceTypes, continuation := a.selectResourceTypes(stackName)
+			if !continuation {
+				return nil
+			}
+			targetStacks = append(targetStacks, targetStack{
+				stackName:           stackName,
+				targetResourceTypes: targetResourceTypes,
+			})
+		}
+	}
+	if !a.InteractiveMode {
+		for _, stackName := range sortedStackNames {
+			targetResourceTypes := resourcetype.GetResourceTypes()
+			targetStacks = append(targetStacks, targetStack{
+				stackName:           stackName,
+				targetResourceTypes: targetResourceTypes,
+			})
+		}
+	}
+
+	return targetStacks
+}
+
+func (a *App) inputKeywordForFilter() string {
+	label := "Filter a keyword of stack names(case-insensitive): "
+	return io.InputKeywordForFilter(label)
+}
+
+func (a *App) selectResourceTypes(stackName string) ([]string, bool) {
 	var checkboxes []string
-	var keyword string
 
-	label := "Select ResourceTypes you wish to delete even if DELETE_FAILED." +
+	label := stackName +
 		"\n" +
+		"Select ResourceTypes you wish to delete even if DELETE_FAILED.\n" +
 		"However, if a resource can be deleted without becoming DELETE_FAILED by the normal CloudFormation stack deletion feature, the resource will be deleted even if you do not select that resource type. " +
 		"\n"
-	opts := resourcetype.GetResourceTypes()
 
-	if a.StackName == "" {
-		stackNameLabel := "Filter a keyword of stack names(case-insensitive): "
-		keyword = io.InputKeywordForFilter(stackNameLabel)
-	}
+	opts := resourcetype.GetResourceTypes()
 
 	for {
 		checkboxes = io.GetCheckboxes(label, opts)
@@ -150,48 +238,48 @@ func (a *App) doInteractiveMode() ([]string, string, bool) {
 		if len(checkboxes) == 0 {
 			ok := io.GetYesNo("No selection?")
 			if ok {
-				return checkboxes, keyword, true
+				return checkboxes, true
 			}
 
 			// The case for interruption(Ctrl + C)
 			ok = io.GetYesNo("Do you want to finish?")
 			if ok {
 				io.Logger.Info().Msg("Finished...")
-				return checkboxes, keyword, false
+				return checkboxes, false
 			}
 			continue
 		}
 
 		ok := io.GetYesNo("OK?")
 		if ok {
-			return checkboxes, keyword, true
+			return checkboxes, true
 		}
 	}
 }
 
-func (a *App) selectStackName(stackNames []string) string {
-	var stackName string
+func (a *App) selectStackNames(stackNames []string) []string {
+	var selectedStackNames []string
 
-	label := "Select StackName." + "\n" +
+	label := "Select StackNames." + "\n" +
 		"Nested child stacks, XXX_IN_PROGRESS(e.g. ROLLBACK_IN_PROGRESS) status stacks and EnableTerminationProtection stacks are not displayed." +
 		"\n"
 
 	for {
-		stackName = io.GetSelection(label, stackNames)
+		selectedStackNames = io.GetCheckboxes(label, stackNames)
 
-		if stackName == "" {
-			io.Logger.Warn().Msg("Select StackName!")
+		if len(selectedStackNames) == 0 {
+			// The case for interruption(Ctrl + C)
 			ok := io.GetYesNo("Do you want to finish?")
 			if ok {
 				io.Logger.Info().Msg("Finished...")
-				return stackName
+				return selectedStackNames
 			}
 			continue
 		}
 
 		ok := io.GetYesNo("OK?")
 		if ok {
-			return stackName
+			return selectedStackNames
 		}
 	}
 }
diff --git a/internal/io/input.go b/internal/io/input.go
index 4de0d6f..d354a59 100644
--- a/internal/io/input.go
+++ b/internal/io/input.go
@@ -15,35 +15,11 @@ func GetCheckboxes(label string, opts []string) []string {
 	res := []string{}
 
 	prompt := &survey.MultiSelect{
-		Message: label,
-		Options: opts,
-	}
-	survey.AskOne(prompt, &res, survey.WithKeepFilter(true))
-
-	return res
-}
-
-func GetSelection(label string, opts []string) string {
-	var res string
-
-	prompt := &survey.Select{
 		Message:  label,
 		Options:  opts,
 		PageSize: SelectionPageSize,
 	}
-	survey.AskOne(
-		prompt,
-		&res,
-		survey.WithFilter(
-			func(filter string, value string, index int) (include bool) {
-				trimmedFilter := strings.TrimSpace(filter)
-				lowerFilter := strings.ToLower(trimmedFilter)
-				lowerValue := strings.ToLower(value)
-
-				return strings.Contains(lowerValue, lowerFilter)
-			},
-		),
-	)
+	survey.AskOne(prompt, &res, survey.WithKeepFilter(true))
 
 	return res
 }
diff --git a/internal/operation/cloudformation_stack.go b/internal/operation/cloudformation_stack.go
index 099e3bf..5e14e2f 100644
--- a/internal/operation/cloudformation_stack.go
+++ b/internal/operation/cloudformation_stack.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"regexp"
 	"runtime"
+	"sort"
 	"strings"
 
 	"github.com/aws/aws-sdk-go-v2/aws"
@@ -145,7 +146,7 @@ func (o *CloudFormationStackOperator) deleteStackNormally(ctx context.Context, s
 		return false, err
 	}
 	if len(stacksAfterDelete) == 0 {
-		io.Logger.Info().Msg("No resources were DELETE_FAILED.")
+		io.Logger.Info().Msgf("%v: No resources were DELETE_FAILED.", *stackName)
 		return true, nil
 	}
 	if stacksAfterDelete[0].StackStatus != "DELETE_FAILED" {
@@ -155,6 +156,60 @@ func (o *CloudFormationStackOperator) deleteStackNormally(ctx context.Context, s
 	return false, nil
 }
 
+func (o *CloudFormationStackOperator) GetSortedStackNames(ctx context.Context, stackNames []string) ([]string, error) {
+	sortedStackNames := []string{}
+	gotStacks := []types.Stack{}
+	notFoundStackNames := []string{}
+
+	type stackNameInProgress struct {
+		stackName   string
+		stackStatus types.StackStatus
+	}
+	stackNamesInProgress := []stackNameInProgress{}
+
+	for _, stackName := range stackNames {
+		stack, err := o.client.DescribeStacks(ctx, aws.String(stackName))
+		if err != nil {
+			return sortedStackNames, err
+		}
+
+		if len(stack) == 0 {
+			notFoundStackNames = append(notFoundStackNames, stackName)
+			continue
+		}
+		if o.isExceptedByStackStatus(stack[0].StackStatus) {
+			stackNamesInProgress = append(stackNamesInProgress, stackNameInProgress{
+				stackName:   stackName,
+				stackStatus: stack[0].StackStatus,
+			})
+			continue
+		}
+		gotStacks = append(gotStacks, stack[0]) // DescribeStacks returns a stack with a single element
+	}
+
+	if len(notFoundStackNames) > 0 {
+		errMsg := fmt.Sprintf("%s stack not found.", strings.Join(notFoundStackNames, ", "))
+		return sortedStackNames, fmt.Errorf("NotExistsError: %v", errMsg)
+	}
+	if len(stackNamesInProgress) > 0 {
+		var stackNamesWithStatus []string
+		for _, stack := range stackNamesInProgress {
+			stackNamesWithStatus = append(stackNamesWithStatus, fmt.Sprintf("%s: %s", stack.stackStatus, stack.stackName))
+		}
+		errMsg := fmt.Sprintf("Stacks with XxxInProgress cannot be deleted, but %s", strings.Join(stackNamesWithStatus, ", "))
+		return sortedStackNames, fmt.Errorf("OperationInProgressError: %v", errMsg)
+	}
+
+	// Sort gotStacks in descending order by stack.CreationTime
+	sort.Slice(gotStacks, func(i, j int) bool {
+		return gotStacks[i].CreationTime.After(*gotStacks[j].CreationTime)
+	})
+	for _, stack := range gotStacks {
+		sortedStackNames = append(sortedStackNames, *stack.StackName)
+	}
+	return sortedStackNames, nil
+}
+
 func (o *CloudFormationStackOperator) ListStacksFilteredByKeyword(ctx context.Context, keyword *string) ([]string, error) {
 	filteredStacks := []string{}
 
@@ -189,6 +244,11 @@ func (o *CloudFormationStackOperator) ListStacksFilteredByKeyword(ctx context.Co
 		}
 	}
 
+	if len(filteredStacks) == 0 {
+		errMsg := fmt.Sprintf("No stacks matching the keyword (%s).", *keyword)
+		return filteredStacks, fmt.Errorf("NotExistsError: %v", errMsg)
+	}
+
 	return filteredStacks, nil
 }
 


Test patch:
diff --git a/internal/operation/cloudformation_stack_test.go b/internal/operation/cloudformation_stack_test.go
index 97a60ff..22afd09 100644
--- a/internal/operation/cloudformation_stack_test.go
+++ b/internal/operation/cloudformation_stack_test.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"reflect"
 	"testing"
+	"time"
 
 	"github.com/aws/aws-sdk-go-v2/aws"
 	"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
@@ -1444,6 +1445,355 @@ func TestCloudFormationStackOperator_deleteStackNormally(t *testing.T) {
 	}
 }
 
+func TestCloudFormationStackOperator_GetSortedStackNames(t *testing.T) {
+	ctx := context.Background()
+
+	type args struct {
+		ctx        context.Context
+		stackNames []string
+	}
+
+	type want struct {
+		sortedStackNames []string
+		err              error
+	}
+
+	cases := []struct {
+		name                        string
+		args                        args
+		prepareMockCloudFormationFn func(m *client.MockICloudFormation)
+		want                        want
+		wantErr                     bool
+	}{
+		{
+			name: "sort stacks with ascending order successfully",
+			args: args{
+				ctx:        ctx,
+				stackNames: []string{"Stack1", "Stack2", "Stack3", "Stack4", "Stack5"},
+			},
+			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack1")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack1"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-40 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack2")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack2"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-30 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack3")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack3"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-20 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack4")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack4"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-10 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack5")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack5"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now()),
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				sortedStackNames: []string{"Stack5", "Stack4", "Stack3", "Stack2", "Stack1"},
+				err:              nil,
+			},
+			wantErr: false,
+		},
+		{
+			name: "sort stacks with descending order successfully",
+			args: args{
+				ctx:        ctx,
+				stackNames: []string{"Stack5", "Stack4", "Stack3", "Stack2", "Stack1"},
+			},
+			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack1")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack1"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-40 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack2")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack2"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-30 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack3")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack3"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-20 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack4")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack4"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-10 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack5")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack5"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now()),
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				sortedStackNames: []string{"Stack5", "Stack4", "Stack3", "Stack2", "Stack1"},
+				err:              nil,
+			},
+			wantErr: false,
+		},
+		{
+			name: "sort stacks with random order successfully",
+			args: args{
+				ctx:        ctx,
+				stackNames: []string{"Stack3", "Stack4", "Stack1", "Stack2", "Stack5"},
+			},
+			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack1")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack1"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-40 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack2")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack2"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-30 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack3")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack3"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-20 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack4")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack4"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-10 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack5")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack5"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now()),
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				sortedStackNames: []string{"Stack5", "Stack4", "Stack3", "Stack2", "Stack1"},
+				err:              nil,
+			},
+			wantErr: false,
+		},
+		{
+			name: "sort stacks failure for stacks in progress",
+			args: args{
+				ctx:        ctx,
+				stackNames: []string{"Stack1", "Stack2", "Stack3"},
+			},
+			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack1")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack1"),
+							StackStatus:  types.StackStatusUpdateInProgress,
+							CreationTime: aws.Time(time.Now().Add(-20 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack2")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack2"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-10 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack3")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack3"),
+							StackStatus:  types.StackStatusRollbackInProgress,
+							CreationTime: aws.Time(time.Now()),
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				sortedStackNames: []string{},
+				err:              fmt.Errorf("OperationInProgressError: Stacks with XxxInProgress cannot be deleted, but UPDATE_IN_PROGRESS: Stack1, ROLLBACK_IN_PROGRESS: Stack3"),
+			},
+			wantErr: true,
+		},
+		{
+			name: "sort stacks failure for non existent stacks",
+			args: args{
+				ctx:        ctx,
+				stackNames: []string{"Stack1", "Stack2", "Stack3"},
+			},
+			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack1")).Return(
+					[]types.Stack{},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack2")).Return(
+					[]types.Stack{},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack3")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack3"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now()),
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				sortedStackNames: []string{},
+				err:              fmt.Errorf("NotExistsError: Stack1, Stack2 stack not found."),
+			},
+			wantErr: true,
+		},
+		{
+			name: "sort stacks failure for DescribeStacksError",
+			args: args{
+				ctx:        ctx,
+				stackNames: []string{"Stack1", "Stack2"},
+			},
+			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack1")).Return(
+					[]types.Stack{
+						{
+							StackName:    aws.String("Stack1"),
+							StackStatus:  types.StackStatusCreateComplete,
+							CreationTime: aws.Time(time.Now().Add(-10 * time.Minute)),
+						},
+					},
+					nil,
+				)
+				m.EXPECT().DescribeStacks(gomock.Any(), aws.String("Stack2")).Return([]types.Stack{}, fmt.Errorf("DescribeStacksError"))
+			},
+			want: want{
+				sortedStackNames: []string{},
+				err:              fmt.Errorf("DescribeStacksError"),
+			},
+			wantErr: true,
+		},
+	}
+
+	for _, tt := range cases {
+		t.Run(tt.name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			cloudformationMock := client.NewMockICloudFormation(ctrl)
+
+			tt.prepareMockCloudFormationFn(cloudformationMock)
+
+			targetResourceTypes := []string{
+				"AWS::S3::Bucket",
+				"AWS::IAM::Role",
+				"AWS::ECR::Repository",
+				"AWS::Backup::BackupVault",
+				"AWS::CloudFormation::Stack",
+				"Custom::",
+			}
+
+			cloudformationStackOperator := NewCloudFormationStackOperator(aws.Config{}, cloudformationMock, targetResourceTypes)
+
+			output, err := cloudformationStackOperator.GetSortedStackNames(tt.args.ctx, tt.args.stackNames)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("error = %#v, wantErr %#v", err.Error(), tt.wantErr)
+				return
+			}
+			if tt.wantErr && err.Error() != tt.want.err.Error() {
+				t.Errorf("err = %#v, want %#v", err.Error(), tt.want.err.Error())
+				return
+			}
+			if !reflect.DeepEqual(output, tt.want.sortedStackNames) {
+				t.Errorf("output = %#v, want %#v", output, tt.want.sortedStackNames)
+			}
+		})
+	}
+}
+
 func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 	io.NewLogger(false)
 	ctx := context.Background()
@@ -1526,7 +1876,7 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			wantErr: false,
 		},
 		{
-			name: "list stacks with RootId filtered by keyword and no stacks do not have RootId found successfully",
+			name: "list stacks with RootId filtered by keyword and no stacks do not have RootId failure",
 			args: args{
 				ctx:     ctx,
 				keyword: "TestStack",
@@ -1550,9 +1900,9 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			},
 			want: want{
 				filteredStacks: []string{},
-				err:            nil,
+				err:            fmt.Errorf("NotExistsError: No stacks matching the keyword (TestStack)."),
 			},
-			wantErr: false,
+			wantErr: true,
 		},
 		{
 			name: "list stacks filtered by lower keyword successfully",
@@ -1645,7 +1995,7 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			wantErr: false,
 		},
 		{
-			name: "list stacks filtered by keyword but no stacks found successfully",
+			name: "list stacks filtered by keyword but no stacks failure",
 			args: args{
 				ctx:     ctx,
 				keyword: "TestStack",
@@ -1655,12 +2005,12 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			},
 			want: want{
 				filteredStacks: []string{},
-				err:            nil,
+				err:            fmt.Errorf("NotExistsError: No stacks matching the keyword (TestStack)."),
 			},
-			wantErr: false,
+			wantErr: true,
 		},
 		{
-			name: "list stacks filtered by keyword but empty keyword and no stacks found successfully",
+			name: "list stacks filtered by keyword but empty keyword and no stacks failure",
 			args: args{
 				ctx:     ctx,
 				keyword: "",
@@ -1670,9 +2020,9 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			},
 			want: want{
 				filteredStacks: []string{},
-				err:            nil,
+				err:            fmt.Errorf("NotExistsError: No stacks matching the keyword ()."),
 			},
-			wantErr: false,
+			wantErr: true,
 		},
 		{
 			name: "list stacks filtered by keyword failure for describe stacks errors",
@@ -1705,7 +2055,7 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			wantErr: true,
 		},
 		{
-			name: "list stacks filtered by keyword with XxxInProgress stacks successfully",
+			name: "list stacks filtered by keyword with XxxInProgress stacks failure",
 			args: args{
 				ctx:     ctx,
 				keyword: "TestStack",
@@ -1727,9 +2077,9 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			},
 			want: want{
 				filteredStacks: []string{},
-				err:            nil,
+				err:            fmt.Errorf("NotExistsError: No stacks matching the keyword (TestStack)."),
 			},
-			wantErr: false,
+			wantErr: true,
 		},
 		{
 			name: "list stacks filtered by keyword with EnableTerminationProtection stacks successfully",
@@ -1762,6 +2112,35 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			},
 			wantErr: false,
 		},
+		{
+			name: "list stacks filtered by keyword with EnableTerminationProtection stacks but empty failure",
+			args: args{
+				ctx:     ctx,
+				keyword: "TestStack",
+			},
+			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return(
+					[]types.Stack{
+						{
+							StackName:                   aws.String("TestStack1"),
+							StackStatus:                 types.StackStatusCreateComplete,
+							EnableTerminationProtection: aws.Bool(true),
+						},
+						{
+							StackName:                   aws.String("TestStack2"),
+							StackStatus:                 types.StackStatusCreateComplete,
+							EnableTerminationProtection: aws.Bool(true),
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				filteredStacks: []string{},
+				err:            fmt.Errorf("NotExistsError: No stacks matching the keyword (TestStack)."),
+			},
+			wantErr: true,
+		},
 	}
 
 	for _, tt := range cases {