Terraform の Provider-defined Function はどうやって実装されているのか

この記事は [terraform Advent Calendar 2024](https://qiita.com/advent-calendar/2024/terraform) の4日目の記事です。  
昨日は Yuya Takeyama さんによる [初めてのサービスでリソースを Terraform 化するときの私のアプローチ](https://blog.yuyat.jp/post/my-approach-to-using-terraform/) でした。

---

tflint を Provider-defined Function 対応させる中で Terraform 内部の実装方に苦労したので紹介します。

## Terraform の Provider-defined Function とは

Provider-defined Function は Terraform 1.8 から利用できるようになった機能です。

https://www.hashicorp.com/blog/terraform-1-8-improves-extensibility-with-provider-defined-functions

今までは Terraform の中で定義された ([Built-in Functions](https://developer.hashicorp.com/terraform/language/functions)) のみが関数として利用できました。

今までも Data source を利用することで同等の機能を実現することはできましたが、直感的ではなく読みづらいことや記述量が多くなるという欠点がありました。

Provider-defined Function の登場により、今までよりも直感的かつ簡単に Provider が用意した関数を利用することができるようになりました。
`provider::<プロバイダー名>::<関数名>(<引数>)` の形式で呼び出せます。

```hcl
data "shellescape_quote" "before" {
  string = "hoge"
}

output "before" {
    value = "echo ${data.shellescape_quote.before.quoted}"
}


output "after" {
    value = "echo ${provider::shellescape::quote(str)}"
}
```

## Provider-defined Function はどうやって実装されているのか

Terraform は [HCL](https://github.com/hashicorp/hcl) という言語で書かれたファイルをパースして処理を行います。
Terraform 内部では Go の [github.com/hashicorp/hcl](https://pkg.go.dev/github.com/hashicorp/hcl) module を使って構文解析をし、 解析した HCL の構文ツリーをもとに処理を行っています。

Provider-defined Function が実装されたのは https://github.com/hashicorp/terraform/pull/34394 です。
この pull request の変更を見ると [internal/lang/functions.go](https://github.com/hashicorp/terraform/blob/26188550c48c439bbc566e25a53d81229082052e/internal/lang/functions.go#L186-L196) に気になる差分があります。

```go
		// We'll also bring in any external functions that the caller provided
		// when constructing this scope. For now, that's just
		// provider-contributed functions, under a "provider::NAME::" namespace
		// where NAME is the local name of the provider in the current module.
		for providerLocalName, funcs := range s.ExternalFuncs.Provider {
			for funcName, fn := range funcs {
				name := fmt.Sprintf("provider::%s::%s", providerLocalName, funcName)
				s.funcs[name] = fn
			}
		}
	}
```

同じ関数内の前の処理では `s.funcs` に built-in function を追加する処理が書かれています。
つまり Terraform 内部では built-in function と provider-defined function は同じまとまりで扱われています。

また HCL の FunctionCall 定義は以下のようになっています。

https://github.com/hashicorp/hcl/blob/main/hclsyntax/spec.md

```ebnf
︙

Identifier = ID_Start (ID_Continue | '-')*;

︙

FunctionCall = Identifier "(" arguments ")";
Arguments = (
    () ||
    (Expression ("," Expression)* ("," | "...")?)
);

︙
```

しかし https://github.com/hashicorp/hcl/pull/639 では次のように `::` も `FunctionCall` の `Identifier` としてパースできるように変更が加えられています。

```go
		// For historical reasons, we represent namespaced function names
		// as strings with :: separating the names. If this was an attempt
		// to call a namespaced function then we'll try to distinguish
		// between an invalid namespace or an invalid name within a valid
		// namespace in order to give the user better feedback about what
		// is wrong.
		//
		// The parser guarantees that a function name will always
		// be a series of valid identifiers separated by "::" with no
		// other content, so we can be relatively unforgiving in our processing
		// here.
		if sepIdx := strings.LastIndex(e.Name, "::"); sepIdx != -1 {
			namespace := e.Name[:sepIdx+2]
			name := e.Name[sepIdx+2:]
```

ここで `namespace` はバリデーションとエラー処理のためだけに使われており、構文ツリーには出てきません。
Terraform には `::` を含む文字列として関数名を渡していることになります。

## tflint rule を実装するときのツラみ

以下は実際に tflint を Provider-defined function 対応したときの PR です。

https://github.com/terraform-linters/tflint-ruleset-terraform/pull/214

既存の provider の記述方法とは違い、今まではただの識別子が書かれていただけの関数名に provider 名が入るようになりました。
これによって `decodeProviderRef` という便利関数が使えなくなったのに加えて、パース処理は internal package 内に書かれているため tflint の中に書く必要がありました。
結果として強引に `FunctionCallExpr` をパースする処理を書くことで実装しました。

## まとめ

Terraform 1.8 から利用できるようになった Provider-defined Function は HCL の言語仕様としてのインターフェースをほとんど変更せず、 Terraform 側で期待する動作になるよう機能が実装されていました。

インターフェースを大きく変えないことによって早いタイミングでリリースできたのかなと思う一方で、正しく責務を分割しないと再利用しづらくなるというのを再認識しました。