Terraformのプロジェクトローカルな小さなプロバイダ（プラグイン）を書いてみる

## はじめに

日常よく使う道具について「**いざとなったら拡張を書いてどうにかしてやるぞ！**」という自信を持つのは精神衛生上良いことです。

今回はTerraformに最低限の機能を持った自作プラグインを追加してみます。


## 簡単なTerraformのプラグインアーキテクチャの振り返り

簡単にTerraformのプラグインアーキテクチャについて振り返ってみます。

Terraform はプラグインアーキテクチャを採用しているソフトウェアであり、基本的なリソースのライフサイクル管理の機能はTerraform本体で提供しているのですが、AWS上などの実際の具体的なクラウドインフラ上のプロビジョニングは専用のプラグイン（例: [terraform-aws-provider](https://github.com/hashicorp/terraform-provider-aws)）を呼ぶ構造になっています。

```mermaid
sequenceDiagram
  terraform ->> terraform: 必要なプロバイダの割り出しなど
  terraform ->> terraform-aws-provider: プロバイダごとにプラグイン（プロセス）起動
  terraform ->> terraform-aws-provider: gRPCによる通信<br>例: EC2インスタンスを作ってくれ
  terraform-aws-provider ->> AWS: 実際のAWSへの働きかけ<br>例: https://ec2.amazonaws.com/?Action=RunInstances
```

Terraform本体からサブプロセスとしてプラグインを起動した後、プラグイン側でTerraform本体からの[gRPC](https://grpc.io/)通信を待ち受けTerraform本体と通信(IPC)します。ただしこれらの仕組みは今回プラグインを作る上ではあまり意識する必要はありません。

https://github.com/hashicorp/go-plugin


## プロジェクト内だけで動作する超簡単なプラグインを書いてみよう

プラグインを理解するために、プロジェクトローカルで動作するごくごく簡単なプラグインをGoで書いてみます。

以下のように起動して使う予定です。

```terraform
# "example.txt" ファイルにメッセージを出力する
resource "hello" "example" {
  provider = helloworld

  file = "example.txt"
}
```

ソースコードは以下から入手可能です。

https://gitlab.com/ksaitou/2024-04-03_simplest_local_terraform_provider


### Goプロジェクトの実装 〜 バイナリの生成

まずプラグインを構成するGoプロジェクトから見ていきましょう。

`file = "..."` で指定したファイル名に `Hello, World!` という内容を書き込むだけのTerraformリソースを提供します。

```go:main.go
package main

import (
	"os"

	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
	"github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
)

func main() {
	plugin.Serve(&plugin.ServeOpts{
		ProviderFunc: func() *schema.Provider {
			return &schema.Provider{
				ResourcesMap: map[string]*schema.Resource{
					// "hello" というリソースを定義
					"hello": {
						Create: func(d *schema.ResourceData, m any) error {
							// "file" という属性を指定可能
							filename := d.Get("file").(string)
							message := "Hello, World!"
							d.SetId(filename)
							// "message" という属性を参照可能
							d.Set("message", message)

							// "file" で指定されたパスへ書き込み
							os.WriteFile(filename, []byte(message), 0644)

							return nil
						},
						Read:   schema.Noop,
						Update: schema.Noop,
						Delete: schema.Noop,

						Schema: map[string]*schema.Schema{
							"file": {
								Type:     schema.TypeString,
								Required: true,
							},
						},
					},
				},
			}
		},
	})
}
```

Goプロジェクトの初期化およびビルドは以下のコマンドで行うことができます。（ビルドされたバイナリの置き場所以外は通常のGoプロジェクトと変わりません）

```bash:プロジェクトの初期化と依存性の取得
$ go mod init example.com/my-hello-provider
$ go mod tidy
```

```bash:プロジェクトのビルド
# Terraformプロジェクトローカルのプラグインフォルダにバイナリを出力する
$ go build -o ./terraform.d/plugins/example.com/my/helloworld/1.0.0/darwin_amd64/terraform-provider-helloworld_v1.0.0

# ※ "darwin_amd64" となっている部分は macOS (Intel) の場合に指定しているのであって、
# 他の環境(OS/CPU)の場合は別の文字列を指定してください
```


### Terraformプロジェクトの作成 〜 生成した自作プラグインのバイナリをプロジェクトから呼んでみる

`./terraform.d/plugins/example.com/my/helloworld/1.0.0/darwin_amd64/terraform-provider-helloworld_v1.0.0` に出力されたGoの実行ファイルをTerraformからプラグインとして起動してもらうようにします。

Goのプロジェクトと同じ階層に以下の `main.tf` を作成しましょう。

```terraform:main.tf
terraform {
  required_providers {
    helloworld = {
      version = "1.0.0"
      // `./terraform.d/plugins` 配下で
      // `example.com/my/helloworld/${バージョン番号}/${CPUアーキテクチャ}/terraform-provider-helloworld_v${バージョン番号}` というファイルが検索される
      source  = "example.com/my/helloworld"
    }
  }
}

provider "helloworld" {}

resource "hello" "example" {
  provider = helloworld
  file = "example.txt"
}
```

あとは通常通りTerraformを初期化(`terraform init`) ＆ 実行(`terraform apply`)すれば自作プラグインが動きます。

```bash:初期化
$ terraform init
...
Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
```

```bash:実行
$ terraform apply
...
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # hello.example will be created
  + resource "hello" "example" {
      + file = "example.txt"
      + id   = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

hello.example: Creating...
hello.example: Creation complete after 0s [id=example.txt]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
```

```bash:実行の結果生成されたファイルの確認
$ cat example.txt
Hello, World!
```

**プロジェクトローカルなTerraformプラグインをGoで実装して、呼び出せました！**


## Terraformプラグインの解決方法

自作のTerraformプラグインをローカルで解決させる方法に触れてきましたが、そもそもTerraformはプラグイン名からどうやってプラグインの場所や取得方法を解決しているのでしょうか？

以下のドキュメントに書いてあります。

> **The default way to install provider plugins is from a provider registry**. The origin registry for a provider is encoded in the provider's source address, like registry.terraform.io/hashicorp/aws. For convenience in the common case, Terraform allows omitting the hostname portion for providers on registry.terraform.io, so you can write shorter public provider addresses like hashicorp/aws.
>
> https://developer.hashicorp.com/terraform/cli/config/config-file#provider-installation


基本的には [Terraformのレジストリ](https://registry.terraform.io/) から動的にダウンロードして解決しているようです。

また、 `provider_installation { ... }` ブロックにて明示的にプロバイダ（プラグイン）の場所を指定できるようです。

本記事で述べているようなローカルなプラグインの解決については、以下のルールに基づきます。

> If a **`terraform.d/plugins` directory exists in the current working directory then Terraform will also include that directory**, regardless of your operating system. This behavior changes when you use the -chdir option with the init command. In that case, Terraform checks for the terraform.d/plugins directory in the launch directory and not in the directory you specified with -chdir.
>
> https://developer.hashicorp.com/terraform/cli/config/config-file#implied-local-mirror-directories

現在ディレクトリ配下に `terraform.d/plugins` があればそれも検索対象としてくれるようです。（他にもOSごとに特定のディレクトリパスを検索してくれるようです）


## まとめ

Terraformで小さなプラグインを書く方法を見てきました。もうちょっと発展したプラグインを書けばTerraformの内部の動きの洞察にも繋がりそうです。