はじめに
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関数から、追加したいカスタムルールを呼び出そう。
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使用はこちらを参照。
主な部分はコメントを入れてあるので、大体使い方は分かると思う。
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
モジュールを使って整形している。
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を自由に拡張できるようになった!