2
0

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-01-14

はじめに

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

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

プラグインのソースコード

ソース構成

ソース構成は以下のように作る。
rulesにプラグインの追加ルールを実装していこう。
Makefile、go.mod、go.sumは、基本的にGitHubの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関数から、追加したいカスタムルールを呼び出そう。

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使用はこちらを参照。

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

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モジュールを使って整形している。

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に以下を書いておく

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

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

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

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を自由に拡張できるようになった!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0