【Github actions】DependabotのPull RequestでSecretsが参照できずワークフローがFailになった場合の対処

Dependabot のPull Request(以下PR)が作られた際に開始したGithub Actionsワークフローが Secrets を参照できずに失敗していたので原因を調べてみました。

f:id:moritamorie:20210317022417p:plain

2021/3/1から適用になった以下のUpdateが影響していて、 Dependabot から実行される Github Actionsワークフローは読み取りだけが可能な GITHUB_TOKEN のみ使うことができ、いかなる Secrets も使えなくなるという変更が原因でした。

github.blog

なので、例えばpushイベントトリガーで実行されるワークフローの中で Secrets として追加しておいたPersonal access tokensを使って、取得したカバレッジのサマリをコメントで追加したり、自動でラベルを追加するといった書き込み(write)権限が必要な場合は、ワークフローが落ちる状況になっていました。

結論

以下のように

  • dependabotから実行されされた場合はpull_request_targetトリガーの時のみ実行
  • dependabot以外から実行された場合は pull_request_targetトリガー以外の時に実行

するようにしました。

on: 
  push:
  pull_request_target:
  workflow_dispatch:

name: Test
jobs:
  test:
    runs-on: ubuntu-latest
    if: (github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]') || (github.event_name != 'pull_request_target' && github.actor != 'dependabot[bot]') 
    steps:
      - uses: actions/checkout@v2
        if: (github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]')
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 0
      - uses: actions/checkout@v2
        if: (github.event_name != 'pull_request_target' && github.actor != 'dependabot[bot]')
        with:
          fetch-depth: 0
      - name: SonarCloud Scan
        uses: SonarSource/sonarcloud-github-action@master
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

actions/checkoutのオプションに ref: ${{ github.event.pull_request.head.sha }} がないと、 mainブランチをチェックアウトしてしまうようなので pull_request_targetの場合は同時にこのオプションを付けるようにすると良いようです。(参照

このように対応することに至った具体的な経緯を共有していきます。

失敗していた原因

実行結果の詳細を見るとSet the SONAR_TOKEN env variable.というエラーが発生しており、 Secrets に設定していた値が取れていないことが確認できました。

f:id:moritamorie:20210317025933p:plain

以下のリポジトリで、同様の状況を再現してみましたので、よろしければ参考にしてみてください。

Bump axios from 0.19.1 to 0.21.1 by dependabot · Pull Request #1 · moritamori/dependabot-testing · GitHub

対応方針の検討

主に以下の3つのドキュメント・Github issueを参照しました。

これらの情報から大きく分けて3通りの対応方法があることが分かりました。

また、①と②のどちらを適用するかは場合によって異なります。いずれの場合もwrite権限が付与され Secrets にアクセスできるようになる、という点は変わりません。

  • PR自体を更新する必要がある場合
    • => ②workflow_run
  • コメントやラベル等を追加しPRを構成するだけで更新しない場合
    • => ①pull_request_target

pull_request_target トリガーを使う

2021/2/19に公開されたGithubブログの記事 によると、write(書き込み)権限があるトークンが必要な場合、以下の2020/12/15に公開されたGithubブログの記事に書いているリスクを把握したうえで pull_request_target を使ってください、という記述があったのでリスクに関する記述を読んでみました。

リスクに関する記述1: 適用用途

pull_request_target トリガーに関して以下の記述がありました。

The reason to introduce the pull_request_target trigger was to enable workflows to label PRs (e.g. needs review) or to comment on the PR. The intent is to use the trigger for PRs that do not require dangerous processing, say building or running the content of the PR.

pull_request_targetトリガを導入した理由は、ワークフローでPRにラベルを付けたり(例:needs review)、PRにコメントを付けたりできるようにするためでした。このトリガーは、PRの内容を構築したり実行したりするような危険な処理を必要としないPRに使用することを意図しています。

今回遭遇したエラーは、この用途に該当するGithubワークフローで発生したものでした。

リスクに関する記述2: 注意点

pull_request_target トリガーの中でどのような処理を実行しても良いかを考えるうえで、以下の記述が参考になりました。

a workflow triggered on pull_request_target still has the read/write repository token in memory that is potentially available to any running program.

If the workflow uses actions/checkout and does not pass the optional parameter persist-credentials as false, it makes it even worse.

The default for the parameter is true. It means that in any subsequent steps any running code can simply read the stored repository token from the disk.

日本語にすると

pull_request_target でトリガーされたワークフローでは、メモリ内に読み取り/書き込み可能なリポジトリトークンが残っており、実行中のプログラムから利用できる可能性があります。

ワークフローが actions/checkout を使用していて、オプションのパラメータ persist-credentials を false にしていない場合は、さらに悪い状況になります。このパラメータのデフォルトはtrueです。

これは、後続のステップで、実行中のコードがディスクから保存されたリポジトリトークンを単純に読み取れることを意味します。リポジトリへの書き込みアクセスやシークレットが必要ない場合は、pull_request トリガーを使用してください。

という感じかと思います。

workflow_run を使う

リスクに関する記述1: 適用用途

workflow_run トリガーに関して以下の記述がありました。

Together with the pull_request_target, a new trigger workflow_run was introduced to enable scenarios that require building the untrusted code and also need write permissions to update the PR with e.g. code coverage results or other test results.

pull_request_target と共に、新しいトリガ workflow_run が導入され、信頼できないコードの構築を必要とするシナリオや、コードカバレッジの結果やその他のテスト結果などで PR を更新するための書き込み権限を必要とするシナリオを可能にしました。

Dependabot によって実行される Github actions ワークフローの中でPRを更新するようなケースでは workflow_run を使うと良さそうです。

リスクに関する記述2: 注意点

workflow_run トリガーの中でどのような処理を実行しても良いかを考えるうえで、以下の記述が参考になりました。

To do this in a secure manner, the untrusted code must be handled via the pull_request trigger so that it is isolated in an unprivileged environment.

これを安全に行うためには、信頼できないコードは、権限のない環境で隔離されるように、pull_requestトリガを介して処理されなければなりません。

③Dependabotを諦めてRenovateに乗り換える

対応が煩雑なので Renovate への置き換える方もでてきているようです、合わせて検討すると良いかもしれません。

github.com

対応したコード

用途に適した対応が①pull_request_target トリガーを使うだったので、Github issueコメント記載されていた対応コードのサンプルを参考に対応してみました。

Dependabot cant read secrets anymore · Issue #3253 · dependabot/dependabot-core · GitHub

ワークフローが正常に動くようになることを確認した検証用リポジトリも共有しておきます。 github.com

private registriesを使う場合

2021/3/15に以下のリリースがあり、GitHub Packagesやnpm等のプライベートレジストリを使ってパッケージの更新を行っている場合はよりセキュアに Secrets を使うことができるようになったので、こちらの方法と併せて対応すると良さそうです。

github.blog

Dependabot secretsの検証

もしかすると、 private registriesで使用可能な以下の DependabotSecrets を設定することで、Github actions ワークフローから参照できるようにならないかな、と思って試してみたのですがうまくいきませんでした。dependabot.ymlの中からしか参照できないようです。

f:id:moritamorie:20210317125542p:plain

Github actions ワークフローで行っている Secrets を使った処理を Dependabot の機能に移せる場合に、 Dependabot のprivate registriesの使用を検討すると良さそうです。

検証した際のGithubリポジトリを以下に記載しておきます。

Bump axios from 0.19.1 to 0.21.1 by dependabot · Pull Request #1 · moritamori/dependabot-testing3 · GitHub

参考資料

DockerイメージをAmazon ECRパブリックレジストリで公開してみた

2020年12月にリリースが発表されたAmazon ECRのパブリックレジストリを試したくて、 cawsay (牛(cow)に喋らせる(say)というジョークコマンド) を実行するだけのDockerファイルをビルドしてイメージを公開してみました。手順を共有してみたいと思います。

f:id:moritamorie:20210314155207p:plain

以下がリリースされた際の公式のブログ記事です。 aws.amazon.com

モチベーション

コンテナイメージからLambda functionを作れるようになったこともあり、AWSリージョン内からのECRの利用用途が広がっているので、パブリックなDockerイメージにも適用範囲を広げられそうということで試してみました。(参照)

なお、任意のAWSリージョン内からのPullは無料とのことで、ECS, EKSやLambdaなどAWSのプラットフォームから公開されているDockerイメージを使う場合には良さそう。 aws.amazon.com

また、Docker Hubのように6時間の間に100回までしかDocker pullできないなどの利用制限が無いところや、ECSやEKSでプライベートなECRリポジトリを使っている場合DockerイメージをAWSに集約できるのも運用面で嬉しい点です。

前提

リポジトリを作成

ecr-publicのサブコマンド create-repositoryを使って新しいリポジトリ moritamorie-cawsay を作ります。リージョンに ap-northeast-1を指定すると失敗したのでus-east-1に作りました。(2021年3月時点での実行結果)

$ aws ecr-public create-repository --repository-name \ 
                 moritamorie-cawsay --region ap-northeast-1

Could not connect to the endpoint URL: 
          "https://api.ecr-public.ap-northeast-1.amazonaws.com/"
$ aws ecr-public create-repository --repository-name \
                 moritamorie-cawsay --region us-east-1
{
    "repository": {
        "repositoryArn": "arn:aws:ecr-public::434137281992:repository/moritamorie-cawsay",
        "registryId": "434137281992",
        "repositoryName": "moritamorie-cawsay",
        "repositoryUri": "public.ecr.aws/w2q8j1y6/moritamorie-cawsay",
        "createdAt": "2021-03-14T17:07:57.039000+09:00"
    },
    "catalogData": {}
}

Dockerfileを作る

Dockerホストの任意のディレクトリ(ここで は cowsay としました)に、以下のDockerfileを作ってみます。

FROM debian:buster

RUN apt-get update && apt-get install -y cowsay

プッシュコマンドを確認

AWS consoleにログインして、Amazon ECRのところから作成したリポジトリを選択すると「プッシュコマンドの表示」というボタンがあるので押して、コマンドを確認しておきます。 f:id:moritamorie:20210314145915p:plain

プッシュコマンドを実行

表示されたプッシュコマンドを順次実行していきます。

ログイン

AWS CLI で認証トークンを取得し、レジストリに対して Docker クライアントを認証します。

$ aws ecr-public get-login-password --region us-east-1 | \
  docker login --username AWS --password-stdin public.ecr.aw

Dockerビルド

Dockerファイルをビルドして、イメージを生成します。

$ docker build -t cawsay .

タグ付け

作成したECRのパブリックリポジトリにイメージをプッシュできるように、イメージにタグをつけます。

$ docker tag moritamorie-cawsay:latest public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest

プッシュする

新しく作成した AWS リポジトリにこのイメージをプッシュします。

$ docker push public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest

※プッシュした後に、ログイン状態をそのままにしておく認証情報がなくてハマることがあるようなので、 プッシュ後docker logout することをお勧めします。(参照

Docker runでcawsayを実行

公開リポジトリにプッシュしたイメージを使って、cawsay 実行して"Moo"と言わせてみます。

$ docker run -it public.ecr.aws/w2q8j1y6/moritamorie-cawsay:latest \
  /usr/games/cowsay "Moo"

f:id:moritamorie:20210314151827p:plain

非常に有用な?イメージを公開できた気がします。

費用

参考までに、2021年3月時点での費用の一例を載せておきます。

費用はストレージとデータ転送の2つに分かれています。 以下の費用は、米国東部 (バージニア北部)[us-east-1]リージョンにおける費用です。

  • ストレージ
  • 月間の無料ストレージ
    • 50 GB
  • 月間の無料ストレージ以上の利用

    • GB/月あたり 0.10USD
  • データ転送

    • AWS アカウントを使用しない場合
      • 500GBまで無料
    • AWS アカウントを使用する場合
      • AWS 以外のリージョン
        • 5 TB/月まで無料
        • 5TB/月を超えるデータは、0.09 USD/GB
      • 任意の AWS リージョンへの任意の量のデータ
        • 無料

詳細は公式の以下のページ参照してみてください。料金の例を元に詳細が記載されています。

aws.amazon.com

ECR Publicのコマンド

以下のドキュメントにまとまっています。

awscli.amazonaws.com

Amazon ECR Public Gallery

作成したリポジトリAmazon ECR Public Galleryから参照可能です。

gallery.ecr.aws

参考資料

【Golang】共有された変数を平行的に安全するには(単一のゴルーチンに閉じ込める、相互排他)

プログラミング言語Go」オンライン読書会で学んだ第9章「共有された変数による平行性」に関して共有したいと思います。

並行的に安全(concurrency-safe)とは

プログラミング言語Go」の一部を引用すると

二つ以上のゴルーチンから同期を加えることなく呼び出された場合であっても正しく動作を続けるのであれば、その関数は並行的に安全(concurrency-safe)です。

という記載があります。

例えば

  • 月予算
  • 週予算
  • 今月中に使った金額

という3つの変数を扱うデータ構造を実装することを考えてみます。

このデータ構造は

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

という条件を満たす必要があり、複数のゴルーチンでこのデータ構造に操作を加えてもこれらの条件が必ず満たせれば並行的に安全と言えます。

実装

以下のような実装にしたとすると、これは並行的に安全ではありません。

type CostManager struct {
    MonthlyBudget uint
    WeeklyBudget  uint
    Spent         uint
}

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget // ①
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    return cm.WeeklyBudget <= cm.Spent
}

並行的に安全ではない理由

複数のゴルーチンから同期を加えることなく操作した場合、変数が必要な条件を満たさなくなるので、並行的に安全ではありません。

1つのゴルーチンが SetBudget(週予算と月予算を設定)を実行した時にプログラム中の①の時点まで実行した時に、他のゴルーチンが予算に達したかをチェックする関数を実行した時に必要な機能を満たさなくなります。

具体的には、最初の状態が

  • 週予算: 100
  • 月予算: 300
  • 今月中に使った金額: 300

の時に別々のゴルーチンから

  • SetBudget(400, 1000) (週予算: 400、月予算: 1,000を設定)を実行し、プログラム中の①の時点(週予算を設定)まで実行
  • 予算に達したかチェックする関数を実行

すると以下の図の状態になり、「今月中に使った金額が月予算に達していれば、週予算も達している」という要件を満たしていません。

f:id:moritamorie:20210312233329p:plain

対処方法

この問題に対処する方法を2つあります。

  • 変数を単一のゴルーチンに閉じ込める
  • 相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

変数を単一のゴルーチンに閉じ込める

1つ目の方法は、以下のように変数を特定のゴルーチンからしかアクセスさせなくすることで、安全にするというシンプルな方法です。

func SomeFunc() {
    // 月予算: 1,000、週予算: 100、今月使った金額: 0で初期化
    cm := CostManager{
        MonthlyBudget: 1000,
        WeeklyBudget:  100,
        Spent:         0,
    }
    // 変数 cm はこのゴルーチン内からしかアクセスさせない。
}

func main() {
    go SomeFunc()
}

また、類似の方法として、単一のゴルーチンの代わりに、パイプラインの中にアクセスする変数を閉じ込めるという方法に関しても言及されていました。パイプラインは以下のGo blogの記事で紹介されている手法です。

相互排他(mutual exclusion)の高度な不変式 (invariant) を維持する

もう1つの方法は、相互排他を用いて不変式を維持する方法です。

不変式とは

不変式とは、常に真である条件または関係です。

上記のデータ構造が持つ特有の不変式としては

  • 月予算 >= 週予算
  • 今月中に使った金額 <= 月予算
  • 今月中に使った金額が月予算に達していれば、週予算も達している

があります。

相互排他とは

不変式を維持する方法として、相互排他( sync.Mutex: ミューテックス )が提供されています。

プログラミング言語Goの一部を引用すると

ミューテックスの目的は、共有された変数のある種の不変式がプログラム実行中の重要な時点で維持されるのを保証することです。

という記載があります。

ミューテックスロックの使い方と効果

ミューテックスのロックを使うことによって、以下のように不変式を維持することができます。

var mu sync.Mutex

func SomeFunc() {
    # ①不変式が維持された状態
    mu.Lock()
    # ②一時的に不変式が破られた状態
    mu.Unlock()
    # ③不変式が維持された状態
}

複数のゴルーチンからデータ構造にアクセスできなくなり、ロックする時・ロックを解除した時に不変式が維持された状態を保証することができます。

月予算・週予算サンプルの例

最初の週予算と月予算のプログラムにミューテックスを導入するには、それぞれの関数の最初に

mu.Lock()
defer mu.Unlock()

を追加します。既にロックを獲得しているゴルーチンが存在する場合、他のゴルーチンはロックの解除待ちになります。

var mu sync.Mutex

// 週予算と月予算を設定
func (cm *CostManager) SetBudget(weeklyBudget uint, monthlyBudget uint) error {
    mu.Lock()
    defer mu.Unlock()
    if weeklyBudget > monthlyBudget {
        return errors.New("週予算が月予算を超えています")
    }
    if cm.Spent > monthlyBudget {
        return errors.New("既に月予算より多い金額を使っています")
    }
    cm.WeeklyBudget = weeklyBudget
    cm.MonthlyBudget = monthlyBudget
    return nil
}

// 使った金額を設定
func (cm *CostManager) SetSpent(spent uint) error {
    mu.Lock()
    defer mu.Unlock()
    if cm.MonthlyBudget < spent {
        return errors.New("使った金額が既に月予算を超えています")
    }

    cm.Spent = spent
    return nil
}

// 使った金額が月予算に達している
func (cm *CostManager) IsReachedToMonthlyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.MonthlyBudget <= cm.Spent
}

// 使った金額が週予算に達している
func (cm *CostManager) IsReachedToWeeklyBudget() bool {
    mu.Lock()
    defer mu.Unlock()
    return cm.WeeklyBudget <= cm.Spent
}

ミューテーションロックが再入可能(re-retrant)ではない

再入可能(re-retrant)とは以下のようにmu.Lock()、mu.Unlock()入れ子の状態にできることを指しますが、実行時にエラー(fatal error: all goroutines are asleep - deadlock)になります。

mu.Lock()
// 処理①
mu.Lock()
// 処理②
mu.Unlock()
// 処理③
mu.Unlock()

プログラミング言語Goの一部を引用すると

再入可能なミューテックスは他のゴルーチンが共有された変数へアクセスしないことを保証するでしょうが、それらの変数の追加の不変式を保護することはできません。

という記載があり、このような背景から明示的に再入できない仕様にしているものと考えられます。

参考資料

【Golang】apitest でEchoを使ったREST APIのテストを書く

apitestとは

GoのAPIテスティング用のライブラリの1つです。 github.com

apitestは

という特徴があり、個人的に好きでAPIのテスト書くのに使っています。

難点としては

  • websocket のテストは書けない(2021年3月時点)
    • その場合 httpexpect 等他のライブラリが候補にあがりそう。
  • サードパーティのライブラリのため都度バージョンアップが必要
    • 標準パッケージだけで賄いたい場合はnet/http/httptest使うのが良さそう

というところかと思います。

apitest の紹介のためにサンプルコードを書いてみました。どのようにテストを書けるのか紹介していきたいと思います。

サンプルコード

作ったサンプルはEchoで作った以下のREST APIを持つアプリケーションです

  • 書籍一覧 (GetIndex - パス: /books、メソッド: GET)
  • 書籍詳細 (GetDetail - パス: /books/1、メソッド: GET)
  • 書籍登録 (Post - パス: /books/、メソッド: POST)
  • 書籍登録 (Put - パス: /books/1、メソッド: PUT)

このEchoのサンプルは、apitest の以下のexamplesを元に作りました。

コードのリンク

コードの全ては紹介しきれないので、この記事中にはAPIの実装コード・テストコードの一部を載せてます。コード全体をみたい方はリンクを載せておきますので、よろしければご参照ください。

書籍一覧 (GetIndex)

一覧のAPIの実装コード・テストコードです。

実装コード

bh.bookRepo.FindAll()で書籍の一覧を取得し

  • エラーが発生していればクライアントにBadRequestを返す
  • エラーがなければステータス: OK(200)と取得した一覧を返す

という実装です。

func (bh *BookHandler) GetIndex(c echo.Context) error {
    bks, err := bh.bookRepo.FindAll()

    if err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    rl := &resultLists{Books: bks}
    return c.JSON(http.StatusOK, rl)
}
テストコード

bh.bookRepo.FindAll()で取得する書籍一覧をスタブにしています。 apitestを使ったテストコードでは、パス/booksにGETでHTTPリクエストを送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

type BookRepoStub struct{}

func (u *BookRepoStub) FindAll() ([]model.Book, error) {
    bks := []model.Book{}
    t, _ := time.Parse("2006-01-02", "2021-01-01")

    bk1 := model.Book{Title: "Go言語の本", Author: "誰か"}
    bk1.ID = 1
    bk1.CreatedAt = t
    bk1.UpdatedAt = t
    bks = append(bks, bk1)

    b2 := model.Book{Title: "Go言語の本2", Author: "誰か2"}
    b2.ID = 2
    b2.CreatedAt = t
    b2.UpdatedAt = t
    bks = append(bks, b2)

    return bks, nil
}

func TestGetIndex(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.GET("/books", h.GetIndex)

    apitest.New().
        Handler(e).
        Get("/books").
        Expect(t).
        Body(`
          {
              "Books": [
                  {
                      "ID": 1,
                      "CreatedAt": "2021-01-01T00:00:00Z",
                      "UpdatedAt": "2021-01-01T00:00:00Z",
                      "DeletedAt": null,
                      "Title": "Go言語の本",
                      "Author": "誰か"
                  },
                  {
                      "ID": 2,
                      "CreatedAt": "2021-01-01T00:00:00Z",
                      "UpdatedAt": "2021-01-01T00:00:00Z",
                      "DeletedAt": null,
                      "Title": "Go言語の本2",
                      "Author": "誰か2"
                  }
              ]
          }
      `).
        Status(http.StatusOK).
        End()
}

Handler()には、http.Handlerのインタフェース実装を渡します。

書籍詳細 (GetDetail)

詳細情報のAPIの実装コード・テストコードです。

実装コード

bh.bookRepo.FindByID()で書籍の詳細情報を取得し

  • エラーが発生していればクライアントにBadRequestを返す
  • エラーがなければステータス: OK(200)と取得した詳細情報を返す

という実装です。

func (bh *BookHandler) GetDetail(c echo.Context) error {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    b, err := bh.bookRepo.FindByID(id)

    if err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.FindByID()で取得する書籍詳細をスタブにしています。 apitestを使ったテストコードでは、パス/books/1にGETでHTTPリクエストを送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

type BookRepoStub struct{}

func (u *BookRepoStub) FindByID(id uint64) (model.Book, error) {
    t, _ := time.Parse("2006-01-02", "2021-01-01")
    b := model.Book{Title: "Go言語の本", Author: "誰か"}
    b.ID = 1
    b.CreatedAt = t
    b.UpdatedAt = t
    return b, nil
}

func TestGetDetail(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.GET("/books/:id", h.GetDetail)

    apitest.New().
        Handler(e).
        Get("/books/1").
        Expect(t).
        Body(`
          {
              "ID": 1,
              "CreatedAt": "2021-01-01T00:00:00Z",
              "UpdatedAt": "2021-01-01T00:00:00Z",
              "DeletedAt": null,
              "Title": "Go言語の本",
              "Author": "誰か"
          }
      `).
        Status(http.StatusOK).
        End()
}

書籍登録 (Post)

登録のAPIの実装コード・テストコードです。

実装コード

実装では

  • HTTPボディで送られてくる"title"(書籍名), "author"(書籍著者)を取得
  • title, authorの両方共に値があるかどうかをバリデートする(空文字もNG)
  • バリデーションに引っかかれば、クライアントにBadRequestを返す
  • Create でエラーが発生していればクライアントにBadRequestを返す
  • エラーがなければOK(200)と取得した登録した書籍情報を返す

という実装です。

func (bh *BookHandler) Post(c echo.Context) error {
    t := c.FormValue("title")
    a := c.FormValue("author")
    b := model.Book{Title: t, Author: a}

    if err := validator.New().Struct(b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    if err := bh.bookRepo.Create(&b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.Create()の書籍登録をスタブにしています。やっていることは nil を返しているだけです。

apitestを使ったテストコードでは、パス/books/にPostでHTTPリクエスト(ボディ "title": "新規書籍名", "author": "新規著者")を送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

また、登録処理ではバリデーションに失敗するケースもあるので、そのケースもテストを書いてみました。

type BookRepoStub struct{}

func (u *BookRepoStub) Create(b *model.Book) error {
    return nil
}

func TestPost(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.POST("/books", h.Post)

    // 正常系
    apitest.New().
        Handler(e).
        Post("/books").
        FormData("title", "新規書籍名").
        FormData("author", "新規著者").
        Expect(t).
        Status(http.StatusOK).
        End()

    // 異常系
    apitest.New().
        Handler(e).
        Post("/books").
        FormData("title", "").
        FormData("author", "新規著者").
        Expect(t).
        Status(http.StatusBadRequest).
        End()
}

書籍更新 (Put)

更新のAPIの実装コード・テストコードです。

実装コード

実装では

  • HTTPボディで送られてくる"title"(書籍名), "author"(書籍著者)を取得
  • title, authorの両方共に値があるかどうかをバリデートする(空文字もNG)
  • バリデーションに引っかかれば、クライアントにBadRequestを返す
  • Save でエラーが発生していればクライアントにBadRequestを返す
  • エラーがなければOK(200)と取得した更新した書籍情報を返す

という実装です。

func (bh *BookHandler) Put(c echo.Context) error {
    id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
    b, _ := bh.bookRepo.FindByID(id)

    t := c.FormValue("title")
    a := c.FormValue("author")
    b = model.Book{Title: t, Author: a}

    if err := validator.New().Struct(b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    if err := bh.bookRepo.Save(&b); err != nil {
        return c.JSON(http.StatusBadRequest, err.Error())
    }
    return c.JSON(http.StatusOK, b)
}
テストコード

bh.bookRepo.Create()の書籍登録をスタブにしています。やっていることは nil を返しているだけです。

apitestを使ったテストコードでは、パス/books/にPostでHTTPリクエスト(ボディ "title": "新規書籍名", "author": "新規著者")を送った時に

  • スタブと同じ内容のResponse bodyが返っているか
  • ステータス: OK(200)が返っているか

をテストしています。

また、登録処理ではバリデーションに失敗するケースもあるので、そのケースもテストを書いてみました。

type BookRepoStub struct{}

func (u *BookRepoStub) Save(b *model.Book) error {
    return nil
}

func TestPut(t *testing.T) {
    e := echo.New()
    brs := &BookRepoStub{}
    h := NewBookHandler(brs)
    e.PUT("/books/:id", h.Put)

    // 正常系
    apitest.New().
        Handler(e).
        Put("/books/1").
        FormData("title", "更新後書籍名").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusOK).
        End()

    // 異常系
    apitest.New().
        Handler(e).
        Put("/books/1").
        FormData("title", "").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusBadRequest).
        End()
}

デバッグの仕方

Debugを使うとHTTPの内容がログ出力され分かりやすくなり、テストの修正に役立てます。

例えば、上記の TestPut のテストコードを以下のように変更し Fail するようにし、併せて Debug を追加します。

   // 正常系
    apitest.New().
        Debug(). ←追加
        Handler(e).
        Put("/books/"). ←ここを存在しないパスに変更
        FormData("title", "更新後書籍名").
        FormData("author", "更新後著者").
        Expect(t).
        Status(http.StatusOK).
        End()

テストを実行すると、以下のように

  • Http requestのinbound内容
  • 最終的なHttp Responseの内容

がコンソールに出力され、テストが Fail した原因を見つけやすくなります。

----------> inbound http request
PUT /books/ HTTP/1.1
Host: sut
Content-Type: application/x-www-form-urlencoded

author=%E6%9B%B4%E6%96%B0%E5%BE%8C%E8%91%97%E8%80%85&title=%E6%9B%B4%E6%96%B0%E5%BE%8C%E6%9B%B8%E7%B1%8D%E5%90%8D

<---------- final response
Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestPut$ github.com/moritamori/echo-testing/handler
HTTP/1.1 404 Not Found
Connection: close
Content-Type: application/json; charset=UTF-8

{"message":"Not Found"}

Duration: 173.685µs

--- FAIL: TestPut (0.00s)
    /home/takashi/go/src/github.com/moritamori/echo-testing/handler/assert.go:37: 
            Error Trace:    assert.go:37
                                        apitest.go:941
                                        apitest.go:792
                                        apitest.go:599
                                        book_test.go:153
            Error:          Not equal: 
                            expected: 200
                            actual  : 404
            Test:           TestPut
            Messages:       Status code 404 not equal to 200
FAIL
FAIL    github.com/moritamori/echo-testing/handler  0.092s
FAIL

Debugがないと以下のようなシンプルな出力内容で、200が返ることを期待しているが実際には404が返っていることが分かるくらいで情報が少ないです。

Running tool: /usr/local/go/bin/go test -timeout 30s -run ^TestPut$ github.com/moritamori/echo-testing/handler

--- FAIL: TestPut (0.00s)
    /home/takashi/go/src/github.com/moritamori/echo-testing/handler/assert.go:37: 
            Error Trace:    assert.go:37
                                        apitest.go:941
                                        apitest.go:792
                                        apitest.go:599
                                        book_test.go:152
            Error:          Not equal: 
                            expected: 200
                            actual  : 404
            Test:           TestPut
            Messages:       Status code 404 not equal to 200
FAIL
FAIL    github.com/moritamori/echo-testing/handler  0.077s
FAIL

便利な機能

JSON path

APIから返ってきたJSON全体をテストするサンプルを書きましたが、JSON pathを使うと部分的にテストする際に便利です。

カスタムマッチャ

レスポンスを検証する対象としてBodyCokkieHeader等がありますが、独自のマッチャを作って検証することもできます。

インタセプター

HTTPリクエストを送る前にインタセプターを挟んで、http.Request を元に送る内容を加工できます。

参考記事

上記のAPIのサンプルは各エントリポイントでGormを使ったDBアクセスをしています。以下の記事では go-sqlmock を使ったGorm アプリケーションのテスト方法も書いてますので、もしご興味があれば参考にしてみてください!

simple-minds-think-alike.hatenablog.com

サンプルコード

github.com

参考資料

mysqldump: Error: 'Access denied; you need (at least one of) the PROCESS privilege(s) for this operation' when trying to dump tablespaces が発生した時の対処

先日、 mysqldumpAWS RDSインスタンス(aurora)に繋いでデータをdumpしようとしたら、見たことがないErrorが発生していたので調べました。

mysqldump: Error: 'Access denied; you need (at least one of) the PROCESS privilege(s) for this operation' when trying to dump tablespaces

原因

調べてみると、MySQLバージョンのアップグレードの影響で tablespace の情報にアクセスするには PROCESS 権限が必要になったことが原因であるということがわかりました。

dev.mysql.com

対応方法

tablespaceの情報は不要だったので、MySQL公式のページに書いてあった通り--no-tablespacesを付けてmysqldumpを実行したら、エラーが発生しなくなりました。

他の方法としては、dumpするユーザにPROCESS権限をつけて対処することもでき、tablespace を含めてdumpしたい場合には権限をつけると良さそうです。

エラー発生の状況

dumpの方法

以下のようにしてdumpしていました。

$ mysqldump -u [DBユーザー] -p[DBパスワード] -h [DBホスト] [データベース名] > backup.sql

エンジン

以下のMySQL 5.6互換のAuroraでエラーが発生していました。エンジンは 5.6.mysql_aurora.1.23.1docs.aws.amazon.com

参考資料

【Golang】Github actionsでカバレッジを取得しCodecovにアップロードする

Go言語で作ったアプリケーションのGithubリポジトリGithub actionsワークフローを設定し、 codecovカバレッジを送る設定方法を紹介したいと思います。

codecov は、テストのコードカバレッジを取得してくれるツールです。テストスイートを実行した時にソースコードの実行箇所を視覚的に示してくれて、どこに新しいテストを書くべきか分かりやすくなります。

f:id:moritamorie:20210228015138p:plain

  • : テストスイートによってソースコード実行されている箇所
  • : テストスイートによってソースコード部分的に実行されている箇所(具体的には、真偽値が返るところで、 truefalse のどちらかしか返っていない箇所)
  • : テストスイートによってソースコード実行されていない箇所

忙しい時はテストコードを書くのが手抜きになったりするのですが、 codecov を使っているとカバレッジ定量的・視覚的に表現されることによって、状況を把握しやすくなり自然とテストコード追加しようという気持ちになると感じていて、自戒の意味を込めて codecov を設定しています。

coverall と codecovの比較

類似のツールに coveralls がありますが、以下の観点で codecov を使うようにしています。

前提

  • codecovでアカウントを作成済

Codecovにカバレッジを送信し、視覚的に見れるように

Codecovにリポジトリを追加

Codecov にログインして、Repositoriesのタブから「Add New repository」のボタンを押すと、以下のようにGithub上のリポジトリが表示されるので、対象のリポジトリを選択します。 f:id:moritamorie:20210227221937p:plain

プライベートリポジトリの場合は、Settingの画面でActivateします。フリープランの場合、アクティベートできるリポジトリ数は1つのみです。 f:id:moritamorie:20210228013431p:plain

再度、Overviewを開くと、codecovトークンが表示されるので、コピーします。 f:id:moritamorie:20210227222000p:plain

GithubリポジトリでSecretsに追加

Github で対象のリポジトリのSettingsタブ→Secretsと選択し、「New repository secret」ボタンを押してNameに「CODECOV_TOKEN」、Valueにコピーしたトークンを入れて保存します。 f:id:moritamorie:20210227222651p:plain

Github actionsでGoのテストカバレッジをcodecovに送信

以下の yaml ファイルをプロジェクトに追加し、Github リポジトリにpushすると(またはPull Request作成時)、Github actionワークフローが実行されます。

Github actionワークフローの処理の中で、Githubアクションcodecov/codecov-action がSecretsに設定したトークンを使ってコードカバレッジCodecov に送ってくれます。

on: [push, pull_request]
name: Test
jobs:
  test:
    strategy:
      matrix:
        go-version: [1.15.x]
        os: [ubuntu-latest, macos-latest, windows-latest]

    runs-on: ${{ matrix.os }}
    steps:
    - name: Install Go
      uses: actions/setup-go@v2
      with:
        go-version: ${{ matrix.go-version }}

    - name: Checkout code
      uses: actions/checkout@v2

    - name: Test
      run: go test -race -coverprofile="coverage.txt" -covermode=atomic ./...

    - name: upload coverage
      uses: codecov/codecov-action@v1
      with:
        token: ${{ secrets.CODECOV_TOKEN }}

このGithubアクションは https://codecov.io/bashを実行し、ファイルをcodecovに送ってくれます。(参照したコード

この実行される bash の中で、カバレッジレポートだと思われるファイルを探して、マッチしたファイルを対象としてくれます。上記の yaml ファイルでは"coverage.txt"がレポートのファイルです。

Github actionsワークフロー実行後の確認

実行後、Codecovに戻ると以下のように、ソースコード上でテストスイートが実行された箇所が視覚的に見えるようになったり、ファイル毎・プロジェクト全体のカバレッジ等を把握できるようになります。 f:id:moritamorie:20210227230257p:plain

f:id:moritamorie:20210228154357p:plain

便利な機能

Codecov には、よりプロジェクトのカバレッジ状況を把握しやすくしたり、コントロールする機能があります。

Pull RequestにCodecovのレポートをコメントされるように

Codecovは、Github integration機能も提供していて、インストールするとPull Requestに自動的にレポートをコメントしてくれるようになります。

f:id:moritamorie:20210228152047p:plain

f:id:moritamorie:20210228151925p:plain

GithubリポジトリのREADMEにCoverageのバッヂを追加

再度 Codecov に戻り、以下のSettingページを参照し、Badgeのマークダウンをコピーします。

f:id:moritamorie:20210227231033p:plain

README.mdに貼り付け、Githubリポジトリを開くと、以下ののようにカバレッジが表示されたバッヂが付き、クリックするとCodecov の該当のプロジェクトのURLに遷移します。

f:id:moritamorie:20210227231419p:plain

Github上でPRを作った際

Github上でPRを作った際に、一定以上のカバレッジを満たさないといけないという制約を付けたい場合はcodecov.ymlを設定することによって実現可能です。

制約の例としては

などです。

DocsのCommon Configurationsを参照すると、実現したい設定と近いものが見つかるかもしれません。 docs.codecov.io

codecov.ymlの設定は、チーム単位/リポジトリ単位にそれぞれ柔軟に設定できます。

サンプルコード

github.com

参考資料

【Golang】go-sqlmock でGorm を使ったアプリケーションのテストを書く

go-sqlmock を使ったことがなかったので、Gormを使ってデータベースにアクセスするアプリケーションをテストするコードを書いてみました。

go-sqlmock の READMEに

sqlmock is a mock library implementing sql/driver. Which has one and only purpose - to simulate any sql driver behavior in tests, without needing a real database connection. sqlmockはsql/driverを実装したモックライブラリです。目的は一つだけで、実際のデータベース接続を必要とせずに、テストで任意のsqlドライバの動作をシミュレートすることです。

という記載があります。なので、Go標準のdatabase/sql パッケージでSQLクエリを発行する場合でも、Gorm のような ORM フレームワークを使った場合でもDBを使わずにテストも行うことができます。

前提

ディレクトリ/ファイル構成と概要

以下のシンプルな構成のサンプルアプリケーションにテストを追加してみます。

.
├── main.go
├── model
│   └── book.go
└── repository
    ├── book.go
    └── book_test.go

それぞれのファイルでやっていることの概要は

  • main.go
    • リポジトリ(repository/book.go)を介して、書籍(book)のデータを登録。
  • model/book.go
    • 書籍(book)を表す構造体を定義。
  • repository/book.go
    • データベースにアクセスし、SQLを発行して書籍(book)のデータを登録。そして、結果を返す。
  • repository/book_test.go
    • 追加するrepository/book.goのテスト。ここでgo-sqlmockを使ってsqlドライバの動作をシミュレート。

です。

サンプルアプリケーションを動かしてみる

main.go

まずは main.go の内容をみてみます。単純に書籍(book)のcreateを行いエラーが発生しないか確認しているだけです。

package main

import (
    "fmt"

    "github.com/moritamori/gorm-testing/model"
    "github.com/moritamori/gorm-testing/repository"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func main() {
    // DB接続を開く
    url := "dbname=gormtesting password=mypassword"
    db, err := gorm.Open(postgres.Open(url), &gorm.Config{})
    if err != nil {
        panic(err)
    }

    // リポジトリ(`repository/book.go`)を介して、書籍(book)のデータを登録
    bookRepository := repository.BookRepositoryImpl{DB: db}
    book := &model.Book{
        Title:  "Go言語の本",
        Author: "誰か",
    }
    err := bookRepository.Create(book)

    // エラーが発生しないかチェック
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("success!")
}

※ローカルで実行する際にはpostgresqlgormtestingデータベースとbooksテーブルが存在する必要があります。

model, repository (book.go)

書籍の構造体の定義( model )と実際のDBの処理( repository )を別のパッケージに分けたうえで、BookRepository というインタフェースを介して具体的なDBの処理は行う設計にしています。

こうすることで、パッケージの外から BookRepository を介してDB処理をする際、repositoryの中の実装を意識せずに済みます。

package model

import "gorm.io/gorm"

type Book struct {
    gorm.Model
    Title  string
    Author string
}
package repository

import (
    "gorm.io/gorm"
    "github.com/moritamori/gorm-testing/model"
)

type BookRepositoryImpl struct {
    DB *gorm.DB
}

type BookRepository interface {
    Create(book *model.Book) error
}

func (bookRepo BookRepositoryImpl) Create(book *model.Book) error {
    cx := bookRepo.DB.Create(book)
    return cx.Error
}

実行

実行すると、想定通り動いていることを確認できます。

$ go run main.go
success!

テストを追加

サンプルアプリケーションにテストを追加しました。テストのセットアップ、データベース接続のクローズテストを共通化するために testify を使っています。

package repository

import (
    "testing"

    "github.com/DATA-DOG/go-sqlmock"
    "github.com/moritamori/gorm-testing/model"
    "github.com/stretchr/testify/suite"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

// テストスイートの構造体
type BookRepositoryTestSuite struct {
    suite.Suite
    bookRepository BookRepositoryImpl
    mock           sqlmock.Sqlmock
}

// テストのセットアップ
// (sqlmockをNew、Gormで発行されるクエリがモックに送られるように)
func (suite *BookRepositoryTestSuite) SetupTest() {
    db, mock, _ := sqlmock.New()
    suite.mock = mock
    bookRepository := BookRepositoryImpl{}
    bookRepository.DB, _ = gorm.Open(postgres.New(postgres.Config{
        Conn: db,
    }), &gorm.Config{})
    suite.bookRepository = bookRepository
}

// テスト終了時の処理(データベース接続のクローズ)
func (suite *BookRepositoryTestSuite) TearDownTest() {
    db, _ := suite.bookRepository.DB.DB()
    db.Close()
}

// テストスイートの実行
func TestBookRepositoryTestSuite(t *testing.T) {
    suite.Run(t, new(BookRepositoryTestSuite))
}

// Createのテスト
func (suite *BookRepositoryTestSuite) TestCreate() {
    suite.Run("create a book", func() {
        newId := 1
        rows := sqlmock.NewRows([]string{"id"}).AddRow(newId)
        suite.mock.ExpectBegin()
        suite.mock.ExpectQuery(
            regexp.QuoteMeta(
                `INSERT INTO "books" ("created_at",` +
                    `"updated_at","deleted_at","title",` +
                    `"author") VALUES ($1,$2,$3,$4,$5) ` +
                    `RETURNING "id"`),
        ).WillReturnRows(rows)
        suite.mock.ExpectCommit()
        book := &model.Book{
            Title:  "Go言語の本",
            Author: "誰か",
        }
        err := suite.bookRepository.Create(book)

        if err != nil {
            suite.Fail("Error発生")
        }
        if book.ID != uint(newId) {
            suite.Fail("登録されるべきIDと異なっている")
        }
    })
}

testify を使う利点

このように testify を使うことでテストケースが増えた際、共通の関心事であるテストのセットアップ・終了時の処理のコードが重複しないテストコードを書けるというメリットがあります。

Gorm を使う際の考慮

トランザクションの考慮

Gormcreate/update/delete に関してはデフォルトでトランザクションが有効になるため、トランザクションのテスト(suite.mock.ExpectBegin()suite.mock.ExpectCommit)を書いています。

ExpectQueryExpectExec

また、 postgresql と Gormを一緒に使った注意点がこちらの記事に記載があって、通常Insert文は ExpectExec でクエリをチェックしますが postgresqlを使用する場合ExpectQuery でクエリをチェックしています。

In general, a non-query SQL expectation (e.g Insert/Update) should be defined by mock.ExpectExec, but this is a special case. For some reason, GROM uses QueryRow instead of Exec for thepostgres dialect (for more detail, please consult this issue).

Tip: Use mock.ExpectQuery for the GORM model insert if you’re using PostgreSQL.

という記載があり、参照先のGithub issueにも同様のコメントがあります。

Gorm V2におけるInsertの挙動

上記のissueが古く、Gorm v1だけに該当する話の可能性があったので、念の為Gorm v2のソースコードを用いてcreateするメソッド(CreateWithReturning)デバッグ実行して確認したところ、テーブルスキーマにデフォルト値がある場合QueryContextを実行していたので、ExpectQuery でクエリを指定するとマッチしそうです。

また、どのカラムにデフォルト値が付くか確認したところ、booksテーブルのgorm.Model追加されるIDカラムにデフォルト値が付いていたので、gorm.Model を使っていればExpectQuery で確認するのが良さそうです。

sqlmockを使う利点

sqlmockはデフォルトで期待する結果を厳密な順序で得られることを確認してくれるので、このサンプルでは

  • suite.mock.ExpectBegin()
  • suite.mock.ExpectQuery(regexp.QuoteMeta(INSERT INTO "books" 〜))
  • suite.mock.ExpectCommit()

という順番でモックに送られることを期待します。

テスト実行

実行すると、OKが返ります。

$ go test ./... -v
?       github.com/moritamori/gorm-testing  [no test files]
?       github.com/moritamori/gorm-testing/model    [no test files]
=== RUN   TestBookRepositoryTestSuite
=== RUN   TestBookRepositoryTestSuite/TestCreate
=== RUN   TestBookRepositoryTestSuite/TestCreate/create_a_book
--- PASS: TestBookRepositoryTestSuite (0.00s)
    --- PASS: TestBookRepositoryTestSuite/TestCreate (0.00s)
        --- PASS: TestBookRepositoryTestSuite/TestCreate/create_a_book (0.00s)
PASS
ok      github.com/moritamori/gorm-testing/repository   0.079s

今回のサンプルアプリケーション

コードをGithubにあげているので、もしよかったら参考にしてみてください! github.com

参考資料