less is more

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

Alpine Linux で Rails 6 で rails-erd を使おうとするとつらい

概要

rails向けのER図出力ツールとしてメジャーな rails-erdを Alpineベースの Docker で使おうとしたら結構面倒だったのでその備忘録。

すでに1年以上メンテされておらず、Rails 6 がサポートされる気配もないのであくまで暫定的なワークアラウンドという方向で。

Gemfile

group :development do
  gem 'rails-erd'
end

Dockerfile

グラフを描画するためのパッケージ graphbiz が必要となるのでインストールする。

また、alpineの場合対応するフォントを入れてやらないと出力されたグラフの文字部分が豆腐になるので色々入れる。

RUN apk add --update --no-cache \
    graphviz \
    msttcorefonts-installer \
    fontconfig \
    font-bitstream-type1 \
    ghostscript-fonts \
    ttf-freefont
RUN  update-ms-fonts \
    && fc-cache -f \
    && rm -rf /var/cache/*

フォントは darwin 以外では Arial が使われるようになっているので、msttcorefonts-installer でインストールする。

また、これだけだと NOT NULL を表す *(U+2217) が正しく表示されなかったりするので他にもいくつか入れている。

実行

bundle exec erd

以下のWarningが出る。
Rails 6 未サポートだからか。

Warning: Ignoring invalid model ActionText::RichText (table action_text_rich_texts does not exist)
Warning: Ignoring invalid model ActionMailbox::InboundEmail (table action_mailbox_inbound_emails does not exist)
Warning: Ignoring invalid model ActiveStorage::Blob (table active_storage_blobs does not exist)
Warning: Ignoring invalid model ActiveStorage::Attachment (table active_storage_attachments does not exist)

通常のユーザーが作ったモデルであれば、--exclude オプションをつけることで出力しないようにすることができるが、これらは色々試しても除外できなかった。

--exclude="ActionText::RichText"
--exclude="RichText"
--exclude="action_text_rich_texts

全部効かない。

特に影響ない Warning なので無視するしかない。
ちなみに --warn オプションつければ出力を制御できる。

eager_load

こんなエラーが出ることもある。

Failed: RuntimeError: No entities found; create your models first!

モデルクラスが認識されていないため。

これに関するワークアラウンドはいくつかある。

1. v6.0.3 以上にする

内部的にはRails.application.eager_load!で読み込んでいるのだが、ZeitwerkではZeitwerk::Loader.eager_load_allをする必要がある。

だがこのあたりの変更は結構活発に進んでいるようで、6.0.3からRails.application.eager_load!後方互換性のために復活した。

なのでバージョンアップすれば問題なく動く。

2. config.eager_load = true

以下バージョンアップできない環境向け。
eager_loadの設定を変更すれば動く。

3. eager_load!のオーバーライド

結構香ばしいですね。

module Rails
  class Application
    def eager_load!
      Zeitwerk::Loader.eager_load_all
    end
  end
end 

4. タスクのオーバーライド

3を局所的に解決する例

FP_Reservation/auto_generate_diagram.rake at master · MurakiSari/FP_Reservation · GitHub

cloudformation:CreateStack のオプションを整理する

aws-cli のドキュメントをもとに一つずつ見ていく。

create-stack — AWS CLI 1.18.78 Command Reference

--stack-name

スタック名。必須。
英数字またはハイフンのみ。

最大128文字で、先頭は英文字でなければならない。
また、リージョン内でユニークという制約がある。

--template-body | --template-url

テンプレートファイルの指定。
どちらか片方でよい。

S3に配置されたファイルを使う場合は、--template-urlにURLを指定する。
ローカルのファイルを使う場合は、--template-body file://{path}のような形で指定する。

--parameters

パラメータの受け渡し。 JSON文字列を指定するか、以下のようなショートハンドでも書ける。

ParameterKey=string,ParameterValue=string,UsePreviousValue=boolean,ResolvedValue=string ...

UsePreviousValueは作成時は使えない。更新時に、前回渡したパラメータを引き継ぐかどうか。

ResolvedValueはパラメータストア(ssm)の参照っぽいんですが、ドキュメントが薄くサンプルも見つからないため具体的な挙動は確認できていません。

Read-only. The value that corresponds to a Systems Manager parameter key. This field is returned only for SSM parameter types in the template.

docs.aws.amazon.com

--disable-rollback | --no-disable-rollback

スタックの作成に失敗した時ロールバックするかどうか。
デフォルトではするので、それを無効にしたい場合に--disable-rollbackを渡すような使い方しかないと思う。

また、このオプションは次の--on-failureと競合する。

--on-failure

スタックの作成に失敗した時の挙動を決める。
デフォルトではROLLBACKで、以下の3つから指定できる。

  • DO_NOTHING
  • ROLLBACK
  • DELETE

--disable-rollbackとは併用できない。
明記はないが--disable-rollbackDO_NOTHINGと同じ挙動っぽい。

DELETEはスタックを自動で削除してしまう。削除されるとなんでエラーになったのかイベントが見れなくなるのでおすすめしない。

--rollback-configuration

ロールバックをトリガーするための詳細条件を設定できる。
CloudWatchと連携して各リソースごとに閾値を設定し、アラーム状態になったらオペレーションを中止してロールバックする、みたいなことを実現する。

使ったことがないので想定するケースがよくわからない。

docs.aws.amazon.com

--timeout-in-minutes

スタックの作成ステータスを失敗とするまでのタイムアウト

--notification-arns

Cloudformationのイベントの通知先のSNSのトピックのARN。
オペレーションの実行 IAM がSNS:Publish権限を有している必要がある。

--capabilities

スタックに特定のIAM関連のリソースがある場合、確認用に渡す必要があるちょっと特殊なオプション。

「IAMに関する操作あるけど大丈夫?認識してる?意図通り?」っていう確認。

値としては CAPABILITY_IAM,CAPABILITY_NAMED_IAM,CAPABILITY_AUTO_EXPANDの3種類がある。

基本的にはCAPABILITY_IAM, 名前付きのIAMリソースがある場合はCAPABILITY_NAMED_IAM, マクロでテンプレートを生成する場合はCAPABILITY_AUTO_EXPANDが必要になる。

とりあえず全部わっとまとめて渡しておけば通せる。

--resource-types

これもcapabilitiesっぽい動きをするオプションで、意図せぬリソースが含まれていないかを確認するような仕組み。

AWS::EC2::Instance, AWS::EC2::*,のような値を渡して、このリストに含まれていないリソースを作ろうとしていたらオペレーションを失敗させるみたいな。

これも使ったことない。

--role-arn

オペレーションの実行IAMロールを指定できる。
サービスロールを使う場合ですね。

なんでサービスロールを使ったほうがいいの?という点に関しては拙著ですが以下をお読みください。 bluepixel.hatenablog.com

--stack-policy-body | --stack-policy-url

作成されたスタックの更新許可を制御する仕組み。
デフォルトでは全てのリソースが更新可能になっているが、ポリシーベースで柔軟に制御をかけられる。

例えばデータベースの更新など物理的なリソースの置換を伴いデータが吹っ飛ぶような危険な操作をできないようにするとかだろうか。

docs.aws.amazon.com

テンプレートの時と同じく、S3のURLを指定してファイルを読み込ませることもできる。

--tags

解説不要ですね。

スタックに対してタグを指定すると管理下のリソースに対して一括でタグを付与してくれるのでとても便利。

--client-request-token

一意の値を指定してオペレーションを識別できるようにするトークン。
マネコンではデフォルト非表示っぽい?けど歯車から表示するように設定できる。

f:id:bluepixel:20200613174437p:plain

--enable-termination-protection | --no-enable-termination-protection

削除保護。
デフォルトは無効。

以上です。

CloudFormationを扱う際のIAMの考え方

あるスタックを作成するケースを考える

SQSのキューを1つだけ作るテンプレートを用意する。

# sample.cf.yml

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  SampleQueue:
    Type: AWS::SQS::Queue

とあるIAMユーザーのプロファイルを指定してcreate-stackを実行。
このIAMユーザーはcloudformation:*のみを許可されているものとする。

$ aws cloudformation create-stack --stack-name sample --template-body file://./sample.cf.yml --profile sample-user
{
    "StackId": "arn:aws:cloudformation:ap-northeast-1:xxxxxx:stack/sample/b579cc80-aaf1-11ea-bafc-0a62df4444de"
}

マネコンからステータスを確認すると、キューの作成に失敗している。

f:id:bluepixel:20200610190859p:plain

これはIAMポリシーの不足が原因。

基本的にcreate-stackを実行するユーザーは、スタックに含まれるリソースそれぞれに対して作成・取得・削除等の権限を有している必要がある。

*:create*だけを与えればいいかというとそうでもない。例のIAMユーザーにsqs:CreateQueueのポリシーを付与して再度実行すると今度は以下のエラーが発生する。

API: sqs:GetQueueAttributes Access to the resource https://sqs.ap-northeast-1.amazonaws.com/xxxx/sample-SampleQueue-G5X7EGA2HGMA is denied.

ステータスの取得やロールバック時のリソースの削除などにも対応するために、結局はフルアクセスを与えておくのが早かったりする。

ただここで問題となるのがメンバーのIAM管理の話。

スタックに併せてリソースの作成・削除権限をやみくもに与えていくのはセキュリティ上好ましくない。

リソースで制限をかけるとしても、まだ作成される前のスタックに関してはリソース名が不定なことが多いのでそもそも不可能。

じゃあどうするのか

結論: サービスロールを使いましょう

What is サービスロール

AWS の多くのサービスでは、ロールを使用して、ユーザーに代わって該当サービスが他のサービスのリソースにアクセスすることを許可する必要があります。サービスがお客様に代わってアクションを実行するために引き受けるロールは、サービスロールと呼ばれます。

IAMユーザーがユーザーに対してAWS APIのアクセスを許可するように、サービスロールはAWSのサービスに対して別のサービスへの操作権限を許可します。

AWS サービスにアクセス許可を委任するロールの作成 - AWS Identity and Access Management

例えば Lambda なんかは CloudWatch Logs にログを送信するためにlog:PutLogEventsを持っている必要があったりしますが、この権限をLambdaサービスに対して許可するのがサービスロールです。

Lambdaの場合関数作成時にデフォルトのLambdaBasicExecutionRoleで諸々付与してくれるのでマネコンで作ってるとあまり意識しないかもしれません。

f:id:bluepixel:20200610193308p:plain

ロールの引受先としてLambdaサービス(lambda.amazonaws.com)が設定されていることがわかります。

f:id:bluepixel:20200610193541p:plain

CloudFormationで作る場合はこんな感じでロールを自分で作ります。

IAMRoleForLambda:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess

そんな感じで、これをCloudFormationのスタック作成時に応用するのがベストプラクティスになります。

つまり、CloudFormationサービスにサービスロールを引き受けさせて、そのロールの権限のもとスタックに含まれるリソースの作成を行うというわけです。

閑話休題Service Linked Role

てなわけでサービスロールの作成に移りたいんですが、ここでService Linked Roleについても少し触れておきます。

日本語にするとサービスにリンクされたロールですが、

サービスにリンクされたロールは、AWS サービスに直接リンクされた一意のタイプの IAM ロールです。サービスにリンクされたロールは、サービスによって事前定義されており、お客様の代わりにサービスから他の AWS サービスを呼び出す必要のあるアクセス権限がすべて含まれています。

サービスにリンクされたロールの使用 - AWS Identity and Access Management

AWS側で事前に定義してくれているもの、という理解でよさそうです。

例えばECSの場合、AWSServiceRoleForECSというものが定義されています。

f:id:bluepixel:20200610194819p:plain

ECSが必要とするロードバランサー、Route53、オートスケーリングなどの各種操作権限が含まれています。

f:id:bluepixel:20200610194835p:plain

こういうテンプレートがあると aws iam create-service-linked-role --aws-service-name <value> で簡単にロールが作成できるのですが、このService Linked Role、全てのサービスに定義されているわけではありません。

こちらに一覧がありますが、 CloudFormation は No となっていますね。

docs.aws.amazon.com

閑話休題でした。

サービスロールの作成

CloudFormationサービスを引き受け先として、SQSのフルアクセスを許可したサービスロールを作成します。

AWSTemplateFormatVersion: "2010-09-09"
Resources:
  CloudFormationServiceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - cloudformation.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: create-sqs-resource
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: 
                  - sqs:*
                Resource: 
                  - "*"

サービスロールの使用

create-stackのオプションとして--role-arnがあります。
これを指定すると、CloudFormationがそのロールを引き受けてスタックの操作を行います。ちなみに、指定していない場合はAPIを実行したユーザーの一時的なセッションが使われます。

IAMユーザーにはiam:PassRole権限が必要になります。サービスに対してロールを渡す操作の許可です。

$ aws cloudformation create-stack --stack-name sample --template-body file://./sample.cf.yml --role-arn arn:aws:iam::xxxxxxx:role/xxxxxx

これで、IAMユーザーにSQSリソースに対する権限がなくてもスタックの作成を実行することができるようになりました。

直接ユーザーに権限を付与していくのではなく、サービスロールを使おうねという話でした。

Lambdaの関数をランタイムごとに数える

完成イメージ

$ abc lambda stats
|         RUNTIME          | COUNT |
|--------------------------|-------|
| nodejs12.x               |     2 |
| nodejs8.10(Deprecated)    |     1 |
| python3.6                |     2 |
| ruby2.5                  |     8 |
| ruby2.7                  |     2 |

--verboseオプションで関数名も表示

$ abc lambda stats --verbose
|         RUNTIME          | COUNT |           FUNCTIONS           |
|--------------------------|-------|-------------------------------|
| nodejs12.x               |     2 | sample-nodejs-12-function-1   |
|                          |       | sample-nodejs-12-function-2   |
| nodejs8.10(Deprecated)    |     1 | sample-nodejs-8-function      |
| python3.6                |     2 | sample-python-36-function-1,  |
|                          |       | sample-pytohn-36-function-2   |
| ruby2.5                  |     8 | sample-ruby-25-function-1,    |
|                          |       | sample-ruby-25-function-2,    |
|                          |       | sample-ruby-25-function-3,    |
|                          |       | sample-ruby-25-function-4,    |
|                          |       | sample-ruby-25-function-5,    |
|                          |       | sample-ruby-25-function-6,    |
|                          |       | sample-ruby-25-function-7,    |
|                          |       | sample-ruby-25-function-8     |
| ruby2.7                  |     2 | sample-ruby-27-function-1,    |
|                          |       | sample-ruby-27-function-2     |

--formatオプションでJSONフォーマットで出力

$ abc lambda stats --format json | jq "."
[
  {
    "runtime": "nodejs12.x",
    "count": 2,
    "deprecated": false
  },
  {
    "runtime": "nodejs8.10",
    "count": 1,
    "deprecated": true
  },
  {
    "runtime": "python3.6",
    "count": 2,
    "deprecated": false
  },
  {
    "runtime": "ruby2.5",
    "count": 8,
    "deprecated": false
  },
  {
    "runtime": "ruby2.7",
    "count": 2,
    "deprecated": false
  }
]

CLIツール

もうブログで何回も紹介しているgo製のCLIツールabcのサブコマンドとして実装する。

github.com

ランタイム一覧

docs.aws.amazon.com

現在サポートされているランタイムは以下。

  • nodejs12.x
  • nodejs10.x
  • python3.8
  • python3.7
  • python3.6
  • python2.7
  • ruby2.7
  • ruby2.5
  • java11
  • java8
  • go1.x
  • dotnetcore3.1
  • dotnetcore2.1
  • provided(カスタムランタイム)

ランタイムサポートポリシー

docs.aws.amazon.com

Lambdaのランタイムにはランタイムサポートポリシーというものが設定されていて、要はセキュリティパッチなどちゃんと実行環境がメンテナンスされているかを表すもの。

最新でないバージョンの言語のランタイムは随時廃止されて行く模様。
廃止は段階的に行われる。

まずはそのランタイムで新しい関数が作成できなくなり、続いて既存の関数の更新ができなくなる。 その後は、既存の関数は廃止されることなくそのまま使い続けることはできるが、実行環境のメンテナンスは行われないので移行が推奨される。

すでに廃止されているランタイムは以下。

  • dotnetcore1.0
  • dotnetcore2.0
  • nodejs(Node.js 0.10)
  • nodejs4.3
  • nodejs4.3-edge
  • nodejs6.10
  • nodejs8.10

現在(2020/06/08)時点で廃止予定のランタイムはないが、Python2.7まわりはざわざわしている。 2020/12/31までセキュリティパッチは当てられるようだが、いつ廃止されるという明記はない。

aws.amazon.com

実装

lambda:ListFunctionsAPIで関数が列挙できる。
再帰的に取得して結果を返す部分の抜粋。

func listFunctions() ([]*lambda.FunctionConfiguration, error) {
    var result []*lambda.FunctionConfiguration
    var nextMarker *string
    for {
        params := &lambda.ListFunctionsInput{
            MaxItems: aws.Int64(1000),
            Marker:   nextMarker,
        }
        resp, err := LambdaClient.ListFunctions(params)
        if err != nil {
            return nil, err
        }
        result = append(result, resp.Functions...)
        if resp.NextMarker == nil {
            break
        }
        nextMarker = resp.NextMarker
    }

    return result, nil
}

パラメータは、なるべく1回で取得するためにMaxItemsを大きめにとっている。次ページがある場合はMarkerを指定して再帰的に取得する。他のAPIではNextTokenという実装をよく見るがMarkerは初めて見た🤔

他にもバージョンの指定やLambda@Edge用にリージョンの指定もできるのだが今回は必要ないので割愛。

集計はランタイムごとにmapに突っ込んでいて、ソートされたキー順に出力するのだが、一点微妙なところがある。Node.jsのようにバージョンが2桁になっているランタイムでは、リリース順にきれいに並ばないみたいなことが起きる。

(例) 昇順にソートしているが、一番上に最新のnodejs12.xがきてしまう

RUNTIME COUNT
nodejs12.x 2
nodejs10.x 2
nodejs8.10(Deprecated) 1
python3.6 1
python3.7 1
python3.8 2
ruby2.5 8
ruby2.7 2

これを考慮したソートを実装するのはちょっと骨が折れるので今回はやらなかった。

出力のフォーマットはマークダウンのtableかJSONを選択できるようにした。
これまではJSON縛りで作ることが多かったが、今回のユースケースではマークダウンにペッと貼り付けたいこともあるかなと思ったので。

func Output(count map[string][]string) (string, error) {
    if format == "json" {
        return jsonOutput(count)
    } else if format == "table" {
        return tableOutput(count), nil
    }
    return "", errors.New("invalid format.")
}

JSONは代わり映えしないので省略するとして、テーブル形式でのフォーマットの実装に関して。

最初は標準のtext/tabwriterを試した。
最近リリースされたAWS謹製のec2-instance-selectorではこれを使ってテーブル出力を実装している。

github.com

使い方もわかりやすく標準で賄えるのはいいのだが、凝ったことをしようとするとなかなか厳しい。それに、色をつけようとするとタブ幅が崩れるという問題があった。

代わりに以下のライブラリを導入した。

github.com

こちらではマークダウン形式のテーブルがサポートされていて、表崩れもなかったのでめでたく採用。

柔軟な色付けもできるっぽい。
(柔軟と言いつつ今回は実装が煩雑になるので自前でカラーコードを埋め込んだが)

func tableOutput(count map[string][]string) string {
    keys := sortKey(count)
    tableString := &strings.Builder{}
    table := tablewriter.NewWriter(tableString)
    table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false})
    table.SetCenterSeparator("|")
    if verbose {
        verboseTableOutput(keys, table, count)
    } else {
        normalTableOutput(keys, table, count)
    }
    return tableString.String()
}

func normalTableOutput(keys []string, table *tablewriter.Table, count map[string][]string) {
    table.SetHeader([]string{"Runtime", "Count"})
    for _, k := range keys {
        runtime := k
        if isDeprecatedRuntime(runtime) {
            runtime = fmt.Sprintf("\x1b[91m%s(Deprecated)\x1b[0m", runtime)
        }
        table.Append([]string{runtime, strconv.Itoa(len(count[k]))})
    }
    table.Render()
}

廃止済みのランタイムを使っている場合は赤くして警告する。

f:id:bluepixel:20200610130002p:plain

v0.5.0としてリリース

Release v0.5.0🎉 · Blue-Pix/abc · GitHub

イメージが残っている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さん(小声)
ロードマップありませんか

GoでAWS APIをモックする

開発中のCLIツールのテストで、AWS API の呼び出しをモックする仕組みを試行錯誤したのでメモ。

github.com

ツールでは aws-sdk-go を用いてAPIを呼び出している。

例として、CloudFormation の ListStacks API をモックする。

サービスクライアントを差し替え可能にする

before

import (
    "github.com/aws/aws-sdk-go/service/cloudformation"
)
service := cloudformation.New(sess)
params := &cloudformation.ListStacksInput{
    NextToken: token,
}
resp, err := service.ListStacks(params)

サービスごとにaws-sdk-go/service/*にサービスクライアントの構造体が定義されていて、通常はそれを使う。

が、そのままハードコーディングしてしまうとテストで差し替えることができなくなってしまう。

このサービスクライアントだが、便利なことにgithub.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface.CloudFormationAPIというインターフェースを実装しているので、インターフェースを経由すれば実装を気にすることなく各APIを呼び出すことができる。

この仕組みを利用して、サービスクライアントを差し替え可能にする。

after

var Client cloudformationiface.CloudFormationAPI

if Client == nil {
    Client = cloudformation.New(sess)
}

params := &cloudformation.ListStacksInput{
    NextToken: token,
}
resp, err := Client.ListStacks(params)

Clientをグローバルに出して(複数のプライベートメソッドから呼ばれるため)、呼び出し時にClientが設定されていなければaws-sdk-goの実装を使う、という仕組みにする。

これによって、テストでは事前にClientにモックオブジェクトを差し込んで、任意の処理をさせることができるようになる。

モックの実装

次に、テストで使うモックの実装。

type mockClient struct {
    cloudformationiface.CloudFormationAPI
    ListStacksMock  func(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error)
    ListExportsMock func(params *cloudformation.ListExportsInput) (*cloudformation.ListExportsOutput, error)
    ListImportsMock func(params *cloudformation.ListImportsInput) (*cloudformation.ListImportsOutput, error)
}

以下の記事が詳しいが、Goでは構造体に直接インターフェースを埋め込むDIの仕組みがあるのでそれを活用する。 qiita.com

cloudformationiface.CloudFormationAPIを埋め込んだモックの構造体を作り、各メソッドの実装をケースごとに差し替えられる様に、関数オブジェクトとして定義する。

ケースごとにオーバライドできる & デフォルトの実装をしておく。

func (client *mockClient) ListStacks(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error) {
    if client.ListStacksMock != nil {
        return client.ListStacksMock(params)
    }

    switch aws.StringValue(params.NextToken) {
    case "":
        return &cloudformation.ListStacksOutput{
            NextToken: aws.String("next_token"),
            StackSummaries: []*cloudformation.StackSummary{
                {StackId: aws.String("aaa"), StackName: aws.String("foo")},
                {StackId: aws.String("bbb"), StackName: aws.String("bar")},
            },
        }, nil

    case "next_token":
        return &cloudformation.ListStacksOutput{
            NextToken: nil,
            StackSummaries: []*cloudformation.StackSummary{
                {StackId: aws.String("ccc"), StackName: aws.String("foobar")},
            },
        }, nil
    default:
        return nil, nil
    }
}

モックすればページングの挙動とかも簡単に確認できますね...!

例えばパーミッションエラー時のテストをしたい場合は、エラーのレスポンスを返す実装を差し込む。

t.Run("authorization error for ListStacks", func(t *testing.T) {
    cmd := NewCmd()
    var args []string
    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"
    Client = &mockClient{
        ListStacksMock: func(params *cloudformation.ListStacksInput) (*cloudformation.ListStacksOutput, error) {
            return nil, awserr.New(errorCode, errorMsg, errors.New("hoge"))
        },
    }
    actual, err := FetchData(cmd, args)
    assert.Empty(t, actual)
    assert.Equal(t, errorCode, err.(awserr.Error).Code())
    assert.Equal(t, errorMsg, err.(awserr.Error).Message())
})

awserrの実装を見て、エラーレスポンスを組み立てる。 docs.aws.amazon.com

OrigError()はあんまり使う気がしないので、該当のCode()Message()だけで十分再現性あると思う。

type Error interface {
    // Satisfy the generic error interface.
    error

    // Returns the short phrase depicting the classification of the error.
    Code() string

    // Returns the error details message.
    Message() string

    // Returns the original error if one was set.  Nil is returned if not set.
    OrigErr() error
}

エラーコードはこの辺りで確認する。

docs.aws.amazon.com

Redashにつながらなくなった時の調査メモ

Redashが落ちてたのでその時の調査と対応の記録。

前提

  • EC2で運用
  • 冗長化はしていない
  • 公式のAMIを使用(つまりdocker-composeで動かすスタイル)

事象

ブラウザでRedashの固定IPアドレスを叩くと画面が真っ白。

原因のあたりをつける

真っ白な画面は返ってくるので、サーバーが落ちてるわけではなさそう🤔

事実、curlで確認するとステータスは200。

サーバーは生きてるがコンテンツがないレスポンスを返している?

この辺りの事情はこれ以上あたりがつけられないが、なんとなくどうせNo space left on deviceだろうなと思った。

というのも、起動時間が。 f:id:bluepixel:20200527194018p:plain

sshして調査

公式イメージはUbuntuベースなので、ec2-user@x.x.x.xではなく、ubuntu@x.x.x.xになる。

ディスクの容量を確認。

ubuntu@ip-192-168-170-114:~$ df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme0n1p1  7.7G  7.7G     0 100% /

やっぱりというお気持ち。
ちなみに8GBのEBSがアタッチされている。

何が占めているのか調査。
抜粋すると

$ sudo du -h -d 1 /
4.8G   /var
2.4G   /usr
8.4G   /

/var/logあたりだろうなと思っていたのでまあvarはよしとして(実際は/var/lib/docker/だったが)、/usr/srcが結構占めていた。

$ ls /usr/src
linux-aws-5.3-headers-5.3.0-1017
linux-aws-headers-4.15.0-1021
linux-aws-headers-4.15.0-1047
linux-aws-headers-4.15.0-1048
linux-aws-headers-4.15.0-1050
linux-aws-headers-4.15.0-1051
linux-aws-headers-4.15.0-1052
linux-aws-headers-4.15.0-1054
linux-aws-headers-4.15.0-1056
linux-aws-headers-4.15.0-1057
linux-aws-headers-4.15.0-1058
linux-aws-headers-4.15.0-1060
linux-aws-headers-4.15.0-1063
linux-aws-headers-4.15.0-1065
linux-headers-4.15.0-1021-aws
linux-headers-4.15.0-1065-aws
linux-headers-5.3.0-1017-aws
linux-headers-5.3.0-1019-aws

カーネルの自動アップデートによるゴミが残ってしまっているので削除する。

sudo apt-get -y autoremove
sudo apt-get -y autoclean

が、空き容量が全くないために以上のコマンドはtmpファイルすら作れずに失敗する。

仕方がないので一時的にEBSを拡張する。
マネジメントコンソールから10GBに拡張を行う。

しばらく待って、適用が完了したらファイルシステムの拡張を行う。

$ lsblk
NAME        MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
loop0         7:0    0   18M  1 loop /snap/amazon
loop1         7:1    0 93.9M  1 loop /snap/core/9
loop2         7:2    0 93.8M  1 loop /snap/core/8
loop3         7:3    0   18M  1 loop /snap/amazon
nvme0n1     259:0    0   10G  0 disk
└─nvme0n1p1 259:1    0    8G  0 part /

今回はパーティション(nvme0n1p1)があるので、先にパーティションの拡張が必要。

$ sudo growpart /dev/nvme0n1 1
mkdir: cannot create directory ‘/tmp/growpart.2200’: No space left on device
FAILED: failed to make temp dir

そうかあああああこれも空き容量必要なのかあああああ。

仕方ないので先に空き領域を適当に確保する。
ジャーナルログをローテートしてしまおう。

$ journalctl --vacuum-time=7d

これで300MBくらい空きができた。
(EBS拡張する必要なかったね)

戻すのも面倒なのでEBSはそのままで10GBで使います。

$ sudo growpart /dev/nvme0n1 1
CHANGED: partition=1 start=2048 old: size=16775135 end=16777183 new: size=20969439,end=20971487

$ lsblk
NAME        MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
loop0         7:0    0   18M  1 loop /snap/amazon
loop1         7:1    0 93.9M  1 loop /snap/core/9
loop2         7:2    0 93.8M  1 loop /snap/core/8
loop3         7:3    0   18M  1 loop /snap/amazon
nvme0n1     259:0    0   10G  0 disk
└─nvme0n1p1 259:1    0   10G  0 part /

パーティションが拡張できたのでファイルシステムをリサイズ。

$ sudo resize2fs /dev/nvme0n1p1
resize2fs 1.44.1 (24-Mar-2018)
Filesystem at /dev/nvme0n1p1 is mounted on /; on-line resizing required
old_desc_blocks = 1, new_desc_blocks = 2
The filesystem on /dev/nvme0n1p1 is now 2621179 (4k) blocks long.

$ sudo df -h
Filesystem      Size  Used Avail Use% Mounted on
/dev/nvme0n1p1  9.7G  7.4G  2.3G  77% /

あとは/var/の方を整理します。

/var/lib/docker/overlay2が肥大化していたので、docker system prune -a してdocker再起動して終了。

4.4GBまで減りました。