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

Issue:
[Feature] Do not display stacks with delete protection
Do not display stacks with delete protection in the interactive mode.

The type of []types.Stack for Stacks in DescribeStacksOutput, has the EnableTerminationProtection.

Pull request:
feat(operation): do not display stacks with delete protection in the interactive mode
Closes #74 

Patch:
diff --git a/README.md b/README.md
index 20101eb..227dbe8 100644
--- a/README.md
+++ b/README.md
@@ -130,7 +130,9 @@ In addition, **child stacks of nested stacks are not displayed**. This is becaus
 
 However, the `-s` command option allows deletion of child stacks by specifying their names, so please use this option if you want.
 
-Also 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.
+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.
 
 ## GitHub Actions
 
diff --git a/internal/operation/cloudformation_stack.go b/internal/operation/cloudformation_stack.go
index 2d690ef..4d71d34 100644
--- a/internal/operation/cloudformation_stack.go
+++ b/internal/operation/cloudformation_stack.go
@@ -141,39 +141,56 @@ func (o *CloudFormationStackOperator) ListStacksFilteredByKeyword(ctx context.Co
 	filteredStacks := []string{}
 
 	// Except StackStatusDeleteComplete and xxInProgress
-	stackStatusFilter := []types.StackStatus{
-		types.StackStatusCreateFailed,
-		types.StackStatusCreateComplete,
-		types.StackStatusRollbackFailed,
-		types.StackStatusRollbackComplete,
-		types.StackStatusDeleteFailed,
-		types.StackStatusUpdateComplete,
-		types.StackStatusUpdateFailed,
-		types.StackStatusUpdateRollbackFailed,
-		types.StackStatusUpdateRollbackComplete,
-		types.StackStatusImportComplete,
+	// In fact, DescribeStacks does not return a DELETE_COMPLETE stack, but it is included just in case.
+	stackStatusExceptionFilter := []types.StackStatus{
+		types.StackStatusCreateInProgress,
+		types.StackStatusRollbackInProgress,
+		types.StackStatusDeleteInProgress,
+		types.StackStatusUpdateInProgress,
+		types.StackStatusUpdateCompleteCleanupInProgress,
+		types.StackStatusUpdateRollbackInProgress,
+		types.StackStatusUpdateRollbackCompleteCleanupInProgress,
+		types.StackStatusReviewInProgress,
+		types.StackStatusImportInProgress,
 		types.StackStatusImportRollbackInProgress,
-		types.StackStatusImportRollbackFailed,
-		types.StackStatusImportRollbackComplete,
+		types.StackStatusDeleteComplete,
 	}
 
-	stackSummaries, err := o.client.ListStacks(ctx, stackStatusFilter)
+	// Use DescribeStacks instead of ListStacks to take EnableTerminationProtection
+	stacks, err := o.client.DescribeStacks(ctx, nil)
 	if err != nil {
 		return filteredStacks, err
 	}
 
 	lowerKeyword := strings.ToLower(*keyword)
 
-	for _, stackSummary := range stackSummaries {
-		// pass the nested child stacks
-		if stackSummary.RootId != nil {
+	for _, stack := range stacks {
+		// except the nested child stacks
+		if stack.RootId != nil {
+			continue
+		}
+
+		// except the stacks with EnableTerminationProtection
+		if stack.EnableTerminationProtection != nil && *stack.EnableTerminationProtection {
+			continue
+		}
+
+		// except the stacks that are in the exception list
+		shouldExcept := false
+		for _, status := range stackStatusExceptionFilter {
+			if stack.StackStatus == status {
+				shouldExcept = true
+				break
+			}
+		}
+		if shouldExcept {
 			continue
 		}
 
 		// for case-insensitive
-		lowerStackName := strings.ToLower(*stackSummary.StackName)
+		lowerStackName := strings.ToLower(*stack.StackName)
 		if strings.Contains(lowerStackName, lowerKeyword) {
-			filteredStacks = append(filteredStacks, *stackSummary.StackName)
+			filteredStacks = append(filteredStacks, *stack.StackName)
 		}
 	}
 


Test patch:
diff --git a/internal/operation/cloudformation_stack_test.go b/internal/operation/cloudformation_stack_test.go
index 849ba6f..e3bffb1 100644
--- a/internal/operation/cloudformation_stack_test.go
+++ b/internal/operation/cloudformation_stack_test.go
@@ -1433,23 +1433,6 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 		err            error
 	}
 
-	// Except StackStatusDeleteComplete and xxInProgress
-	stackStatusFilter := []types.StackStatus{
-		types.StackStatusCreateFailed,
-		types.StackStatusCreateComplete,
-		types.StackStatusRollbackFailed,
-		types.StackStatusRollbackComplete,
-		types.StackStatusDeleteFailed,
-		types.StackStatusUpdateComplete,
-		types.StackStatusUpdateFailed,
-		types.StackStatusUpdateRollbackFailed,
-		types.StackStatusUpdateRollbackComplete,
-		types.StackStatusImportComplete,
-		types.StackStatusImportRollbackInProgress,
-		types.StackStatusImportRollbackFailed,
-		types.StackStatusImportRollbackComplete,
-	}
-
 	cases := []struct {
 		name                        string
 		args                        args
@@ -1464,8 +1447,8 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 				keyword: "TestStack",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return(
-					[]types.StackSummary{
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return(
+					[]types.Stack{
 						{
 							StackName:   aws.String("TestStack1"),
 							StackStatus: types.StackStatusCreateComplete,
@@ -1494,8 +1477,8 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 				keyword: "TestStack",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return(
-					[]types.StackSummary{
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return(
+					[]types.Stack{
 						{
 							StackName:   aws.String("TestStack1"),
 							StackStatus: types.StackStatusCreateComplete,
@@ -1524,8 +1507,8 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 				keyword: "TestStack",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return(
-					[]types.StackSummary{
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return(
+					[]types.Stack{
 						{
 							StackName:   aws.String("TestStack1"),
 							StackStatus: types.StackStatusCreateComplete,
@@ -1553,8 +1536,8 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 				keyword: "teststack",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return(
-					[]types.StackSummary{
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return(
+					[]types.Stack{
 						{
 							StackName:   aws.String("TestStack1"),
 							StackStatus: types.StackStatusCreateComplete,
@@ -1583,8 +1566,8 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 				keyword: "TESTSTACK",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return(
-					[]types.StackSummary{
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return(
+					[]types.Stack{
 						{
 							StackName:   aws.String("TestStack1"),
 							StackStatus: types.StackStatusCreateComplete,
@@ -1613,8 +1596,8 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 				keyword: "",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return(
-					[]types.StackSummary{
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return(
+					[]types.Stack{
 						{
 							StackName:   aws.String("TestStack1"),
 							StackStatus: types.StackStatusCreateComplete,
@@ -1643,7 +1626,7 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 				keyword: "TestStack",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return([]types.StackSummary{}, nil)
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return([]types.Stack{}, nil)
 			},
 			want: want{
 				filteredStacks: []string{},
@@ -1658,7 +1641,7 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 				keyword: "",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return([]types.StackSummary{}, nil)
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return([]types.Stack{}, nil)
 			},
 			want: want{
 				filteredStacks: []string{},
@@ -1667,35 +1650,121 @@ func TestCloudFormationStackOperator_ListStacksFilteredByKeyword(t *testing.T) {
 			wantErr: false,
 		},
 		{
-			name: "list stacks filtered by keyword failure for list stacks errors",
+			name: "list stacks filtered by keyword failure for describe stacks errors",
 			args: args{
 				ctx:     ctx,
 				keyword: "TestStack",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return([]types.StackSummary{}, fmt.Errorf("ListStacksError"))
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return([]types.Stack{}, fmt.Errorf("DescribeStacksError"))
 			},
 			want: want{
 				filteredStacks: []string{},
-				err:            fmt.Errorf("ListStacksError"),
+				err:            fmt.Errorf("DescribeStacksError"),
 			},
 			wantErr: true,
 		},
 		{
-			name: "list stacks filtered by keyword but empty keyword failure for list stacks errors",
+			name: "list stacks filtered by keyword but empty keyword failure for describe stacks errors",
 			args: args{
 				ctx:     ctx,
 				keyword: "",
 			},
 			prepareMockCloudFormationFn: func(m *client.MockICloudFormation) {
-				m.EXPECT().ListStacks(gomock.Any(), stackStatusFilter).Return([]types.StackSummary{}, fmt.Errorf("ListStacksError"))
+				m.EXPECT().DescribeStacks(gomock.Any(), nil).Return([]types.Stack{}, fmt.Errorf("DescribeStacksError"))
 			},
 			want: want{
 				filteredStacks: []string{},
-				err:            fmt.Errorf("ListStacksError"),
+				err:            fmt.Errorf("DescribeStacksError"),
 			},
 			wantErr: true,
 		},
+		{
+			name: "list stacks filtered by keyword with XxxInProgress stacks successfully",
+			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.StackStatusCreateInProgress,
+						},
+						{
+							StackName:   aws.String("TestStack2"),
+							StackStatus: types.StackStatusUpdateRollbackCompleteCleanupInProgress,
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				filteredStacks: []string{},
+				err:            nil,
+			},
+			wantErr: false,
+		},
+		// In fact, DescribeStacks does not return a DELETE_COMPLETE stack, but it is included just in case.
+		{
+			name: "list stacks filtered by keyword with DeleteComplete stacks successfully",
+			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.StackStatusDeleteComplete,
+						},
+						{
+							StackName:   aws.String("TestStack2"),
+							StackStatus: types.StackStatusDeleteComplete,
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				filteredStacks: []string{},
+				err:            nil,
+			},
+			wantErr: false,
+		},
+		{
+			name: "list stacks filtered by keyword with EnableTerminationProtection stacks successfully",
+			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(false),
+						},
+						{
+							StackName:                   aws.String("TestStack2"),
+							StackStatus:                 types.StackStatusCreateComplete,
+							EnableTerminationProtection: aws.Bool(true),
+						},
+					},
+					nil,
+				)
+			},
+			want: want{
+				filteredStacks: []string{
+					"TestStack1",
+				},
+				err: nil,
+			},
+			wantErr: false,
+		},
 	}
 
 	for _, tt := range cases {