less is more

心のフルスタックエンジニア👨‍💻バイブスでコードを書いています🤘

イメージが残っているECRを含んだCloudformationスタックは削除できない。なのでできるようにした。

できない

ECR単体の削除であれば、例えばCLIだったらecr delete-repository --forceとすればイメージごとリポジトリを削除できるのだが、Cloudformationのスタックとして作成されている場合、イメージが残っているとスタックの削除に失敗する。このケースにおいてイメージごと抹消する方法は存在しない。

いろいろ試してる時にイメージが入ってて消せないみたいなことがよくあり、個人的には結構需要を感じている。

できるようにした

最近Goの練習がてら作っているCLIツールのサブコマンドとして実装。

github.com

事前に中のイメージを全削除してからdelete-stackを呼び出す様な設計でいこうと思う。

サブコマンドの追加

cobraをベースとしているので、サブコマンドの追加はcobra addで行えるのだが、今回はサブコマンドのサブコマンドとなるので、手動でファイルを追加する。

abcというツールで、aws-cliを模してサービス名をサブコマンドとしている。
今回作るコマンドはabc cfn purge-stack --stack-name xxxとする。

ディレクトリ構成はAWSのサービスごと、コマンドごとにフォルダが切ってあるので、/lib/cfn/purge_stack/purge_stack.goというファイルを置く。

(エントリポイントとなる親コマンドはすでにあるので今回は割愛。
cfn unused-exportsというコマンドがすでに実装済みなのでcmd/cfn.goが存在する。)

// /lib/cfn/purge_stack/purge_stack.go

package purge_stack

var CfnClient cloudformationiface.CloudFormationAPI
var EcrClient ecriface.ECRAPI

var (
    stackName string
)

func NewCmd() *cobra.Command {
    cmd := &cobra.Command{
        Use:   "purge-stack",
        Short: "Delete stack completely including resource contents.",
        Long: `
[abc cfn purge-stack]
This command delete CloudFormation's stack,
which delete-stack api provided by AWS officcially cannot to perform.
For example, a stack which includes non-empty ECR repository.

Internally it uses aws cloudformation api.
Please configure your aws credentials with following policies.
- cloudformation:DeleteStack
- cloudformation:ListStackResources
- ecr:BatchDeleteImages
- ecr:DescribeImages`,
        RunE: func(cmd *cobra.Command, args []string) error {
            err := run(cmd, args)
            return err
        },
    }
    cmd.Flags().StringVar(&stackName, "stack-name", "", "stack name to delete")
    cmd.MarkFlagRequired("stack-name")
    return cmd
}

func run(cmd *cobra.Command, args []string) error {
    if err := ExecPurgeStack(cmd, args); err != nil {
        return err
    }
    cmd.Println("Perform delete-stack is in progress asynchronously.\nPlease check deletion status by yourself.")
    return nil
}

func ExecPurgeStack(cmd *cobra.Command, args []string) error {
//
}

cobraデフォルトのジェネレータではコマンドがハードコーティングされて単体でテストできないため、コマンドを生成するNewCmd()をメソッドとして定義する。

エントリポイントは、エラーハンドリングを細かくやるためにRunではなくRunEを使用し、runメソッドに委譲する。

そこからさらに、単体でテストしやすい単位で内部実装を別のパブリックなメソッドに委譲する。runはなるべく薄く保つ様にしているが、このあたりの境界をどうするのがベストなのかはまだ答えがない。

例えばAWS APIをたたいてデータを取得して、それをJSON形式で標準出力に流すという処理の場合、フローは以下の様なステップに分割できる。

  • コマンドの初期化
  • フラグのパース
  • AWS認証
  • データの取得
  • JSON変換
  • 出力

フラグのパースはNewCmd()に含めて透過的に扱うことになる。
ただし親コマンドを経由しないでパッケージ単体でテストする場合は明示的にcmd.Flags().Set()を呼ぶ必要がある。

AWS認証はテストでサービスクライアントをモックするために、少しトリックを使う必要があるが、基本的にはパブリックメソッド内に含む。

APIを使用するデータ取得部分がパブリックメソッドの主な処理内容。
パースされたフラグ、初期化済みのサービスクライアントを用いてAPIをコールして結果を返す。
データ型は独自の構造体のスライスにすることが多い。
JSON変換はここでは行わない。JSON文字列はテスト書きにくいので。

JSON変換と出力は薄く切ったrunで行う。
これはグルーコードなのでテストはせず、rootコマンドのテストでE2Eとして出力を確認することにしている。

細かいパターンの網羅はパッケージ単体のUTで行い、E2EではフラグがちゃんとパースされてJSONフォーマットで標準出力に出力されることを確認する。

デフォルトのcobraのジェネレータではこのあたりがベッタリ密結合してしまう🤔

サブコマンドの登録

サブサブコマンドとなるので、親であるcfnサブコマンドに登録を行う。
cfn unused-exportsというサブサブコマンドがすでに登録済み)

// /cmd/cfn.go

package cmd

import (
    "github.com/Blue-Pix/abc/lib/cfn"
    "github.com/Blue-Pix/abc/lib/cfn/purge_stack"
    "github.com/Blue-Pix/abc/lib/cfn/unused_exports"
)

var cfnCmd = cfn.NewCmd()
var unusedExportsCmd = unused_exports.NewCmd()
// 追加
var purgeStackCmd = purge_stack.NewCmd()

func init() {
    cfnCmd.SetOut(rootCmd.OutOrStdout())
    rootCmd.AddCommand(cfnCmd)
    cfnCmd.AddCommand(unusedExportsCmd)
  // 追加
    cfnCmd.AddCommand(purgeStackCmd)
}

ロジックの実装

以下の4つのAPIを使います。

  • cloudformation:ListStackResources(スタックに含まれるリソースを取得)
  • ecr:DescribeImages(リポジトリに含まれるイメージを取得)
  • ecr:BatchDeleteImage(イメージの削除)
  • cloudformation:DeleteStack(スタックの削除)

フローとしては、

  • スタック内のリソースを走査
  • ECRが含まれていた場合、イメージが含まれているか確認
  • 取得したイメージをバッチで全削除
  • スタックを削除

という流れになります。

イメージはタグかダイジェストを指定して削除することになります。
タグだとややこしいのでダイジェストで決め打ちで消していきます。

最後のメインとなるスタックの削除処理ですが、戻り値はありません。
というのもスタックの削除は非同期なので、削除されたかどうかは list-stacks API などでポーリングして確認する必要があります。
なのでとりあえずコールが成功したら「削除実行中だよ、ステータスは自分で確認してね」というメッセージを出すことにします。

package purge_stack

import (
    "errors"
    "fmt"

    "github.com/Blue-Pix/abc/lib/util"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/service/cloudformation"
    "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface"
    "github.com/aws/aws-sdk-go/service/ecr"
    "github.com/aws/aws-sdk-go/service/ecr/ecriface"
    "github.com/spf13/cobra"
)

var CfnClient cloudformationiface.CloudFormationAPI
var EcrClient ecriface.ECRAPI

var (
    stackName string
)

func NewCmd() *cobra.Command {
    cmd := &cobra.Command{
        Use:   "purge-stack",
        Short: "Delete stack completely including resource contents.",
        Long: `
[abc cfn purge-stack]
This command delete CloudFormation's stack,
which delete-stack api provided by AWS officcially cannot to perform.
For example, a stack which includes non-empty ECR repository.

Internally it uses aws cloudformation api.
Please configure your aws credentials with following policies.
- cloudformation:DeleteStack
- cloudformation:ListStackResources
- ecr:BatchDeleteImages
- ecr:DescribeImages`,
        RunE: func(cmd *cobra.Command, args []string) error {
            err := run(cmd, args)
            return err
        },
    }
    cmd.Flags().StringVar(&stackName, "stack-name", "", "stack name to delete")
    cmd.MarkFlagRequired("stack-name")
    return cmd
}

func run(cmd *cobra.Command, args []string) error {
    if err := ExecPurgeStack(cmd, args); err != nil {
        return err
    }
    cmd.Println("Perform delete-stack is in progress asynchronously.\nPlease check deletion status by yourself.")
    return nil
}

func ExecPurgeStack(cmd *cobra.Command, args []string) error {
    initClient(cmd)
    resources, err := listEcrResources(nil, []*cloudformation.StackResourceSummary{})
    if err != nil {
        return err
    }
    for _, resource := range resources {
        repositoryName := resource.PhysicalResourceId
        images, err := listImageDigests(nil, repositoryName, []*ecr.ImageIdentifier{})
        if err != nil {
            return err
        }
        if len(images) == 0 {
            continue
        }
        failures, err := deleteImages(images, repositoryName)
        if err != nil {
            return err
        }
        if len(failures) > 0 {
            cmd.Println(failures)
            return errors.New(fmt.Sprintf("failed to delete images of %s", aws.StringValue(repositoryName)))
        }
        cmd.Println(fmt.Sprintf("All images in %s successfully deleted.", aws.StringValue(repositoryName)))
    }

    if err = deleteStack(stackName); err != nil {
        return err
    }

    return nil
}

func initClient(cmd *cobra.Command) {
    profile, _ := cmd.Flags().GetString("profile")
    region, _ := cmd.Flags().GetString("region")
    if CfnClient == nil {
        sess := util.CreateSession(profile, region)
        CfnClient = cloudformation.New(sess)
    }
    if EcrClient == nil {
        sess := util.CreateSession(profile, region)
        EcrClient = ecr.New(sess)
    }
}

func listEcrResources(token *string, ecrs []*cloudformation.StackResourceSummary) ([]*cloudformation.StackResourceSummary, error) {
    params := &cloudformation.ListStackResourcesInput{
        NextToken: token,
        StackName: aws.String(stackName),
    }
    resp, err := CfnClient.ListStackResources(params)
    if err != nil {
        return nil, err
    }
    for _, r := range resp.StackResourceSummaries {
        if aws.StringValue(r.ResourceType) == "AWS::ECR::Repository" {
            ecrs = append(ecrs, r)
        }
    }
    if resp.NextToken != nil {
        ecrs, err = listEcrResources(resp.NextToken, ecrs)
        if err != nil {
            return nil, err
        }
    }
    return ecrs, nil
}

func listImageDigests(token *string, repositoryName *string, images []*ecr.ImageIdentifier) ([]*ecr.ImageIdentifier, error) {
    params := &ecr.DescribeImagesInput{
        NextToken:      token,
        MaxResults:     aws.Int64(1000),
        RepositoryName: repositoryName,
    }
    resp, err := EcrClient.DescribeImages(params)
    if err != nil {
        return nil, err
    }
    for _, i := range resp.ImageDetails {
        images = append(images, &ecr.ImageIdentifier{
            ImageDigest: i.ImageDigest,
        })
    }
    if resp.NextToken != nil {
        images, err = listImageDigests(resp.NextToken, repositoryName, images)
        if err != nil {
            return nil, err
        }
    }
    return images, nil
}

func deleteImages(images []*ecr.ImageIdentifier, repositoryName *string) ([]*ecr.ImageFailure, error) {
    params := &ecr.BatchDeleteImageInput{
        ImageIds:       images,
        RepositoryName: repositoryName,
    }
    resp, err := EcrClient.BatchDeleteImage(params)
    if err != nil {
        return nil, err
    }
    return resp.Failures, nil
}

func deleteStack(stackName string) error {
    params := &cloudformation.DeleteStackInput{
        StackName: aws.String(stackName),
    }
    _, err := CfnClient.DeleteStack(params)
    if err != nil {
        return err
    }
    return nil
}

サービスクライアントはテストでモックを差し込めるようにインターフェースにしてパブリックに出しています。
aws-sdk-goがインターフェースを用意してくれているので便利です。

あとはAWS全般に言えることですが、ページングの実装に気をつける必要があります。レスポンスにNextTokenが含まれている場合、まだ結果があるので再帰的に呼び出しを行います。

テスト書く

パッケージとしてはExecPurgeStackAPIが呼び出されていることを確認します。 rootのテストでフラグのパース、標準出力をテストします。

// purge_stack_test.go
package purge_stack_test

import (
    "errors"
    "fmt"
    "os"
    "testing"

    "github.com/Blue-Pix/abc/lib/cfn/purge_stack"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/awserr"
    "github.com/aws/aws-sdk-go/service/cloudformation"
    "github.com/aws/aws-sdk-go/service/ecr"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func initMockClient(cm *purge_stack.MockCfnClient, em *purge_stack.MockEcrClient) {
    purge_stack.SetMockDefaultBehaviour(cm, em)
    purge_stack.CfnClient = cm
    purge_stack.EcrClient = em
}

func TestExecPurgeStack(t *testing.T) {
    // include two ecr resources, one with images, other with no image.
    t.Run("success", func(t *testing.T) {
        stackName := "foo"
        cm := &purge_stack.MockCfnClient{}
        em := &purge_stack.MockEcrClient{}
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Nil(t, err)
        cm.AssertNumberOfCalls(t, "ListStackResources", 2)
        em.AssertNumberOfCalls(t, "DescribeImages", 3)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 1)
        cm.AssertNumberOfCalls(t, "DeleteStack", 1)
    })

    t.Run("stack without ecr", func(t *testing.T) {
        stackName := "foo"
        cm := &purge_stack.MockCfnClient{}
        cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{
            StackName: aws.String(stackName),
        }).Return(
            &cloudformation.ListStackResourcesOutput{
                NextToken: nil,
                StackResourceSummaries: []*cloudformation.StackResourceSummary{
                    {PhysicalResourceId: aws.String("cluster"), ResourceType: aws.String("AWS::ECS::Cluster")},
                },
            },
            nil,
        )
        em := &purge_stack.MockEcrClient{}
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Nil(t, err)
        cm.AssertNumberOfCalls(t, "ListStackResources", 1)
        em.AssertNumberOfCalls(t, "DescribeImages", 0)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 0)
        cm.AssertNumberOfCalls(t, "DeleteStack", 1)
    })

    /************************************
       Authorization Error
   ************************************/

    t.Run("authorization error for ListStackResources", func(t *testing.T) {
        stackName := "foo"
        const errorCode = "AccessDeniedException"
        const errorMsg = "An error occurred (AccessDeniedException) when calling the ListStackResources operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: cloudformation:ListStackResources"
        cm := &purge_stack.MockCfnClient{}
        cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{StackName: aws.String(stackName)}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge")))
        em := &purge_stack.MockEcrClient{}
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Equal(t, errorCode, err.(awserr.Error).Code())
        assert.Equal(t, errorMsg, err.(awserr.Error).Message())
        cm.AssertNumberOfCalls(t, "ListStackResources", 1)
        em.AssertNumberOfCalls(t, "DescribeImages", 0)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 0)
        cm.AssertNumberOfCalls(t, "DeleteStack", 0)
    })

    t.Run("authorization error for DescribeImages", func(t *testing.T) {
        stackName := "foo"
        const errorCode = "AccessDeniedException"
        const errorMsg = "An error occurred (AccessDeniedException) when calling the DescribeImages operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: ecr:DescribeImages"
        cm := &purge_stack.MockCfnClient{}
        em := &purge_stack.MockEcrClient{}
        em.On("DescribeImages", &ecr.DescribeImagesInput{NextToken: nil, MaxResults: aws.Int64(1000), RepositoryName: aws.String("ecr1")}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge")))
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Equal(t, errorCode, err.(awserr.Error).Code())
        assert.Equal(t, errorMsg, err.(awserr.Error).Message())
        cm.AssertNumberOfCalls(t, "ListStackResources", 2)
        em.AssertNumberOfCalls(t, "DescribeImages", 1)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 0)
        cm.AssertNumberOfCalls(t, "DeleteStack", 0)
    })

    t.Run("authorization error for BatchDeleteImage", func(t *testing.T) {
        stackName := "foo"
        const errorCode = "AccessDeniedException"
        const errorMsg = "An error occurred (AccessDeniedException) when calling the BatchDeleteImage operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: ecr:BatchDeleteImage"
        cm := &purge_stack.MockCfnClient{}
        em := &purge_stack.MockEcrClient{}
        em.On("BatchDeleteImage", &ecr.BatchDeleteImageInput{
            ImageIds: []*ecr.ImageIdentifier{
                {ImageDigest: aws.String("foofoofoo")},
                {ImageDigest: aws.String("barbarbar")},
                {ImageDigest: aws.String("foobarfoobar")},
                {ImageDigest: aws.String("barfoobarfoo")},
            },
            RepositoryName: aws.String("ecr1"),
        }).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge")))
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Equal(t, errorCode, err.(awserr.Error).Code())
        assert.Equal(t, errorMsg, err.(awserr.Error).Message())
        cm.AssertNumberOfCalls(t, "ListStackResources", 2)
        em.AssertNumberOfCalls(t, "DescribeImages", 2)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 1)
        cm.AssertNumberOfCalls(t, "DeleteStack", 0)
    })

    t.Run("authorization error for DeleteStack", func(t *testing.T) {
        stackName := "foo"
        const errorCode = "AccessDeniedException"
        const errorMsg = "An error occurred (AccessDeniedException) when calling the ListStacks operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: cloudformation:ListStacks"
        cm := &purge_stack.MockCfnClient{}
        cm.On("DeleteStack", &cloudformation.DeleteStackInput{StackName: aws.String(stackName)}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge")))
        em := &purge_stack.MockEcrClient{}
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Equal(t, errorCode, err.(awserr.Error).Code())
        assert.Equal(t, errorMsg, err.(awserr.Error).Message())
        cm.AssertNumberOfCalls(t, "ListStackResources", 2)
        em.AssertNumberOfCalls(t, "DescribeImages", 3)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 1)
        cm.AssertNumberOfCalls(t, "DeleteStack", 1)
    })

    t.Run("authorization error for ListStackResources", func(t *testing.T) {
        stackName := "foo"
        const errorCode = "AccessDeniedException"
        const errorMsg = "An error occurred (AccessDeniedException) when calling the ListStackResources operation: User: arn:aws:iam::xxxxx:user/xxxxx is not authorized to perform: cloudformation:ListStackResources"
        cm := &purge_stack.MockCfnClient{}
        cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{StackName: aws.String(stackName)}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge")))
        em := &purge_stack.MockEcrClient{}
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Equal(t, errorCode, err.(awserr.Error).Code())
        assert.Equal(t, errorMsg, err.(awserr.Error).Message())
        cm.AssertNumberOfCalls(t, "ListStackResources", 1)
        em.AssertNumberOfCalls(t, "DescribeImages", 0)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 0)
        cm.AssertNumberOfCalls(t, "DeleteStack", 0)
    })

    /************************************
       Validation Error
   ************************************/

    t.Run("no such stack name", func(t *testing.T) {
        stackName := "no_such_stack_name"
        const errorCode = "ValidationError"
        const errorMsg = "Stack with id no_such_stack_name does not exist"
        cm := &purge_stack.MockCfnClient{}
        cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{StackName: aws.String(stackName)}).Return(nil, awserr.New(errorCode, errorMsg, errors.New("hoge")))
        em := &purge_stack.MockEcrClient{}
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Equal(t, errorCode, err.(awserr.Error).Code())
        assert.Equal(t, errorMsg, err.(awserr.Error).Message())
        cm.AssertNumberOfCalls(t, "ListStackResources", 1)
        em.AssertNumberOfCalls(t, "DescribeImages", 0)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 0)
        cm.AssertNumberOfCalls(t, "DeleteStack", 0)
    })

    /************************************
       Others
   ************************************/

    t.Run("failed on BatchDeleteImage", func(t *testing.T) {
        stackName := "foo"
        cm := &purge_stack.MockCfnClient{}
        em := &purge_stack.MockEcrClient{}
        em.On("BatchDeleteImage", mock.AnythingOfType("*ecr.BatchDeleteImageInput")).Return(
            &ecr.BatchDeleteImageOutput{
                ImageIds: []*ecr.ImageIdentifier{},
                Failures: []*ecr.ImageFailure{
                    {FailureCode: aws.String("hoge"), FailureReason: aws.String("fuga"), ImageId: &ecr.ImageIdentifier{}},
                },
            },
            nil,
        )
        initMockClient(cm, em)

        cmd := purge_stack.NewCmd()
        cmd.Flags().Set("stack-name", stackName)
        var args []string
        err := purge_stack.ExecPurgeStack(cmd, args)

        assert.Equal(t, fmt.Sprintf("failed to delete images of %s", "ecr1"), err.Error())
        cm.AssertNumberOfCalls(t, "ListStackResources", 2)
        em.AssertNumberOfCalls(t, "DescribeImages", 2)
        em.AssertNumberOfCalls(t, "BatchDeleteImage", 1)
        cm.AssertNumberOfCalls(t, "DeleteStack", 0)
    })
}

モックはtestify/mockで作っています。
aws-sdk-goのインターフェースがあるのでライブラリなしで簡単にモックは作れるのですが、APIのコール回数を確認するのがしんどいの、今回はtestify/mockを利用することにしました。
gomockでも実装してみましたが、こちらの方が書きやすかったです。

// mock.go

package purge_stack

import (
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/service/cloudformation"
    "github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface"
    "github.com/aws/aws-sdk-go/service/ecr"
    "github.com/aws/aws-sdk-go/service/ecr/ecriface"
    "github.com/stretchr/testify/mock"
)

type MockCfnClient struct {
    mock.Mock
    cloudformationiface.CloudFormationAPI
}

func (client *MockCfnClient) ListStackResources(params *cloudformation.ListStackResourcesInput) (*cloudformation.ListStackResourcesOutput, error) {
    args := client.Called(params)
    if args.Get(0) != nil {
        return args.Get(0).(*cloudformation.ListStackResourcesOutput), args.Error(1)
    } else {
        return nil, args.Error(1)
    }
}

func (client *MockCfnClient) DeleteStack(params *cloudformation.DeleteStackInput) (*cloudformation.DeleteStackOutput, error) {
    args := client.Called(params)
    if args.Get(0) != nil {
        return args.Get(0).(*cloudformation.DeleteStackOutput), args.Error(1)
    } else {
        return nil, args.Error(1)
    }
}

type MockEcrClient struct {
    mock.Mock
    ecriface.ECRAPI
}

func (client *MockEcrClient) DescribeImages(params *ecr.DescribeImagesInput) (*ecr.DescribeImagesOutput, error) {
    args := client.Called(params)
    if args.Get(0) != nil {
        return args.Get(0).(*ecr.DescribeImagesOutput), args.Error(1)
    } else {
        return nil, args.Error(1)
    }
}

func (client *MockEcrClient) BatchDeleteImage(params *ecr.BatchDeleteImageInput) (*ecr.BatchDeleteImageOutput, error) {
    args := client.Called(params)
    if args.Get(0) != nil {
        return args.Get(0).(*ecr.BatchDeleteImageOutput), args.Error(1)
    } else {
        return nil, args.Error(1)
    }
}

func SetMockDefaultBehaviour(cm *MockCfnClient, em *MockEcrClient) {
    stackName := "foo"
    cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{
        StackName: aws.String(stackName),
    }).Return(
        &cloudformation.ListStackResourcesOutput{
            NextToken: aws.String("next_token"),
            StackResourceSummaries: []*cloudformation.StackResourceSummary{
                {PhysicalResourceId: aws.String("cluster"), ResourceType: aws.String("AWS::ECS::Cluster")},
                {PhysicalResourceId: aws.String("ecr1"), ResourceType: aws.String("AWS::ECR::Repository")},
            },
        },
        nil,
    )
    cm.On("ListStackResources", &cloudformation.ListStackResourcesInput{
        NextToken: aws.String("next_token"),
        StackName: aws.String(stackName),
    }).Return(
        &cloudformation.ListStackResourcesOutput{
            NextToken: nil,
            StackResourceSummaries: []*cloudformation.StackResourceSummary{
                {PhysicalResourceId: aws.String("ecr2"), ResourceType: aws.String("AWS::ECR::Repository")},
                {PhysicalResourceId: aws.String("queue"), ResourceType: aws.String("AWS::SQS::Queue")},
            },
        },
        nil,
    )
    em.On("DescribeImages", &ecr.DescribeImagesInput{
        NextToken:      nil,
        MaxResults:     aws.Int64(1000),
        RepositoryName: aws.String("ecr1"),
    }).Return(
        &ecr.DescribeImagesOutput{
            NextToken: aws.String("next_token"),
            ImageDetails: []*ecr.ImageDetail{
                {ImageDigest: aws.String("foofoofoo"), ImageTags: []*string{aws.String("foo")}},
                {ImageDigest: aws.String("barbarbar"), ImageTags: []*string{aws.String("bar")}},
            },
        },
        nil,
    )
    em.On("DescribeImages", &ecr.DescribeImagesInput{
        NextToken:      aws.String("next_token"),
        MaxResults:     aws.Int64(1000),
        RepositoryName: aws.String("ecr1"),
    }).Return(
        &ecr.DescribeImagesOutput{
            NextToken: nil,
            ImageDetails: []*ecr.ImageDetail{
                {ImageDigest: aws.String("foobarfoobar"), ImageTags: []*string{aws.String("foobar")}},
                {ImageDigest: aws.String("barfoobarfoo"), ImageTags: []*string{aws.String("barfoo")}},
            },
        },
        nil,
    )
    em.On("DescribeImages", &ecr.DescribeImagesInput{
        NextToken:      nil,
        MaxResults:     aws.Int64(1000),
        RepositoryName: aws.String("ecr2"),
    }).Return(
        &ecr.DescribeImagesOutput{
            NextToken:    nil,
            ImageDetails: []*ecr.ImageDetail{},
        },
        nil,
    )
    em.On("BatchDeleteImage", &ecr.BatchDeleteImageInput{
        ImageIds: []*ecr.ImageIdentifier{
            {ImageDigest: aws.String("foofoofoo")},
            {ImageDigest: aws.String("barbarbar")},
            {ImageDigest: aws.String("foobarfoobar")},
            {ImageDigest: aws.String("barfoobarfoo")},
        },
        RepositoryName: aws.String("ecr1"),
    }).Return(
        &ecr.BatchDeleteImageOutput{
            ImageIds: []*ecr.ImageIdentifier{},
            Failures: []*ecr.ImageFailure{},
        },
        nil,
    )
    cm.On("DeleteStack", &cloudformation.DeleteStackInput{
        StackName: aws.String(stackName),
    }).Return(
        &cloudformation.DeleteStackOutput{},
        nil,
    )
}

4つもAPIを使っているので結構長くなってしまった。

rootの方のテストはE2Eっぽい感じです。

t.Run("success", func(t *testing.T) {
    stackName := "foo"
    args := []string{"cfn", "purge-stack", "--stack-name", stackName}
    cmd := NewCmd()
    cmd.SetArgs(args)
    cfnCmd := cfn.NewCmd()
    purgeStackCmd := purge_stack.NewCmd()
    cfnCmd.AddCommand(purgeStackCmd)
    cmd.AddCommand(cfnCmd)

    cm := &purge_stack.MockCfnClient{}
    em := &purge_stack.MockEcrClient{}
    purge_stack.SetMockDefaultBehaviour(cm, em)
    purge_stack.CfnClient = cm
    purge_stack.EcrClient = em

    b := bytes.NewBufferString("")
    cmd.SetOut(b)
    cmd.Execute()
    out, err := ioutil.ReadAll(b)
    if err != nil {
        t.Fatal(err)
    }
    expected := "All images in ecr1 successfully deleted.\nPerform delete-stack is in progress asynchronously.\nPlease check deletion status by yourself.\n"
    assert.Equal(t, expected, string(out))
    assert.Nil(t, err)
})

まとめ

cobraはデフォルトの使い方をするとかなりテストがしづらいです。
ただでさえCLIというインターフェースの上に、AWS APIのモック、認証(profile, region)の使い分けなどが載っているため、テストの境界も設定しづらいです。

モックについてはかなり試行錯誤しましたがまあ及第点は出せたかなと思います。
規模が大きくなるにつれて手を入れないとしんどそうな未来は見えていますがまたその時考えることにします。

あとこれ公式でサポートして欲しいですAWSさん(小声)
ロードマップありませんか