tflintでカスタムルールを作って適用する話

この記事は[terraform Advent Calendar 2023](https://qiita.com/advent-calendar/2023/terraform)の20日目です。



## 前置き

Terraformをモノレポで管理する場合、複数のサービスで共通で利用するmoduleが出てくるかと思います。

moduleを利用する時は通常以下のように相対パスで指定すると思いますが、この実装ですとmodule側を変更した時に全てのサービスで変更が等しく反映されてしまうというデメリットがあります。

```hcl
module "hogehoge" {
  source = "../modules/hogehoge"
  ...
}
```

サービス側で変更のタイミングを個別に調整するためにはGitタグを発行して以下のような指定方法にすることで実現できます。

```hcl
module "hogehoge" {
  source = "git::https://github.com/org/terraform-repos.git//modules/hogehoge?ref=modules_hogehoge_v1.0.0"
  ...
}
```

モノレポ環境でのmodule管理の話しは以下記事で詳しく書かれているので深く知りたい方は一読をおすすめします。

https://blog.studysapuri.jp/entry/2022/03/30/080000

上述の通り、モノレポ環境でmoduleを運用していくには呼び出し方法を相対パスではなくGitタグで行うのが望ましいのですが、terraform単体ではmoduleを利用する全ての実装箇所でGitタグで指定することを強制できません。ここでtflintのカスタムルールを利用する動機が生まれる事になるかと思います（少なくとも私の会社ではそうでした）。

## tflint-ruleset-templateからテンプレートを作成する
ここからは実際にmoduleのsourceでGitタグ指定を強制するカスタムルールを作成する方法を解説していきます。

tflintのカスタムルールを作る場合はテンプレートが用意されているので、まずは以下からテンプレートを作成します。

https://github.com/terraform-linters/tflint-ruleset-template

テンプレートにはリリースに必要なファイルやカスタムルールを作成するに当たって参考になるルール実装例が含まれています。

## カスタムルールの実装
moduleのsourceでGitタグ指定を強制するルールは以下のように実装します。


```go
package rules

import (
	"fmt"
	"strings"

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

type TerraformModuleInvalidSourceRule struct {
	tflint.DefaultRule
}

func NewTerraformModuleInvalidSourceRule() *TerraformModuleInvalidSourceRule {
	return &TerraformModuleInvalidSourceRule{}
}

func (r *TerraformModuleInvalidSourceRule) Name() string {
	return "terraform_module_invalid_source"
}

func (r *TerraformModuleInvalidSourceRule) Enabled() bool {
	return true
}

func (r *TerraformModuleInvalidSourceRule) Severity() tflint.Severity {
	return tflint.ERROR
}

func (r *TerraformModuleInvalidSourceRule) Link() string {
	return ""
}

func (r *TerraformModuleInvalidSourceRule) Check(runner tflint.Runner) error {
	contents, err := runner.GetModuleContent(&hclext.BodySchema{
		Blocks: []hclext.BlockSchema{
			{
				Type:       "module",
				LabelNames: []string{"name"},
				Body: &hclext.BodySchema{
					Attributes: []hclext.AttributeSchema{
						{Name: "source"},
					},
				},
			},
		},
	}, &tflint.GetModuleContentOption{ModuleCtx: tflint.RootModuleCtxType})
	if err != nil {
		return err
	}

	for _, module := range contents.Blocks {
		attr, exists := module.Body.Attributes["source"]
		if !exists {
			if err := runner.EmitIssue(
				r,
				fmt.Sprintf("`%s` output has no source", module.Labels[0]),
				module.DefRange,
			); err != nil {
				return err
			}
			continue
		}

		if err := runner.EvaluateExpr(attr.Expr, func(source string) error {
			if isInvalidSource(source) {
				return runner.EmitIssue(
					r,
					fmt.Sprintf("%q is not allowed", source),
					attr.Expr.Range(),
				)
			}

			return nil
		}, nil); err != nil {
			return err
		}
	}

	return nil
}

func isInvalidSource(source string) bool {
	return strings.Contains(source, "../modules/")
}
```

HCLブロックをパースしてsourceの値に`../modules/`が含まれる場合をエラーとして検出します。

この実装ですと`./modules`や`../module`等で指定した場合はエラーにならないので相対パスを完全に禁止したい場合はGitタグ指定以外は許可しないような実装にするのが良いかと思います。

## テストコード

実際に動かす前に想定通りのルールの振る舞いになっているかを確認するためテストコードを書くことをおすすめします。今回ですと以下のようなテストコードを通れば最低限の振る舞いは担保できます。

```go
package rules

import (
	"testing"

	hcl "github.com/hashicorp/hcl/v2"
	"github.com/terraform-linters/tflint-plugin-sdk/helper"
)

func Test_TerraformModuleInvalidSource(t *testing.T) {
	tests := []struct {
		Name     string
		Content  string
		Expected helper.Issues
	}{
		{
			Name: "invalid source",
			Content: `
module "hogehoge" {
	source     = "../../modules/hogehoge"
	project_id = var.project_id
}`,
			Expected: helper.Issues{
				{
					Rule:    NewTerraformModuleInvalidSourceRule(),
					Message: `"../../modules/hogehoge" is not allowed`,
					Range: hcl.Range{
						Filename: "resource.tf",
						Start:    hcl.Pos{Line: 3, Column: 15},
						End:      hcl.Pos{Line: 3, Column: 48},
					},
				},
			},
		},
		{
			Name: "valid source (git::)",
			Content: `
module "hogehoge" {
	source     = "git::https://github.com/org/terraform-repos.git//modules/hogehoge?ref=modules_hogehoge_v0.1.0"
	project_id = var.project_id
}`,
			Expected: helper.Issues{},
		},
	}

	rule := NewTerraformModuleInvalidSourceRule()

	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)
		})
	}
}
```

## カスタムルールを実行する
カスタムルールを実行するには以下2つが必要になります。

- .tflint.hclを配置
- パーソナルアクセストークンを設定
- tflintコマンドを実行

### .tflint.hclを配置

```hcl
plugin "hoge" {
  enabled = true
  version = "0.0.1"
  source  = "github.com/org/terraform-repos"

  signing_key = <<-EOF
-----BEGIN PGP PUBLIC KEY BLOCK-----

xxxxxxx
-----END PGP PUBLIC KEY BLOCK-----
EOF
}
```

PGP鍵のセットアップ方法は以下の記事を参考にしてください。

https://zenn.dev/thaim/articles/2023-07-tflint-plugin-pgp-key

### パーソナルアクセストークンを設定
カスタムルールをプライベートリポジトリで管理している場合の注意点ですが、ローカルで何も設定せずにtflintを実行すると404になります。

対象のプライベートリポジトリへのアクセス権を付与したアクセストークンを発行して環境変数GITHUB_TOKENに設定するとtflintがプライベートリポジトリにアクセスできるようになります。

### tflintコマンドを実行

GITHUB_TOKENの設定含め以下でtflintを実行できます。

```console
export GITHUB_TOKEN=ghq_xxx
tflint --init --config .tflint.hcl
tflint --recursive --config .tflint.hcl
```

以上「tflintでカスタムルールを作って適用する話」でした。