長生村本郷Engineers'Blog

千葉県長生村本郷育ちのエンジニアが書いているブログ

Go 静的解析 & 単体テスト on GitHub Actions

以前、複数の AWS Account EC2 インスタンスへの接続を EC2 Instance Connect を使用しインタラクティブssh 接続できるツールを作成しました。

kenzo0107.hatenablog.com

自分用にチャチャッと作ってアップしたもので手元でしかテストしておらず 、 lint や test もしてなかったのですが、

  • 後学の為、改めて lint, test した上でコードを管理できる様になりたい
  • 自分のリポジトリとして公開しているし、愛情持ちたい

という気持ちで取り組んでみたいと思いました。

成果物

github.com

静的解析 on GitHub Actions

grandcolline/golang-github-actions を利用させていただきまして以下解析しました。

GitHub Actions での設定ファイルは以下です。

gist.github.com

開発時にも静的解析

開発時に解析できる様、 Makefile に設定しました。

.PHONY: lint
lint: devel-deps
    go vet ./...
    go vet -vettool=$(shell which shadow)
    staticcheck ./...
    errcheck ./...
    # exclude G106: Audit the use of ssh.InsecureIgnoreHostKey
    gosec -quiet -exclude=G106 ./... 
    golint -set_exit_status ./...

goimports に関してはエディタの設定で保存時にチェックする様にしてます。

実際に設定して動かしてみたらわかりやすいですが、以下にどんなことを指摘してくるかまとめます。

go vet ./...

構文エラーを指摘してくれます。

a := fmt.Sprintf("%s", 1)
fmt.Println(a)

go vet -vettool=$(shell which shadow)

スコープの外側で定義した変数と同名の変数がスコープの内部で使用されている場合に警告されます。

潜在的にエラーが発生する可能性があり、変数名を変更する等の対応が必要です。

func hoge() error {
    err := errors.New("error occured")
    {
        err := errors.New("error occured")
        if err != nil {
            return err
        }
    }

    if err != nil {
        return err
    }
    return nil
}
./main.go:14:3: declaration of "err" shadows declaration at line 12

上記の場合、以下の様にスコープ内でスコープ外の変数を利用することで回避できます。

   err := errors.New("error occured")
    {
        err = errors.New("error occured")

ですが、この場合だと最初は err := で定義し、その後は err = で定義しなければならず、わかりづらいので、 変数 err を定義した直後に処理を実施する様にすると回避できます。

func hoge() error {
    err := errors.New("error occured")
    if err != nil {
        return err
    }

    {
        err := errors.New("error occured")
        if err != nil {
            return err
        }
    }
    return nil
}

staticcheck ./...

バグの検出、コードの簡素化の提案、デッドコードの指摘などをしてくれます。

以下のコードで staticcheck ./... を実行すると

import "github.com/aws/aws-sdk-go/aws/session"
...

session.New(&config)

session.New 使うのは非推奨だよ、と警告してくれました。

deprecated を指摘してくれるのありがたい!

session.New is deprecated: Use NewSession functions to create sessions instead. NewSession has the same functionality as New except an error can be returned when the func is called instead of waiting to receive an error until a request is made.  (SA1019)

以下の様に修正することで、対応できました。

session.Must(session.NewSession(&config))

errcheck ./...

error を返す function の処理をチェックしているか警告してくれます。

以下のコードで errcheck ./... を実行すると

f, err := os.Open(fpath)
...
defer f.Close()

以下の様に警告されます。

pkg/utility/profile.go:19:15:   defer f.Close()

f.Close() が error を返しますが、その error がチェックされていないですよ、という指摘です。

Close closes the File, rendering it unusable for I/O. On files that support SetDeadline, any pending I/O operations will be canceled and return immediately with an error.

func (*os.File).Close() error

無名関数で wrap してその中で error check する様にし対応しました。

defer func() {
    if err := f.Close(); err != nil {
        log.Fatalln(err)
    }
}()

gosec -quiet ./...

使用コードでの脆弱性を指摘してくれます。

現状の omssh では gosec -quiet ./... を実行してみると以下の様な警告が出ます。

G106 として管理されている ssh.InsecureIgnoreHostKey() を利用していることへの脆弱性が指摘されています。

Results:


[/Users/kenzo.tanaka/src/github.com/kenzo0107/omssh/omssh.go:50] - G106 (CWE-322): Use of ssh InsecureIgnoreHostKey should be audited (Confidence: HIGH, Severity: MEDIUM)
  > ssh.InsecureIgnoreHostKey()


Summary:
   Files: 9
   Lines: 718
   Nosec: 0
  Issues: 1

対応法を把握できていないので -exclude=G106 オプションで回避しています。

gosec で警告されがちなコード

よくありがちな gosec に警告されがちなコード例としては以下ではないでしょうか。

引数として、ファイルパスを渡して、そのファイルを開こうとしています。

func GetProfiles(credentialsPath string) (profiles []string, err error) {
    f, err := os.Open(credentialsPath)
        ...

このコードを gosec -quiet ./... してみると以下の様に警告されます。

[/Users/kenzo.tanaka/src/github.com/kenzo0107/omssh/pkg/utility/profile.go:16] - G304 (CWE-22): Potential file inclusion via variable (Confidence: HIGH, Severity: MEDIUM)
  > os.Open(credentialsPath)

こちらの対処法としては、ファイルパスを綺麗にしてくれる filepath.Clean(string) を噛ませると回避できました。

- f, err := os.Open(credentialsPath)
+ f, err := os.Open(filepath.Clean(credentialsPath))

../kenzo\hoge/moge というファイルパスだと ../kenzo\hoge/moge というファイルパスが返ります。

golint -set_exit_status ./...

-set_exit_status を指定しているのは、終了ステータスを返してくれます。

以下の様なコードがあるとします。

type EC2Iface interface {
   ...
}

golint -set_exit_status ./... すると以下の様なエラーを出力されます。

pkg/awsapi/ec2.go:12:6: exported type EC2Iface should have comment or be unexported
Found 1 lint suggestions; failing.

EC2Iface と大文字始まりなので、他パッケージからも参照できる exported type なのでコメントを書きましょうね、
という指摘です。

VSCode を使っていますが、この様にカーソルを合わせるとコメントが表示されてくれます。

丁寧に書いて置いて上げるとこんな値を返すよ〜とかわかりやすいですね。

f:id:kenzo0107:20191225151004p:plain

面倒くさがらずコメント書きましょう。

単体テスト on GitHub Actions

こちらはシンプルで test を実行し coverage を CodeCov に上げる様にしました。また、そのカバレッジを README にラベルとして表示できます。

CODECOV_TOKEN は GitHub の Settings > Secrets で設定しておきます。

codecov

gist.github.com

まとめ

静的解析と単体テストを追加したことで

  • コード変更がしやすくなった。
  • Golang の求めるコードの書き方を学ぶことができた。
  • aws-sdk-go の mock の作成の仕方を学ぶことができた。

というご利益がありました。

単体テストで 100% を目指しましたが、

ssh 接続周りで手こずってカバレッジが微増でした。

ssh 接続する際に仮想的な ssh server を起動する所まではよかったんですが、 (*ssh.Session).RequestPty するとそこで処理待ちが発生し、テストが進まなくなってしまいました。

良い案ありましたらご教示いただけましたら幸いです m(_ _)m

今回の知見を活かして

AWS の EC2 や RDS 等の Reserved Instance の使用率・カバレッジ率を Datadog にプロットする Lambda を生成する SAM プロジェクトを Go で作ってみました。

プロットしたメトリクスに監視することで使用率・カバレッジ率低下をアラートできます。

github.com

また、より OSS らしくロゴを作ってみました♪

愛情が増します。

f:id:kenzo0107:20191225173147p:plain

参照

stackoverflow.com

gosec で警告される os.Open() 対応

gosec で以下のようなコードがあると

os.Open(fname)

以下のように警告されます。

G304 (CWE-22): Potential file inclusion via variable (Confidence: HIGH, Severity: MEDIUM)

変数でファイルパスを指定するのは、意図しないファイルパスを指定される危険性があります。

対応

filepath.Clean() を使用し、よろしくないパスを綺麗に整えるようにします。

os.Open(filepath.Clean(fname))

以上 ご参考になれば幸いです。

Golang errcheck による defer 警告対応

概要

このようなコードを書いていると errcheck を実行した場合、 defer f.Close() と指摘されてしまいます。

    f, err := os.Open(fpath)
    if err != nil {
        log.Println(err)
        return hoge, err
    }

    defer f.Close()

f.Close() は返り値が error であり、その error の返り値をハンドリングしていない、という警告です。

対応

その為、以下のように修正することで回避

    f, err := os.Open(fpath)
    if err != nil {
        log.Println(err)
        return hoge, err
    }

    defer func() {
        err = f.Close()
        if err != nil {
            log.Fatalln(err)
        }
    }()

参考

随分前に既に掲題について話をしていた

github.com

Fix: can't find gem bundler (>= 0.a) with executable bundle (Gem::GemNotFoundException)

rbenv で複数 ruby バージョンが存在する環境下で bundle install しようとすると以下のエラーが出てしまいました。

can't find gem bundler (>= 0.a) with executable bundle (Gem::GemNotFoundException)
  • ruby バージョンは合ってる、
  • Gemfile もある、
  • gem install bundler して bundle もある ← ここがダメだった

けど、エラー

ちょいちょいハマってたので備忘録とりました。

結論

bundle のバージョン (2.0.2) が Gemfile.lock (1.17.1) と異なることで発生していました。

  • gem インストール時の bundler は 2.0.2
$ gem install bundler

Successfully installed bundler-2.0.2
Parsing documentation for bundler-2.0.2
Done installing documentation for bundler after 2 seconds
1 gem installed
  • Gemfile.lock での bundler は 1.17.1
...
RUBY VERSION
   ruby 2.5.3p105

BUNDLED WITH
   1.17.1

なので、実行する bundle のバージョンを Gemfile.lock 側に合わせてあげれば実行できるようになりました。

対応

$ gem install bundler -v 1.17.1
$ gem uninstall bundler -v 2.0.2

(>= 0.a) というのがパッと見、ん?となってしまい、あれ、設定したのにな、と思ってるとハマるので、このエラーメッセージを見たら反応できるようにしておきたい内容でした。

以上 参考になれば幸いです。

Ruby inject (Enumerable) メソッドを学ぶ

f:id:kenzo0107:20191123143247p:plain

概要

お題を通して Ruby で inject メソッドを学びました。その備忘録です。

お題

123,456 円を紙幣・硬貨が一番少なくなる様になる組み合わせを求めてください。

硬貨 ... 1, 5, 10, 50, 100, 500 円玉 紙幣 ... 1000, 2000, 5000, 10000 円札

答え

  • 10,000 円札 × 12
  • 5,000 円札 × 0
  • 2,000 円札 × 1
  • 1,000 円札 × 1
  • 500 円玉 × 0
  • 100 円玉 × 4
  • 50 円玉 × 1
  • 10 円玉 × 0
  • 5 円玉 × 1
  • 1 円玉 × 1

inject メソッド

配列の組み合わせで答えを出すとシンプルに書けます。

gist.github.com

大きい値から順に divmod の結果 [商, 余り]不定数個の配列 (a) の後ろに足して、 r に配列の一番後ろの要素 余り が入るので、それ次の iterator の計算に利用する方法です。

[] + 123456.divmode(10000) # [12, 3456]
[12, 3456] + 3456.divmod(5000) # [12, 0, 3456]
[12, 0, 3456] + 3456.divmod(2000) # [12, 0, 1, 1456]
[12, 0, 1, 1456] + 1456.divmod(1000) # [12, 0, 1, 1, 456]
[12, 0, 1, 1, 456] + 456.divmod(500) # [12, 0, 1, 1, 0, 456]
[12, 0, 1, 1, 0, 456] + 456.divmod(100) # [12, 0, 1, 1, 0, 4, 56]
[12, 0, 1, 1, 0, 456] + 56.divmod(50) # [12, 0, 1, 1, 0, 4, 1, 6]
[12, 0, 1, 1, 0, 4, 1, 6] + 6.divmod(10) # [12, 0, 1, 1, 0, 4, 1, 1, 0, 6]
[12, 0, 1, 1, 0, 4, 1, 1, 0, 6] + 6.divmod(5) # [12, 0, 1, 1, 0, 4, 1, 1, 0, 1]
[12, 0, 1, 1, 0, 4, 1, 1, 0, 1] + 1.divmod(1) # [12, 0, 1, 1, 0, 4, 1, 1, 0, 1, 1, 0]

最後の 0 が余計ですが、非常にシンプルな形で答えを求られました。

ただ、
この inject の計算式が初手でスッと出すのは慣れが必要な印象。

なので、
一旦 each で計算の手順を確認してから inject でコードがシンプルになりそうならやってみる、
っていうのが慣れるのに良さそうでした。

each メソッド

gist.github.com

each でやると loop の外側で b = [] と最終的に返す配列を定義する必要があります。

b << r if p.size - 1 == index は、不要ですが、回答の配列を inject メソッドの場合と合わせました。

benchmark とってみる

require "benchmark"

r = 123_456

Benchmark.bmbm do |x|
    x.report("div_inject") do
        10000.times do
            div_inject(r)
        end
    end

    x.report("div_each") do
        10000.times do
            div_each(r)
        end
    end
end

each の方が速かったです。

                 user     system      total        real
div_inject   0.037365   0.000524   0.037889 (  0.040733)
div_each     0.024333   0.000257   0.024590 (  0.025320)

個人的には inject の方が速くなってくれると本記事が締まったんですが 今回の計算ロジックでは、each の方がパフォーマンスがよかったです。

hash を扱ってみる

inject

gist.github.com

each

gist.github.com

benchmark とると、 each, hash どちらもほぼ同等でした。

まとめ

inject メソッドを例題を通して学びました。

例題では特にシンプルに書けましたが、パフォーマンスが格段に上がるという話ではないので 使い所は見定める必要があるかなと思いました。

以上 ご参考になれば幸いです。

Golang で関数のデフォルト引数を指定する

f:id:kenzo0107:20191105183003j:plain

概要

Ruby で関数のデフォルト引数を設定する場合は以下のように指定できます。

def hoge(a, b = 2)
    c = a + b
end

c = hoge(1)

p c
// c = 3

Golang だと以下の様なデフォルト引数の定義ができません。

func hoge(a, b = 2 int) string {
    c := a + b
    return c
}

こんな時にどうしようかと探っていると、 Functional Option Pattern に当たりました。

Functional Option Pattern

以下 2 記事が有名で必読です。

任意の値を定義する・しないで関数を分けると引数分、メソッドが増え、煩雑になります。

const defaultB = 2

func Hoge(a int) int {
    return a + defaultB
}

func HogeWithB(a, b int) int {
    return a + b
}

こういった煩雑さを解決する Golang の特性を活かした解決法が Functional Option Pattern です。

Functional Option Pattern で書き換え

package main

const (
    defaultB = 2
)

type configs struct {
    b int
}

type Option func(c *configs)

func WithB(v int) Option {
    return func(c *configs) {
        c.b = v
    }
}

var args *configs

func init() {
    args = &configs{
        b: defaultB,
    }
}

func Hoge(a int, options ...Option) int {
    for _, option := range options {
        option(args)
    }

    c := a + args.b

    return c
}

func main() {
    c := Hoge(1)
    fmt.Println(c) // 3

    d := Hoge(1, WithB(9))
    fmt.Println(d) // 10
}

デフォルト引数の指定が効いているのがわかります。

Option という引数に *configs の設定を持つ関数を定義し、 その関数で各値を任意で設定することで、デフォルトの設定を上書いています。

こうすることで、 設定したい任意の値だけを設定し、その他はデフォルト値を参照する、ということができます。

よし完璧だ!と思ったら...テストでこける。。。

fmt.Println(reflect.DeepEqual(WithB(12), WithB(12)) // false

WithB は関数型が返るので、 reflect.DeepEqual では false になります。

reflect.DeepEqual のコードを見てみると関数型 (reflect.Func) で Nil でなければ、 false が返るようになってます。

コメントにある // Can't do better than this: が推して知るべし。

https://github.com/golang/go/blob/master/src/reflect/deepequal.go#L126-L131

Functional Option Pattern にもう一手間加える

const (
    defaultB = 2
)

type configs struct {
    b int
}

type Option interface {
    Apply(*configs)
}

type B int

func (o B) Apply(c *configs) {
    c.b = int(o)
}
func WithB(v int) B {
    return B(v)
}

var args *configs

func init() {
    args = &configs{
        b: defaultB,
    }
}

func Hoge(a int, options ...Option) int {
    for _, option := range options {
        option.Apply(args)
    }

    c := a + args.b

    return c
}

func main() {
    c := Hoge(1)
    fmt.Println(c) // 3

    d := Hoge(1, WithB(9))
    fmt.Println(d) // 10

    fmt.Println(reflect.DeepEqual(WithB(12), WithB(12))) // true
}

WithB は int 型の値を返し、 reflect.DeepEqual は true を返します。

Option を interface で定義することで、どの様な型にでも呼び出せるのを利用しつつ、 Apply メソッドを定義して、 configs の上書きを図る、という算段です。

以下 googleapis/google-api-go-client で定義されている Functional Option Pattern が素敵だったので、こちらとても参考になります。

https://github.com/googleapis/google-api-go-client/blob/master/option/option.go

まとめ

軽い気持ちで関数のデフォルト引数の設定どうやるんだろうか? と調べたら思わぬ展開になって、奥深さを感じました。

以上 ご参考になれば幸いです。

スターティングGo言語 (CodeZine BOOKS)

スターティングGo言語 (CodeZine BOOKS)

  • 作者:松尾 愛賀
  • 出版社/メーカー: 翔泳社
  • 発売日: 2016/04/15
  • メディア: 単行本(ソフトカバー)

Datadog Agent for ECS Launch Type=EC2

f:id:kenzo0107:20191017124709p:plain

概要

ECS 起動タイプ EC2 にてタスク定義に datadog/agent:latest を設定したが メトリクスが取得できない事象がありました。

Infrastructure > Containers には datadog/agent:latest を設置したタスク定義内のコンテナ情報は一覧に表示されてますが、メトリクスが取れていない、という状況でした。

結論

https://docs.datadoghq.com/json/datadog-agent-ecs.json 参考に、以下の様な volume mount の設定が必要でした。

  datadog:
    image: datadog/agent:latest
    environment:
      DD_API_KEY: ${DD_API_KEY}
    logging:
      driver: awslogs
      options:
        awslogs-group: ${LOG_GROUP}
        awslogs-region: ${REGION}
        awslogs-stream-prefix: datadog
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /proc/:/host/proc:ro
      - /sys/fs/cgroup/:/host/sys/fs/cgroup:ro

ドキュメントよく読もう、を身につまされる想いでした。

参考

Amazon Elastic Container Service (ECS)