Skip to content

Commit

Permalink
feat(operator): support IAM Groups (#368)
Browse files Browse the repository at this point in the history
* without tests and testdata

* change testdata

* mockgen

* change testdata sh

* remove unused processes

* operation tests

* unit tests

* typo in tests

* typo

* fix deploy.sh
  • Loading branch information
go-to-k authored Aug 13, 2024
1 parent cafa5ce commit be31631
Show file tree
Hide file tree
Showing 13 changed files with 1,290 additions and 36 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ All resources that do not fail normal deletion can be deleted as is.
| ---- | ---- |
| AWS::S3::Bucket | S3 Buckets, including buckets with **Non-empty or Versioning enabled** and DeletionPolicy **not Retain**. (Because "Retain" buckets should not be deleted.) |
| AWS::S3Express::DirectoryBucket | S3 Directory Buckets for S3 Express One Zone, including buckets with Non-empty and DeletionPolicy not Retain. (Because "Retain" buckets should not be deleted.) |
| AWS::IAM::Role | IAM Roles, including roles **with policies from outside the stack**. |
| AWS::IAM::Role | IAM Roles, including roles **with IAM policies from outside the stack**. |
| AWS::IAM::Group | IAM Groups, including groups **with IAM users from outside the stack.** |
| AWS::ECR::Repository | ECR Repositories, including repositories **containing images**. |
| AWS::Backup::BackupVault | Backup Vaults, including vaults **containing recovery points**. |
| AWS::CloudFormation::Stack | **Nested Child Stacks** that failed to delete. If any of the other resources are included in the child stack, **they too will be deleted**. |
Expand Down Expand Up @@ -140,6 +141,7 @@ However, if a resource can be deleted without becoming DELETE_FAILED by the norm
[x] AWS::S3::Bucket
[ ] AWS::S3Express::DirectoryBucket
[ ] AWS::IAM::Role
[ ] AWS::IAM::Group
> [x] AWS::ECR::Repository
[ ] AWS::Backup::BackupVault
[ ] AWS::CloudFormation::Stack
Expand Down
78 changes: 78 additions & 0 deletions internal/operation/iam_group.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package operation

import (
"context"
"runtime"

"github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
"github.com/go-to-k/delstack/pkg/client"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/semaphore"
)

var _ IOperator = (*IamGroupOperator)(nil)

type IamGroupOperator struct {
client client.IIam
resources []*types.StackResourceSummary
}

func NewIamGroupOperator(client client.IIam) *IamGroupOperator {
return &IamGroupOperator{
client: client,
resources: []*types.StackResourceSummary{},
}
}

func (o *IamGroupOperator) AddResource(resource *types.StackResourceSummary) {
o.resources = append(o.resources, resource)
}

func (o *IamGroupOperator) GetResourcesLength() int {
return len(o.resources)
}

func (o *IamGroupOperator) DeleteResources(ctx context.Context) error {
eg, ctx := errgroup.WithContext(ctx)
sem := semaphore.NewWeighted(int64(runtime.NumCPU()))

for _, Group := range o.resources {
Group := Group
if err := sem.Acquire(ctx, 1); err != nil {
return err
}
eg.Go(func() error {
defer sem.Release(1)

return o.DeleteIamGroup(ctx, Group.PhysicalResourceId)
})
}

return eg.Wait()
}

func (o *IamGroupOperator) DeleteIamGroup(ctx context.Context, GroupName *string) error {
exists, err := o.client.CheckGroupExists(ctx, GroupName)
if err != nil {
return err
}
if !exists {
return nil
}

users, err := o.client.GetGroupUsers(ctx, GroupName)
if err != nil {
return err
}
if len(users) > 0 {
if err := o.client.RemoveUsersFromGroup(ctx, GroupName, users); err != nil {
return err
}
}

if err := o.client.DeleteGroup(ctx, GroupName); err != nil {
return err
}

return nil
}
251 changes: 251 additions & 0 deletions internal/operation/iam_group_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
package operation

import (
"context"
"fmt"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
cfnTypes "github.com/aws/aws-sdk-go-v2/service/cloudformation/types"
"github.com/aws/aws-sdk-go-v2/service/iam/types"
"github.com/go-to-k/delstack/internal/io"
"github.com/go-to-k/delstack/pkg/client"
gomock "github.com/golang/mock/gomock"
)

/*
Test Cases
*/

func TestIamGroupOperator_DeleteIamGroup(t *testing.T) {
io.NewLogger(false)

type args struct {
ctx context.Context
groupName *string
}

cases := []struct {
name string
args args
prepareMockFn func(m *client.MockIIam)
want error
wantErr bool
}{
{
name: "delete group successfully",
args: args{
ctx: context.Background(),
groupName: aws.String("test"),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("test")).Return(true, nil)
m.EXPECT().GetGroupUsers(gomock.Any(), aws.String("test")).Return(
[]types.User{
{
UserName: aws.String("UserName1"),
},
{
UserName: aws.String("UserName2"),
},
}, nil)
m.EXPECT().RemoveUsersFromGroup(gomock.Any(), aws.String("test"), gomock.Any()).Return(nil)
m.EXPECT().DeleteGroup(gomock.Any(), aws.String("test")).Return(nil)
},
want: nil,
wantErr: false,
},
{
name: "delete group failure for CheckGroupExists errors",
args: args{
ctx: context.Background(),
groupName: aws.String("test"),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("test")).Return(false, fmt.Errorf("GetGroupError"))
},
want: fmt.Errorf("GetGroupError"),
wantErr: true,
},
{
name: "delete group failure for CheckGroupExists (not exists)",
args: args{
ctx: context.Background(),
groupName: aws.String("test"),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("test")).Return(false, nil)
},
want: nil,
wantErr: false,
},
{
name: "delete group failure for GetGroupUsers errors",
args: args{
ctx: context.Background(),
groupName: aws.String("test"),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("test")).Return(true, nil)
m.EXPECT().GetGroupUsers(gomock.Any(), aws.String("test")).Return(nil, fmt.Errorf("GetGroupUsersError"))
},
want: fmt.Errorf("GetGroupUsersError"),
wantErr: true,
},
{
name: "delete group failure for RemoveUsersFromGroup errors",
args: args{
ctx: context.Background(),
groupName: aws.String("test"),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("test")).Return(true, nil)
m.EXPECT().GetGroupUsers(gomock.Any(), aws.String("test")).Return(
[]types.User{
{
UserName: aws.String("UserName1"),
},
{
UserName: aws.String("UserName2"),
},
}, nil)
m.EXPECT().RemoveUsersFromGroup(gomock.Any(), aws.String("test"), gomock.Any()).Return(fmt.Errorf("RemoveUsersFromGroupError"))
},
want: fmt.Errorf("RemoveUsersFromGroupError"),
wantErr: true,
},
{
name: "delete group successfully for GetGroupUsers with zero length",
args: args{
ctx: context.Background(),
groupName: aws.String("test"),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("test")).Return(true, nil)
m.EXPECT().GetGroupUsers(gomock.Any(), aws.String("test")).Return([]types.User{}, nil)
m.EXPECT().DeleteGroup(gomock.Any(), aws.String("test")).Return(nil)
},
want: nil,
wantErr: false,
},
{
name: "delete group failure for DeleteGroup errors",
args: args{
ctx: context.Background(),
groupName: aws.String("test"),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("test")).Return(true, nil)
m.EXPECT().GetGroupUsers(gomock.Any(), aws.String("test")).Return(
[]types.User{
{
UserName: aws.String("UserName1"),
},
{
UserName: aws.String("UserName2"),
},
}, nil)
m.EXPECT().RemoveUsersFromGroup(gomock.Any(), aws.String("test"), gomock.Any()).Return(nil)
m.EXPECT().DeleteGroup(gomock.Any(), aws.String("test")).Return(fmt.Errorf("DeleteGroupError"))
},
want: fmt.Errorf("DeleteGroupError"),
wantErr: true,
},
}

for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
iamMock := client.NewMockIIam(ctrl)
tt.prepareMockFn(iamMock)

iamGroupOperator := NewIamGroupOperator(iamMock)

err := iamGroupOperator.DeleteIamGroup(tt.args.ctx, tt.args.groupName)
if (err != nil) != tt.wantErr {
t.Errorf("error = %#v, wantErr %#v", err.Error(), tt.wantErr)
return
}
if tt.wantErr && err.Error() != tt.want.Error() {
t.Errorf("err = %#v, want %#v", err.Error(), tt.want.Error())
return
}
})
}
}

func TestIamGroupOperator_DeleteResourcesForIamGroup(t *testing.T) {
io.NewLogger(false)

type args struct {
ctx context.Context
}

cases := []struct {
name string
args args
prepareMockFn func(m *client.MockIIam)
want error
wantErr bool
}{
{
name: "delete resources successfully",
args: args{
ctx: context.Background(),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("PhysicalResourceId1")).Return(true, nil)
m.EXPECT().GetGroupUsers(gomock.Any(), aws.String("PhysicalResourceId1")).Return(
[]types.User{
{
UserName: aws.String("UserName1"),
},
{
UserName: aws.String("UserName2"),
},
}, nil)
m.EXPECT().RemoveUsersFromGroup(gomock.Any(), aws.String("PhysicalResourceId1"), gomock.Any()).Return(nil)
m.EXPECT().DeleteGroup(gomock.Any(), aws.String("PhysicalResourceId1")).Return(nil)
},
want: nil,
wantErr: false,
},
{
name: "delete resources failure",
args: args{
ctx: context.Background(),
},
prepareMockFn: func(m *client.MockIIam) {
m.EXPECT().CheckGroupExists(gomock.Any(), aws.String("PhysicalResourceId1")).Return(false, fmt.Errorf("GetGroupError"))
},
want: fmt.Errorf("GetGroupError"),
wantErr: true,
},
}

for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
iamMock := client.NewMockIIam(ctrl)
tt.prepareMockFn(iamMock)

iamGroupOperator := NewIamGroupOperator(iamMock)
iamGroupOperator.AddResource(&cfnTypes.StackResourceSummary{
LogicalResourceId: aws.String("LogicalResourceId1"),
ResourceStatus: "DELETE_FAILED",
ResourceType: aws.String("AWS::IAM::Group"),
PhysicalResourceId: aws.String("PhysicalResourceId1"),
})

err := iamGroupOperator.DeleteResources(tt.args.ctx)
if (err != nil) != tt.wantErr {
t.Errorf("error = %#v, wantErr %#v", err.Error(), tt.wantErr)
return
}
if tt.wantErr && err.Error() != tt.want.Error() {
t.Errorf("err = %#v, want %#v", err.Error(), tt.want.Error())
return
}
})
}
}
7 changes: 6 additions & 1 deletion internal/operation/operator_collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func (c *OperatorCollection) SetOperatorCollection(stackName *string, stackResou
s3BucketOperator := c.operatorFactory.CreateS3BucketOperator()
s3DirectoryBucketOperator := c.operatorFactory.CreateS3DirectoryBucketOperator()
iamRoleOperator := c.operatorFactory.CreateIamRoleOperator()
iamGroupOperator := c.operatorFactory.CreateIamGroupOperator()
ecrRepositoryOperator := c.operatorFactory.CreateEcrRepositoryOperator()
backupVaultOperator := c.operatorFactory.CreateBackupVaultOperator()
cloudformationStackOperator := c.operatorFactory.CreateCloudFormationStackOperator(c.targetResourceTypes)
Expand All @@ -62,6 +63,8 @@ func (c *OperatorCollection) SetOperatorCollection(stackName *string, stackResou
s3DirectoryBucketOperator.AddResource(&stackResource)
case resourcetype.IamRole:
iamRoleOperator.AddResource(&stackResource)
case resourcetype.IamGroup:
iamGroupOperator.AddResource(&stackResource)
case resourcetype.EcrRepository:
ecrRepositoryOperator.AddResource(&stackResource)
case resourcetype.BackupVault:
Expand All @@ -80,6 +83,7 @@ func (c *OperatorCollection) SetOperatorCollection(stackName *string, stackResou
c.operators = append(c.operators, s3BucketOperator)
c.operators = append(c.operators, s3DirectoryBucketOperator)
c.operators = append(c.operators, iamRoleOperator)
c.operators = append(c.operators, iamGroupOperator)
c.operators = append(c.operators, ecrRepositoryOperator)
c.operators = append(c.operators, backupVaultOperator)
c.operators = append(c.operators, cloudformationStackOperator)
Expand Down Expand Up @@ -118,7 +122,8 @@ func (c *OperatorCollection) RaiseUnsupportedResourceError() error {
supportedStackResourcesData := [][]string{
{resourcetype.S3Bucket, "S3 Buckets, including buckets with Non-empty or Versioning enabled and DeletionPolicy not Retain."},
{resourcetype.S3DirectoryBucket, "S3 Directory Buckets for S3 Express One Zone, including buckets with Non-empty and DeletionPolicy not Retain."},
{resourcetype.IamRole, "IAM Roles, including roles with policies from outside the stack."},
{resourcetype.IamRole, "IAM Roles, including roles with IAM policies from outside the stack."},
{resourcetype.IamGroup, "IAM Groups, including groups with IAM users from outside the stack."},
{resourcetype.EcrRepository, "ECR Repositories, including repositories containing images."},
{resourcetype.BackupVault, "Backup Vaults, including vaults containing recovery points."},
{resourcetype.CloudformationStack, "Nested Child Stacks that failed to delete."},
Expand Down
Loading

0 comments on commit be31631

Please sign in to comment.