TFLintのカスタムルールプラグインを作成する

# はじめに
TFLintはデフォルトで強力なルールセットを持っているが、それに加えてチームとして標準化したいルールが発生した時のためのカスタムルールを実装したプラグインを自作して拡張できるようになっている。

作成方法の日本語記事はありがたいことに以下が見つかるが、TFLintのAPI仕様が変わったのか、そのままでは動作しなかったので、見直ししつつ、下記記事中では扱われていないローカルビルドのプラグイン登録方法を試してみる。

https://tech.kanmu.co.jp/entry/2022/03/01/100717

# プラグインのソースコード
## ソース構成
ソース構成は以下のように作る。
rulesにプラグインの追加ルールを実装していこう。
Makefile、go.mod、go.sumは、基本的にGitHubの[tflint-ruleset-template](https://github.com/terraform-linters/tflint-ruleset-template)をコピーしておけば問題ない。

```
tflint_ruleset_myrule
├── Makefile
├── go.mod
├── go.sum
├── main.go
└── rules
    ├── aws_rds_cluster_must_be_encrypted.go
    └── aws_rds_cluster_must_be_encrypted_test.go
```

## main関数
プラグインのソースは以下のようになる。
まずはmain関数から、追加したいカスタムルールを呼び出そう。

```golang:main.go
package main

import (
	"github.com/terraform-linters/tflint-plugin-sdk/plugin"
	"github.com/terraform-linters/tflint-plugin-sdk/tflint"
	"tflint-ruleset-myrule/rules"
)

func main() {
	plugin.Serve(&plugin.ServeOpts{
		RuleSet: &tflint.BuiltinRuleSet{
			Name:    "example",
			Version: "0.1.0",
			Rules: []tflint.Rule{
				rules.NewAwsRdsClusterMustBeEncryptedRule(),
			},
		},
	})
}
```

## カスタムルール
カスタムルールは以下のように定義する。
tflint-plugin-sdkのAPIではloggerも用意されていてなかなか扱いやすい。
なお、SDKのAPI使用は[こちら](https://pkg.go.dev/github.com/terraform-linters/tflint-plugin-sdk/tflint)を参照。

主な部分はコメントを入れてあるので、大体使い方は分かると思う。

```golang:aws_rds_cluster_must_be_encrypted_test.go
package rules

import (
	"fmt"

	"github.com/terraform-linters/tflint-plugin-sdk/hclext"
	"github.com/terraform-linters/tflint-plugin-sdk/logger"
	"github.com/terraform-linters/tflint-plugin-sdk/tflint"
)

// おまじない1
type AwsRdsClusterMustBeEncryptedRule struct {
	tflint.DefaultRule
}

// おまじない2
func NewAwsRdsClusterMustBeEncryptedRule() *AwsRdsClusterMustBeEncryptedRule {
	return &AwsRdsClusterMustBeEncryptedRule{}
}

// ルール名
func (r *AwsRdsClusterMustBeEncryptedRule) Name() string {
	return "aws_rds_cluster_must_be_encrypted"
}

// ロード時デフォルトで有効にする場合は true を設定する
func (r *AwsRdsClusterMustBeEncryptedRule) Enabled() bool {
	return true
}

// エラーの重要度を設定
func (r *AwsRdsClusterMustBeEncryptedRule) Severity() tflint.Severity {
	return tflint.ERROR
}

// ルールのドキュメントのリンク先(省略可能)
func (r *AwsRdsClusterMustBeEncryptedRule) Link() string {
	return "https://rule-document.example.com/posts/12345"
}

// 実際の検査内容
func (r *AwsRdsClusterMustBeEncryptedRule) Check(runner tflint.Runner) error {
	resources, err := runner.GetResourceContent("aws_rds_cluster", &hclext.BodySchema{
		Attributes: []hclext.AttributeSchema{
			{Name: "storage_encrypted"},
		},
	}, nil)
	if err != nil {
		return err
	}

	// `TFLINT_LOG=debug` でログ出力する
	logger.Debug(fmt.Sprintf("Get %d instances", len(resources.Blocks)))

	for _, resource := range resources.Blocks {
		// storage_encryptedが存在しない場合は違反
		attribute, exists := resource.Body.Attributes["storage_encrypted"]
		if !exists {
			err := runner.EmitIssue(
				r,
				"`storage_encrypted` attribute not found",
				resource.DefRange,
			)
			if err != nil {
				return err
			}

			continue
		}

		// storage_encryptedが存在する場合は内容の検査
		var storageEncrypted string
		err := runner.EvaluateExpr(attribute.Expr, &storageEncrypted, nil)
		if err != nil {
			return err
		}

		err = runner.EnsureNoError(err, func() error {
			if storageEncrypted != "true" {
				// storage_encryptedがtrueでない場合は違反
				return runner.EmitIssue(
					r,
					"`storage_encrypted` must be true",
					attribute.Expr.Range(),
				)
			}
			return nil
		})
		if err != nil {
			return err
		}
	}

	return nil
}
```

### Linkについて
Linkについては、設定しておくと、エラーを検知したときに

```
Error: `storage_encrypted` must be true (aws_rds_cluster_must_be_encrypted)

  on 21_aurora.tf line 16:
  16:   storage_encrypted = false

Reference: https://rule-document.example.com/posts/12345
```

といった感じで、参照の表示がされるようになる。

### runner.EmitIssueについて
エラー(Issue)をあげたい場合は、```Check```関数内で```runner.EmitIssue```を実行すれば良い。とても簡単。

### runner.EvaluateExprとrunner.EnsureNoError
```runner.EvaluateExpr```でattributeの内容を取得する。
ただし、これは不測のエラーが発生する可能性があり、そこで使うのが、```runner.EnsureNoError```だ。
この関数でエラーが無いことを確認した場合のみ、funcで指定した内容が実行される。
あとは同様に、func内で取得したattributeの内容を検査して、必要に応じて```runner.EmitIssue```する。

## テストコード
TFLintのSDKにはテスト用の機能もAPIも提供されている。
少し長くなるが、以下のように定義をする。大半はロジックではなく```tests```のテストケースの記載だ。
```Content```で、入力となるHCLを渡す。
```Expected: helper.Issues```で、期待するルール(本体の「おまじない2」の部分)、メッセージ(```runner.EmitIssue```の第二引数)、ソースコード行数を設定して検査を行う。Golangのheredocは、最初のバッククォートの後に改行を入れてしまうと行数がカウントされてしまうため、分かりやすくするために```heredoc.Doc```モジュールを使って整形している。

```golang:aws_rds_cluster_must_be_encrypted_test.go
package rules

import (
	"testing"

	hcl "github.com/hashicorp/hcl/v2"

	"github.com/MakeNowJust/heredoc/v2"
	"github.com/terraform-linters/tflint-plugin-sdk/helper"
)

func Test_AwsRdsClusterMustBeEncrypted(t *testing.T) {
	tests := []struct {
		Name     string
		Content  string
		Expected helper.Issues
	}{
		{
			Name: "Single - Correct",
			Content: heredoc.Doc(`
				resource "aws_rds_cluster" "example1" {
					storage_encrypted = true
				}
			`),
			Expected: helper.Issues{},
		},
		{
			Name: "Single - Not found",
			Content: heredoc.Doc(`
				resource "aws_rds_cluster" "example1" {
				}
			`),
			Expected: helper.Issues{
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` attribute not found",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 1, Column: 1},
						End:      hcl.Pos{Line: 1, Column: 38},
					},
				},
			},
		},
		{
			Name: "Single - False value",
			Content: heredoc.Doc(`
				resource "aws_rds_cluster" "example1" {
					storage_encrypted = false
				}
			`),
			Expected: helper.Issues{
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` must be true",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 2, Column: 22},
						End:      hcl.Pos{Line: 2, Column: 27},
					},
				},
			},
		},
		{
			Name: "Multi - Correct",
			Content: heredoc.Doc(`
				resource "aws_rds_cluster" "example1" {
					storage_encrypted = true
				}

				resource "aws_rds_cluster" "example2" {
					storage_encrypted = true
				}
			`),
			Expected: helper.Issues{},
		},
		{
			Name: "Multi - 2 Not found",
			Content: heredoc.Doc(`
				resource "aws_rds_cluster" "example1" {
				}

				resource "aws_rds_cluster" "example2" {
				}
			`),
			Expected: helper.Issues{
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` attribute not found",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 1, Column: 1},
						End:      hcl.Pos{Line: 1, Column: 38},
					},
				},
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` attribute not found",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 4, Column: 1},
						End:      hcl.Pos{Line: 4, Column: 38},
					},
				},
			},
		},
		{
			Name: "Multi - 2 False value",
			Content: heredoc.Doc(`
				resource "aws_rds_cluster" "example1" {
					storage_encrypted = false
				}

				resource "aws_rds_cluster" "example2" {
					storage_encrypted = false
				}
			`),
			Expected: helper.Issues{
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` must be true",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 2, Column: 22},
						End:      hcl.Pos{Line: 2, Column: 27},
					},
				},
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` must be true",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 6, Column: 22},
						End:      hcl.Pos{Line: 6, Column: 27},
					},
				},
			},
		},
		{
			Name: "Multi - Not found -> False value",
			Content: heredoc.Doc(`
				resource "aws_rds_cluster" "example1" {
				}

				resource "aws_rds_cluster" "example2" {
					storage_encrypted = false
				}
			`),
			Expected: helper.Issues{
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` attribute not found",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 1, Column: 1},
						End:      hcl.Pos{Line: 1, Column: 38},
					},
				},
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` must be true",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 5, Column: 22},
						End:      hcl.Pos{Line: 5, Column: 27},
					},
				},
			},
		},
		{
			Name: "Multi - False value -> Not found",
			Content: heredoc.Doc(`
				resource "aws_rds_cluster" "example2" {
					storage_encrypted = false
				}

				resource "aws_rds_cluster" "example1" {
				}
			`),
			Expected: helper.Issues{
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` must be true",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 2, Column: 22},
						End:      hcl.Pos{Line: 2, Column: 27},
					},
				},
				{
					Rule:    NewAwsRdsClusterMustBeEncryptedRule(),
					Message: "`storage_encrypted` attribute not found",
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 5, Column: 1},
						End:      hcl.Pos{Line: 5, Column: 38},
					},
				},
			},
		},
	}

	rule := NewAwsRdsClusterMustBeEncryptedRule()

	for _, test := range tests {
		t.Run(test.Name, func(t *testing.T) {
			runner := helper.TestRunner(t, map[string]string{"resource.tf": test.Content})

			if err := rule.Check(runner); err != nil {
				t.Fatalf("Unexpected error occurred: %s", err)
			}

			helper.AssertIssues(t, test.Expected, runner.Issues)
		})
	}
}
```

実際に上記を```go test -v ./...```すると、以下の出力を得られる。

```
?       tflint-ruleset-myrule   [no test files]
=== RUN   Test_AwsRdsClusterMustBeEncrypted
=== RUN   Test_AwsRdsClusterMustBeEncrypted/Single_-_Correct
=== RUN   Test_AwsRdsClusterMustBeEncrypted/Single_-_Not_found
=== RUN   Test_AwsRdsClusterMustBeEncrypted/Single_-_False_value
=== RUN   Test_AwsRdsClusterMustBeEncrypted/Multi_-_Correct
=== RUN   Test_AwsRdsClusterMustBeEncrypted/Multi_-_2_Not_found
=== RUN   Test_AwsRdsClusterMustBeEncrypted/Multi_-_2_False_value
=== RUN   Test_AwsRdsClusterMustBeEncrypted/Multi_-_Not_found_->_False_value
=== RUN   Test_AwsRdsClusterMustBeEncrypted/Multi_-_False_value_->_Not_found
--- PASS: Test_AwsRdsClusterMustBeEncrypted (0.00s)
    --- PASS: Test_AwsRdsClusterMustBeEncrypted/Single_-_Correct (0.00s)
    --- PASS: Test_AwsRdsClusterMustBeEncrypted/Single_-_Not_found (0.00s)
    --- PASS: Test_AwsRdsClusterMustBeEncrypted/Single_-_False_value (0.00s)
    --- PASS: Test_AwsRdsClusterMustBeEncrypted/Multi_-_Correct (0.00s)
    --- PASS: Test_AwsRdsClusterMustBeEncrypted/Multi_-_2_Not_found (0.00s)
    --- PASS: Test_AwsRdsClusterMustBeEncrypted/Multi_-_2_False_value (0.00s)
    --- PASS: Test_AwsRdsClusterMustBeEncrypted/Multi_-_Not_found_->_False_value (0.00s)
    --- PASS: Test_AwsRdsClusterMustBeEncrypted/Multi_-_False_value_->_Not_found (0.00s)
PASS
ok      tflint-ruleset-myrule/rules     0.003s
```

# プラグインのインストール
Makefileに以下を書いておく

```makefile
install: build
	mkdir -p ~/.tflint.d/plugins
	mv ./tflint-ruleset-myrule ~/.tflint.d/plugins
```

これで、```make install```で必要なパスにビルド生成物が移動される。
気を付けなければいけないのは、モジュール名は、```tflint-ruleset-```で始まらなければいけないこと。
これを理解しないと、いつまでも「モジュール作ったはずなのに見つからん……」とハマる。

あとは、tflintのconfigファイルに以下を追加すればよい。```myrule```は適宜変更して問題ない。直す場合は、Makefileとgo.modを忘れないように気を付ける。

```HCL
plugin "myrule" {
  enabled = true
}
```

さて、これで、早速、
- storage_encrypted は定義したものの値がfalseのaws_rds_cluster.example1リソース
- storage_encrypted の定義すらしていないaws_rds_cluster.example2リソース
が入った.tfをtflintで検査してみよう。

```
$ tflint -c .tflint/configure.tf
2 issue(s) found:

Error: `storage_encrypted` must be true (aws_rds_cluster_must_be_encrypted)

  on 21_aurora.tf line 16:
  16:   storage_encrypted = false

Reference: https://rule-document.example.com/posts/12345

Error: `storage_encrypted` attribute not found (aws_rds_cluster_must_be_encrypted)

  on 21_aurora.tf line 22:
  22: resource "aws_rds_cluster" "example2" {

Reference: https://rule-document.example.com/posts/12345
```

動いた！途中で止まることなく2つとも検査されている！
これで、TFLintを自由に拡張できるようになった！