Terraformドキュメントやsnippetを出力するCLIツール、tfdocをGoで作った

Terraformを書くとき、各リソースをどう書けばいいのか、常にウェブで Terraform Documentation を見ながら書いていたんだけど、ブラウザとエディタを行き来するのが結構面倒だった。Ansibleにはドキュメントとスニペットを吐き出す
ansible-doc というCLIツールがあるので、これと同じものがTerraformにもあったら、コマンドでドキュメント確認もできて便利だと考えて作ってみた。

chroju/tfdoc

※むしろこの手の入力補助ツールないとすげーTerraform辛くない??と思うんだけど、今のところあんまり良いのが存在しないので、みなさまどうやって普段tfファイル書いているのか気になるところ。

動作

完全に ansible-doc をリスペクトして作っている。

使い方は引数に目当てのリソース名を与えて実行するだけ。ざっくりこういう感じで出力される。

$ tfdoc aws_instance | head -n 20
aws_instance
Provides an EC2 instance resource. This allows instances to be created, updated,and deleted. Instances also support provisioning.

Argument Reference (= is mandatory):


= ami
  (Required) The AMI to use for the instance.

- availability_zone
  (Optional) The AZ to start the instance in.

- placement_group
  (Optional) The Placement Group to start the instance in.

- tenancy
  (Optional) The tenancy of the instance (if the instance is running in a VPC).
  An instance with a tenancy of dedicated runs on single-tenant hardware.
  The host tenancy is not supported for the import-instance command.

スニペット形式で出力したいときは、 -s オプションを付ける。リダイレクトでファイルに書き込んでからエディタで開いて作成を始めるととても楽。

$ tfdoc -s aws_subnet
resource "aws_subnet" "sample" {
  // (Optional) The AZ for the subnet.
  availability_zone = ""

  // (Required) The CIDR block for the subnet.
  cidr_block = ""

  // (Optional) The IPv6 network range for the subnet,in CIDR notation. The subnet size must use a /64 prefix length.
  ipv6_cidr_block = ""

  // (Optional) Specify true to indicatethat instances launched into the subnet should be assigneda public IP address. Default is false.
  map_public_ip_on_launch = ""

  // (Optional) Specify true to indicatethat network interfaces created in the specified subnet should beassigned an IPv6 address. Default is false
  assign_ipv6_address_on_creation = ""

  // (Required) The VPC ID.
  vpc_id = ""

  // (Optional) A mapping of tags to assign to the resource.
  tags = ""

}

他にも、ドキュメントを実際に見たいよねってときもあるのでドキュメントURLを吐き出す --url というオプションを付けたり、スニペットのフォーマットを少しずつ調整するためのオプションも用意したりしている。詳細はREADMEを参照で。

実装

最近はCLIツール作成にはGoがよく使われている傾向も見かけるので、習得も兼ねてGoを使って実装した。

Terraformドキュメントを提供しているのが、ウェブのHTMLによるドキュメントしかないようだったので、そこから goquery でせこせことスクレイピングして出力している。そのためインターネットに繋がらない環境だと動作しないんだけど、まぁTerraformをインターネット断絶環境で使う物好きな方もそんなにいないだろうと。

予めスクレイピングした結果を、tfdocのリポジトリ内に含めてしまって、ローカルから結果を吐くことも出来るとは思うけど、それをやると最新のドキュメントを追跡するのが結構大変そうだったし(自動でやらせればいいんだけど)、現状はリアルタイムにスクレイピングさせている。

現状のソースを抜き出すとこんな感じ。案外Terraformドキュメントの書式やHTMLの構造が一定ではなくて、スクレイピングってまぁ便利だけど悪手なのは確かだなと思った次第。現状全リソースでテストは出来ていないので、抜け漏れはあるのかもしれない。

func scrapeTfResource(name string, res *http.Response) (*TfResource, error) {
    var ret = TfResource{Name: name}

    // Load the HTML document
    doc, err := goquery.NewDocumentFromReader(res.Body)
    if err != nil {
        err = fmt.Errorf("HTML Read error: %s", err)
        return nil, err
    }

    ret.Description = strings.Replace(strings.TrimSpace(doc.Find("#inner > p").First().Text()), "\n", "", -1)
    doc.Find("#inner > ul").Each(func(i int, selection *goquery.Selection) {
        if i == 0 {
            selection.Children().Each(func(_ int, li *goquery.Selection) {
                arg := scrapingResourceList(li)
                ret.Args = append(ret.Args, arg)
            })
        } else {
            fieldName := selection.Prev().Find("code,strong").Text()
            for i, arg := range ret.Args {
                if arg.Name == fieldName {
                    selection.Children().Each(func(_ int, li *goquery.Selection) {
                        ret.Args[i].NestedField = append(ret.Args[i].NestedField, scrapingResourceList(li))
                    })
                }
            }
        }
    })

    return &ret, nil
}

func scrapingResourceList(li *goquery.Selection) *tfResourceArg {
    a := &tfResourceArg{}
    a.Name = li.Find("a > code").Text()
    a.Description = strings.TrimSpace(strings.SplitN(li.Text(), "-", 2)[1])
    a.Description = strings.Replace(a.Description, "\n", "", -1)
    if strings.Contains(strings.SplitN(li.Text(), " ", 3)[2], "Required") {
        a.Required = true
    } else {
        a.Required = false
    }
    return a
}

特にリソースでネストになっている部分(例えばAWS: aws_instance - Terraform by HashiCorp)の処理が面倒であった。

またコマンドラインオプションを実装するにあたり、ショートオプションとロングオプションを両方使いたい、ロングオプションはGNUスタイル(Go製CLIツールでよくある「-hoge」というハイフン1つスタイルではなく、「--hoge」のスタイル)にしたいという気持ちがあったので、それらを満たす pflag を使っている。

Goについて

Goでまとまったライブラリを作ったのは、これが初めてなんだけど、バイナリでCLIツールを配布できる、クロスコンパイルが容易にできるというのはやっぱり楽。

またテストのための機能が内包されていたり、go lintgo fmt のようなコーディングを補助してくれる機能が豊富で、プログラミングを学ぶにあたってもとても良い言語だなと感じた。

このツールを育てながら、もっとGoのプラクティスを学んでいきたい。

今後について

  • 出力に色を使ったり、太字を使ったりしてもっと見やすくしたい。
  • スニペットのフォーマットを terraform fmt による整形後のような形にしたい。
  • テストカバレッジ上げたい。
  • 何か他に良い機能があったら追加していきたいが、シンプルさを忘れないようにはしたい。
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.